Skip to content

Commit c885349

Browse files
committed
fix: address review feedback — duplicate item_code + lazy VAT mapping
Addresses two points raised in review of PR #74: 1. Duplicate item_code on multiple invoice lines v16 lookup key is now (tax_row_name, item_row_name) instead of (tax_row_name, item_code). Each POS Invoice Item row is processed individually — two lines with the same item_code no longer collapse under one shared key. A separate net_amount_by_row_name dict tracks net amounts per unique row. For the v15 path, net_amount_by_item_code now accumulates with += instead of overwriting on duplicates. 2. Lazy VAT mapping resolution _resolve_vat_code / _resolve_vat_id are now deferred until we actually have relevant detail data to process. Tax rows without usable item_wise_tax_detail data no longer force a mapping lookup — matching the original pre-v16 behavior of silently skipping such rows (tse_transaction.py:243 style continue). Applied symmetrically in both _build_amounts_per_vat_rate (tse_transaction.py) and _build_amounts_per_vat_definition (dsfinv_k_cash_point_closing.py). Tested on v16 with 6 scenarios (all via HTTP): - 19% only - 7% only - Mixed 19% + 7% - Duplicate item_code 19% (2x same item, different amounts) - Duplicate item_code 7% - Duplicate 19% + single 7% (3 lines, 2 with same item_code)
1 parent 53fb15f commit c885349

2 files changed

Lines changed: 87 additions & 68 deletions

File tree

erpnext_tse/erpnext_tse/doctype/dsfinv_k_cash_point_closing/dsfinv_k_cash_point_closing.py

Lines changed: 45 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -349,14 +349,21 @@ def _build_amounts_per_vat_definition(pos_inv) -> list[dict[str, Any]]:
349349
if not taxes:
350350
frappe.throw(_("POS Invoice has no taxes rows."))
351351

352+
# v15: item_code -> net amount (addiert bei doppeltem item_code auf mehreren Zeilen)
352353
net_amount_by_item_code: dict[str, float] = {}
354+
# v16: item_row name -> net amount (eindeutig pro Zeile, vermeidet Merge bei gleichem item_code)
355+
net_amount_by_row_name: dict[str, float] = {}
353356
for item_row in items:
354357
item_code = getattr(item_row, "item_code", None) or (
355358
item_row.get("item_code") if isinstance(item_row, dict) else None
356359
)
357360
if not item_code:
358361
continue
359362

363+
row_name = getattr(item_row, "name", None) or (
364+
item_row.get("name") if isinstance(item_row, dict) else None
365+
)
366+
360367
base_net_amount = (
361368
getattr(item_row, "base_net_amount", None)
362369
if not isinstance(item_row, dict)
@@ -369,7 +376,10 @@ def _build_amounts_per_vat_definition(pos_inv) -> list[dict[str, Any]]:
369376
else item_row.get("net_amount")
370377
)
371378

372-
net_amount_by_item_code[item_code] = float(base_net_amount or 0)
379+
amt = float(base_net_amount or 0)
380+
net_amount_by_item_code[item_code] = net_amount_by_item_code.get(item_code, 0.0) + amt
381+
if row_name:
382+
net_amount_by_row_name[row_name] = amt
373383

374384
if not net_amount_by_item_code:
375385
frappe.throw(_("POS Invoice items are missing item_code values."))
@@ -396,20 +406,11 @@ def parse_item_wise_detail(detail_value) -> tuple[float, float]:
396406
order_by="idx",
397407
)
398408

399-
# Lookup: (tax_row_name, item_code) -> {rate, amount}
400-
iwtd_by_tax_item: dict[tuple[str, str], dict] = {}
409+
# v16 Lookup: (tax_row_name, item_row_name) -> {rate, amount}
410+
# Schlüssel ist item_row_name (eindeutig pro Invoice-Zeile), damit mehrere Zeilen mit
411+
# gleichem item_code getrennt bleiben und nicht unter einem gemeinsamen Key zusammenfallen.
412+
iwtd_by_tax_item_row: dict[tuple[str, str], dict] = {}
401413
if item_wise_tax_details_table:
402-
item_code_by_row_name = {}
403-
for item_row in items:
404-
row_name = getattr(item_row, "name", None) or (
405-
item_row.get("name") if isinstance(item_row, dict) else None
406-
)
407-
ic = getattr(item_row, "item_code", None) or (
408-
item_row.get("item_code") if isinstance(item_row, dict) else None
409-
)
410-
if row_name and ic:
411-
item_code_by_row_name[row_name] = ic
412-
413414
for detail_row in item_wise_tax_details_table:
414415
tax_row_name = getattr(detail_row, "tax_row", None) or (
415416
detail_row.get("tax_row") if isinstance(detail_row, dict) else None
@@ -426,21 +427,14 @@ def parse_item_wise_detail(detail_value) -> tuple[float, float]:
426427
getattr(detail_row, "amount", 0)
427428
or (detail_row.get("amount", 0) if isinstance(detail_row, dict) else 0)
428429
)
429-
item_code = item_code_by_row_name.get(item_row_name)
430-
if item_code:
431-
iwtd_by_tax_item[(tax_row_name, item_code)] = {
432-
"rate": rate,
433-
"amount": amount,
434-
}
435-
436-
for tax_row in taxes:
437-
tax_account = getattr(tax_row, "account_head", None) or (
438-
tax_row.get("account_head") if isinstance(tax_row, dict) else None
439-
)
440-
441-
if not tax_account:
442-
continue
443-
430+
iwtd_by_tax_item_row[(tax_row_name, item_row_name)] = {
431+
"rate": rate,
432+
"amount": amount,
433+
}
434+
435+
def _resolve_vat_id(tax_account: str) -> int:
436+
"""Löst Tax Account -> DSFinV-K VAT Definition Export ID (mit Cache). Wird lazy
437+
aufgerufen, damit Steuerzeilen ohne nutzbare Detail-Daten kein Mapping erzwingen."""
444438
vat_id = vat_id_by_tax_account.get(tax_account)
445439
if not vat_id:
446440
vat_id = frappe.db.get_value(
@@ -451,6 +445,15 @@ def parse_item_wise_detail(detail_value) -> tuple[float, float]:
451445
_("No DSFinV-K VAT Rate mapping found for Tax Account '{0}'.").format(tax_account)
452446
)
453447
vat_id_by_tax_account[tax_account] = int(vat_id)
448+
return int(vat_id)
449+
450+
for tax_row in taxes:
451+
tax_account = getattr(tax_row, "account_head", None) or (
452+
tax_row.get("account_head") if isinstance(tax_row, dict) else None
453+
)
454+
455+
if not tax_account:
456+
continue
454457

455458
# v15 path: JSON field on tax row
456459
item_wise_tax_detail_json = (
@@ -464,6 +467,9 @@ def parse_item_wise_detail(detail_value) -> tuple[float, float]:
464467
if not isinstance(item_wise_details, dict):
465468
continue
466469

470+
# VAT-Mapping erst auflösen wenn wir tatsächlich Daten haben
471+
vat_id = _resolve_vat_id(tax_account)
472+
467473
for item_code, detail_value in item_wise_details.items():
468474
if item_code not in net_amount_by_item_code:
469475
continue
@@ -473,17 +479,22 @@ def parse_item_wise_detail(detail_value) -> tuple[float, float]:
473479
bucket["excl_vat"] += net_amount_by_item_code[item_code]
474480
bucket["vat"] += float(tax_amount or 0)
475481

476-
elif iwtd_by_tax_item:
477-
# v16 path: Child Table "Item Wise Tax Detail"
482+
elif iwtd_by_tax_item_row:
483+
# v16 path: Child Table "Item Wise Tax Detail" — iteriert über item_row_names
484+
# (eindeutig), damit mehrere Zeilen mit gleichem item_code getrennt verarbeitet werden.
478485
tax_row_name = getattr(tax_row, "name", None) or (
479486
tax_row.get("name") if isinstance(tax_row, dict) else None
480487
)
481-
for item_code in net_amount_by_item_code:
482-
detail = iwtd_by_tax_item.get((tax_row_name, item_code))
488+
vat_id = None
489+
for item_row_name, item_net_amount in net_amount_by_row_name.items():
490+
detail = iwtd_by_tax_item_row.get((tax_row_name, item_row_name))
483491
if not detail:
484492
continue
493+
# VAT-Mapping lazy: erst auflösen wenn wir relevante Daten haben
494+
if vat_id is None:
495+
vat_id = _resolve_vat_id(tax_account)
485496
bucket = vat_sums.setdefault(int(vat_id), {"excl_vat": 0.0, "vat": 0.0})
486-
bucket["excl_vat"] += net_amount_by_item_code[item_code]
497+
bucket["excl_vat"] += item_net_amount
487498
bucket["vat"] += float(detail["amount"] or 0)
488499

489500
if not vat_sums:

erpnext_tse/erpnext_tse/doctype/tse_transaction/tse_transaction.py

Lines changed: 42 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -184,15 +184,21 @@ def _build_amounts_per_vat_rate(pos_inv) -> list[dict[str, str]]:
184184
if not taxes:
185185
frappe.throw(_("POS Invoice has no taxes rows."))
186186

187-
# item_code -> net amount (base bevorzugt, fallback net_amount)
187+
# v15: item_code -> net amount (addiert bei doppeltem item_code auf mehreren Zeilen)
188188
net_amount_by_item_code: dict[str, float] = {}
189+
# v16: item_row name -> net amount (eindeutig pro Zeile, vermeidet Merge bei gleichem item_code)
190+
net_amount_by_row_name: dict[str, float] = {}
189191
for item_row in invoice_items:
190192
item_code = getattr(item_row, "item_code", None) or (
191193
item_row.get("item_code") if isinstance(item_row, dict) else None
192194
)
193195
if not item_code:
194196
continue
195197

198+
row_name = getattr(item_row, "name", None) or (
199+
item_row.get("name") if isinstance(item_row, dict) else None
200+
)
201+
196202
base_net_amount = (
197203
getattr(item_row, "base_net_amount", None)
198204
if not isinstance(item_row, dict)
@@ -205,7 +211,10 @@ def _build_amounts_per_vat_rate(pos_inv) -> list[dict[str, str]]:
205211
else item_row.get("net_amount")
206212
)
207213

208-
net_amount_by_item_code[item_code] = float(base_net_amount or 0)
214+
amt = float(base_net_amount or 0)
215+
net_amount_by_item_code[item_code] = net_amount_by_item_code.get(item_code, 0.0) + amt
216+
if row_name:
217+
net_amount_by_row_name[row_name] = amt
209218

210219
if not net_amount_by_item_code:
211220
frappe.throw(_("POS Invoice items are missing item_code values. Cannot build VAT breakdown."))
@@ -244,20 +253,11 @@ def parse_item_wise_detail(detail_value) -> tuple[float, float]:
244253
order_by="idx",
245254
)
246255

247-
# Lookup: (tax_row_name, item_code) -> {rate, amount}
248-
iwtd_by_tax_item: dict[tuple[str, str], dict] = {}
256+
# v16 Lookup: (tax_row_name, item_row_name) -> {rate, amount}
257+
# Schlüssel ist item_row_name (eindeutig pro Invoice-Zeile), damit mehrere Zeilen mit
258+
# gleichem item_code getrennt bleiben und nicht unter einem gemeinsamen Key zusammenfallen.
259+
iwtd_by_tax_item_row: dict[tuple[str, str], dict] = {}
249260
if item_wise_tax_details_table:
250-
item_code_by_row_name = {}
251-
for item_row in invoice_items:
252-
row_name = getattr(item_row, "name", None) or (
253-
item_row.get("name") if isinstance(item_row, dict) else None
254-
)
255-
ic = getattr(item_row, "item_code", None) or (
256-
item_row.get("item_code") if isinstance(item_row, dict) else None
257-
)
258-
if row_name and ic:
259-
item_code_by_row_name[row_name] = ic
260-
261261
for detail_row in item_wise_tax_details_table:
262262
tax_row_name = getattr(detail_row, "tax_row", None) or (
263263
detail_row.get("tax_row") if isinstance(detail_row, dict) else None
@@ -274,12 +274,21 @@ def parse_item_wise_detail(detail_value) -> tuple[float, float]:
274274
getattr(detail_row, "amount", 0)
275275
or (detail_row.get("amount", 0) if isinstance(detail_row, dict) else 0)
276276
)
277-
item_code = item_code_by_row_name.get(item_row_name)
278-
if item_code:
279-
iwtd_by_tax_item[(tax_row_name, item_code)] = {
280-
"rate": rate,
281-
"amount": amount,
282-
}
277+
iwtd_by_tax_item_row[(tax_row_name, item_row_name)] = {
278+
"rate": rate,
279+
"amount": amount,
280+
}
281+
282+
def _resolve_vat_code(tax_account: str) -> str:
283+
"""Löst Tax Account -> VAT Code (mit Cache). Wird lazy aufgerufen, damit
284+
Steuerzeilen ohne nutzbare Detail-Daten kein Mapping erzwingen."""
285+
vat_code = vat_code_by_tax_account.get(tax_account)
286+
if not vat_code:
287+
vat_code = frappe.db.get_value("TSE VAT Rate", {"account": tax_account}, "vat_rate_code")
288+
if not vat_code:
289+
frappe.throw(_("No TSE VAT Rate mapping found for Tax Account '{0}'.").format(tax_account))
290+
vat_code_by_tax_account[tax_account] = vat_code
291+
return vat_code
283292

284293
for tax_row in taxes:
285294
tax_account = getattr(tax_row, "account_head", None) or (
@@ -289,14 +298,6 @@ def parse_item_wise_detail(detail_value) -> tuple[float, float]:
289298
if not tax_account:
290299
continue
291300

292-
# Tax Account -> VAT Code (über Mapping DocType), mit Cache
293-
vat_code = vat_code_by_tax_account.get(tax_account)
294-
if not vat_code:
295-
vat_code = frappe.db.get_value("TSE VAT Rate", {"account": tax_account}, "vat_rate_code")
296-
if not vat_code:
297-
frappe.throw(_("No TSE VAT Rate mapping found for Tax Account '{0}'.").format(tax_account))
298-
vat_code_by_tax_account[tax_account] = vat_code
299-
300301
# v15-Pfad: JSON-Feld item_wise_tax_detail auf der Steuerzeile
301302
item_wise_tax_detail_json = (
302303
getattr(tax_row, "item_wise_tax_detail", None)
@@ -310,6 +311,9 @@ def parse_item_wise_detail(detail_value) -> tuple[float, float]:
310311
if not isinstance(item_wise_details, dict):
311312
continue
312313

314+
# VAT-Mapping erst auflösen wenn wir tatsächlich Daten haben
315+
vat_code = _resolve_vat_code(tax_account)
316+
313317
# Pro Item auswerten
314318
for item_code, detail_value in item_wise_details.items():
315319
# Wenn Keys nicht matchen (z. B. Item Name statt Item Code), wird dieses Item übersprungen
@@ -330,20 +334,24 @@ def parse_item_wise_detail(detail_value) -> tuple[float, float]:
330334
gross_amount_by_vat_code.get(vat_code, 0.0) + item_gross_amount
331335
)
332336

333-
elif iwtd_by_tax_item:
334-
# v16-Pfad: Child-Tabelle "Item Wise Tax Detail"
337+
elif iwtd_by_tax_item_row:
338+
# v16-Pfad: Child-Tabelle "Item Wise Tax Detail" — iteriert über item_row_names
339+
# (eindeutig), damit mehrere Zeilen mit gleichem item_code getrennt verarbeitet werden.
335340
tax_row_name = getattr(tax_row, "name", None) or (
336341
tax_row.get("name") if isinstance(tax_row, dict) else None
337342
)
338-
for item_code in net_amount_by_item_code:
339-
detail = iwtd_by_tax_item.get((tax_row_name, item_code))
343+
vat_code = None
344+
for item_row_name, item_net_amount in net_amount_by_row_name.items():
345+
detail = iwtd_by_tax_item_row.get((tax_row_name, item_row_name))
340346
if not detail:
341347
continue
342348
rate_percent = detail["rate"]
343349
tax_amount = detail["amount"]
344350
if rate_percent not in (19.0, 7.0):
345351
continue
346-
item_net_amount = net_amount_by_item_code[item_code]
352+
# VAT-Mapping lazy: erst auflösen wenn wir relevante Daten haben
353+
if vat_code is None:
354+
vat_code = _resolve_vat_code(tax_account)
347355
item_gross_amount = item_net_amount + float(tax_amount or 0)
348356
gross_amount_by_vat_code[vat_code] = (
349357
gross_amount_by_vat_code.get(vat_code, 0.0) + item_gross_amount

0 commit comments

Comments
 (0)