Skip to content

Commit 94479ee

Browse files
authored
Merge pull request #74 from TRS-WOB/fix/v16-item-wise-tax-details
fix: v16 compatibility for item_wise_tax_detail in _build_amounts_per_vat_rate
2 parents c8d5844 + c885349 commit 94479ee

2 files changed

Lines changed: 219 additions & 52 deletions

File tree

erpnext_tse/erpnext_tse/doctype/dsfinv_k_cash_point_closing/dsfinv_k_cash_point_closing.py

Lines changed: 108 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,9 @@ def _build_cash_point_closing_head(doc, transactions: list[dict[str, Any]]) -> d
217217
}
218218

219219
if getattr(doc, "posting_date", None):
220-
head["business_date"] = doc.posting_date
220+
# In v16 posting_date can be a datetime.date object which is not
221+
# JSON-serializable. Convert to ISO format string explicitly.
222+
head["business_date"] = str(doc.posting_date)
221223

222224
return head
223225

@@ -331,6 +333,14 @@ def _build_transaction_from_pos_invoice(pos_inv, company_currency: str) -> dict[
331333

332334

333335
def _build_amounts_per_vat_definition(pos_inv) -> list[dict[str, Any]]:
336+
"""
337+
Build VAT breakdown per DSFinV-K VAT definition for a POS Invoice.
338+
339+
In v15 the tax details are stored as a JSON field `item_wise_tax_detail`
340+
on each tax row. In v16 this field no longer exists — the data is in the
341+
Child Table `item_wise_tax_details` (DocType: "Item Wise Tax Detail").
342+
Both formats are supported with v15 as primary and v16 as fallback.
343+
"""
334344
items = pos_inv.get("items") or []
335345
if not items:
336346
frappe.throw(_("POS Invoice has no items."))
@@ -339,14 +349,21 @@ def _build_amounts_per_vat_definition(pos_inv) -> list[dict[str, Any]]:
339349
if not taxes:
340350
frappe.throw(_("POS Invoice has no taxes rows."))
341351

352+
# v15: item_code -> net amount (addiert bei doppeltem item_code auf mehreren Zeilen)
342353
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] = {}
343356
for item_row in items:
344357
item_code = getattr(item_row, "item_code", None) or (
345358
item_row.get("item_code") if isinstance(item_row, dict) else None
346359
)
347360
if not item_code:
348361
continue
349362

363+
row_name = getattr(item_row, "name", None) or (
364+
item_row.get("name") if isinstance(item_row, dict) else None
365+
)
366+
350367
base_net_amount = (
351368
getattr(item_row, "base_net_amount", None)
352369
if not isinstance(item_row, dict)
@@ -359,7 +376,10 @@ def _build_amounts_per_vat_definition(pos_inv) -> list[dict[str, Any]]:
359376
else item_row.get("net_amount")
360377
)
361378

362-
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
363383

364384
if not net_amount_by_item_code:
365385
frappe.throw(_("POS Invoice items are missing item_code values."))
@@ -376,19 +396,45 @@ def parse_item_wise_detail(detail_value) -> tuple[float, float]:
376396
return rate_percent, tax_amount
377397
return 0.0, 0.0
378398

379-
for tax_row in taxes:
380-
tax_account = getattr(tax_row, "account_head", None) or (
381-
tax_row.get("account_head") if isinstance(tax_row, dict) else None
382-
)
383-
item_wise_tax_detail_json = (
384-
getattr(tax_row, "item_wise_tax_detail", None)
385-
if not isinstance(tax_row, dict)
386-
else tax_row.get("item_wise_tax_detail")
399+
# --- v16 Child Table fallback (same approach as _build_amounts_per_vat_rate) ---
400+
item_wise_tax_details_table = pos_inv.get("item_wise_tax_details") or []
401+
if not item_wise_tax_details_table and pos_inv.name:
402+
item_wise_tax_details_table = frappe.get_all(
403+
"Item Wise Tax Detail",
404+
filters={"parent": pos_inv.name, "parenttype": pos_inv.doctype},
405+
fields=["tax_row", "item_row", "rate", "amount", "taxable_amount"],
406+
order_by="idx",
387407
)
388408

389-
if not tax_account or not item_wise_tax_detail_json:
390-
continue
391-
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] = {}
413+
if item_wise_tax_details_table:
414+
for detail_row in item_wise_tax_details_table:
415+
tax_row_name = getattr(detail_row, "tax_row", None) or (
416+
detail_row.get("tax_row") if isinstance(detail_row, dict) else None
417+
)
418+
item_row_name = getattr(detail_row, "item_row", None) or (
419+
detail_row.get("item_row") if isinstance(detail_row, dict) else None
420+
)
421+
if tax_row_name and item_row_name:
422+
rate = float(
423+
getattr(detail_row, "rate", 0)
424+
or (detail_row.get("rate", 0) if isinstance(detail_row, dict) else 0)
425+
)
426+
amount = float(
427+
getattr(detail_row, "amount", 0)
428+
or (detail_row.get("amount", 0) if isinstance(detail_row, dict) else 0)
429+
)
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."""
392438
vat_id = vat_id_by_tax_account.get(tax_account)
393439
if not vat_id:
394440
vat_id = frappe.db.get_value(
@@ -399,19 +445,57 @@ def parse_item_wise_detail(detail_value) -> tuple[float, float]:
399445
_("No DSFinV-K VAT Rate mapping found for Tax Account '{0}'.").format(tax_account)
400446
)
401447
vat_id_by_tax_account[tax_account] = int(vat_id)
448+
return int(vat_id)
402449

403-
item_wise_details = frappe.parse_json(item_wise_tax_detail_json)
404-
if not isinstance(item_wise_details, dict):
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:
405456
continue
406457

407-
for item_code, detail_value in item_wise_details.items():
408-
if item_code not in net_amount_by_item_code:
458+
# v15 path: JSON field on tax row
459+
item_wise_tax_detail_json = (
460+
getattr(tax_row, "item_wise_tax_detail", None)
461+
if not isinstance(tax_row, dict)
462+
else tax_row.get("item_wise_tax_detail")
463+
)
464+
465+
if item_wise_tax_detail_json:
466+
item_wise_details = frappe.parse_json(item_wise_tax_detail_json)
467+
if not isinstance(item_wise_details, dict):
409468
continue
410469

411-
rate_percent, tax_amount = parse_item_wise_detail(detail_value)
412-
bucket = vat_sums.setdefault(int(vat_id), {"excl_vat": 0.0, "vat": 0.0})
413-
bucket["excl_vat"] += net_amount_by_item_code[item_code]
414-
bucket["vat"] += float(tax_amount or 0)
470+
# VAT-Mapping erst auflösen wenn wir tatsächlich Daten haben
471+
vat_id = _resolve_vat_id(tax_account)
472+
473+
for item_code, detail_value in item_wise_details.items():
474+
if item_code not in net_amount_by_item_code:
475+
continue
476+
477+
rate_percent, tax_amount = parse_item_wise_detail(detail_value)
478+
bucket = vat_sums.setdefault(int(vat_id), {"excl_vat": 0.0, "vat": 0.0})
479+
bucket["excl_vat"] += net_amount_by_item_code[item_code]
480+
bucket["vat"] += float(tax_amount or 0)
481+
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.
485+
tax_row_name = getattr(tax_row, "name", None) or (
486+
tax_row.get("name") if isinstance(tax_row, dict) else None
487+
)
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))
491+
if not detail:
492+
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)
496+
bucket = vat_sums.setdefault(int(vat_id), {"excl_vat": 0.0, "vat": 0.0})
497+
bucket["excl_vat"] += item_net_amount
498+
bucket["vat"] += float(detail["amount"] or 0)
415499

416500
if not vat_sums:
417501
frappe.throw(_("Could not derive VAT amounts for POS Invoice {0}.").format(pos_inv.name))
@@ -498,7 +582,9 @@ def _build_payment_types_for_pos_invoice(pos_inv, company_currency: str) -> list
498582

499583

500584
def _get_pos_invoices_from_closing(doc) -> list[Document]:
501-
rows = doc.get("pos_transactions") or []
585+
# In v15 the child table is called "pos_transactions",
586+
# in v16 it was renamed to "pos_invoices".
587+
rows = doc.get("pos_invoices") or doc.get("pos_transactions") or []
502588
if not rows:
503589
frappe.throw(_("POS Closing Entry has no POS Transactions."))
504590

erpnext_tse/erpnext_tse/doctype/tse_transaction/tse_transaction.py

Lines changed: 111 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,8 @@ def _build_amounts_per_vat_rate(pos_inv) -> list[dict[str, str]]:
165165
3) Pro Steuerzeile wird der Steuer-Account (account_head) über den DocType "TSE VAT Rate"
166166
auf einen fiskaly VAT Code gemappt (z. B. NORMAL / REDUCED_1).
167167
4) Über item_wise_tax_detail wird pro Item der Steuerbetrag und der Steuersatz ermittelt.
168+
In v15 liegt das als JSON-Feld auf der Steuerzeile, in v16 als Child-Tabelle
169+
"Item Wise Tax Detail" auf der POS Invoice. Beide Formate werden unterstützt.
168170
Für diesen Schritt berücksichtigen wir aktuell nur 19% und 7%.
169171
5) Für jedes Item wird der Bruttobetrag berechnet: net_amount + tax_amount
170172
und danach je VAT Code aufsummiert.
@@ -182,15 +184,21 @@ def _build_amounts_per_vat_rate(pos_inv) -> list[dict[str, str]]:
182184
if not taxes:
183185
frappe.throw(_("POS Invoice has no taxes rows."))
184186

185-
# item_code -> net amount (base bevorzugt, fallback net_amount)
187+
# v15: item_code -> net amount (addiert bei doppeltem item_code auf mehreren Zeilen)
186188
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] = {}
187191
for item_row in invoice_items:
188192
item_code = getattr(item_row, "item_code", None) or (
189193
item_row.get("item_code") if isinstance(item_row, dict) else None
190194
)
191195
if not item_code:
192196
continue
193197

198+
row_name = getattr(item_row, "name", None) or (
199+
item_row.get("name") if isinstance(item_row, dict) else None
200+
)
201+
194202
base_net_amount = (
195203
getattr(item_row, "base_net_amount", None)
196204
if not isinstance(item_row, dict)
@@ -203,7 +211,10 @@ def _build_amounts_per_vat_rate(pos_inv) -> list[dict[str, str]]:
203211
else item_row.get("net_amount")
204212
)
205213

206-
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
207218

208219
if not net_amount_by_item_code:
209220
frappe.throw(_("POS Invoice items are missing item_code values. Cannot build VAT breakdown."))
@@ -229,52 +240,122 @@ def parse_item_wise_detail(detail_value) -> tuple[float, float]:
229240

230241
return 0.0, 0.0
231242

243+
# --- v16 Child-Tabelle vorbereiten (Fallback wenn JSON-Feld leer) ---
244+
# In ERPNext v16 existiert das JSON-Feld `item_wise_tax_detail` nicht mehr.
245+
# Die Daten liegen stattdessen in der Child-Tabelle `item_wise_tax_details`
246+
# (DocType: "Item Wise Tax Detail") auf der POS Invoice.
247+
item_wise_tax_details_table = pos_inv.get("item_wise_tax_details") or []
248+
if not item_wise_tax_details_table and pos_inv.name:
249+
item_wise_tax_details_table = frappe.get_all(
250+
"Item Wise Tax Detail",
251+
filters={"parent": pos_inv.name, "parenttype": pos_inv.doctype},
252+
fields=["tax_row", "item_row", "rate", "amount", "taxable_amount"],
253+
order_by="idx",
254+
)
255+
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] = {}
260+
if item_wise_tax_details_table:
261+
for detail_row in item_wise_tax_details_table:
262+
tax_row_name = getattr(detail_row, "tax_row", None) or (
263+
detail_row.get("tax_row") if isinstance(detail_row, dict) else None
264+
)
265+
item_row_name = getattr(detail_row, "item_row", None) or (
266+
detail_row.get("item_row") if isinstance(detail_row, dict) else None
267+
)
268+
if tax_row_name and item_row_name:
269+
rate = float(
270+
getattr(detail_row, "rate", 0)
271+
or (detail_row.get("rate", 0) if isinstance(detail_row, dict) else 0)
272+
)
273+
amount = float(
274+
getattr(detail_row, "amount", 0)
275+
or (detail_row.get("amount", 0) if isinstance(detail_row, dict) else 0)
276+
)
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
292+
232293
for tax_row in taxes:
233294
tax_account = getattr(tax_row, "account_head", None) or (
234295
tax_row.get("account_head") if isinstance(tax_row, dict) else None
235296
)
297+
298+
if not tax_account:
299+
continue
300+
301+
# v15-Pfad: JSON-Feld item_wise_tax_detail auf der Steuerzeile
236302
item_wise_tax_detail_json = (
237303
getattr(tax_row, "item_wise_tax_detail", None)
238304
if not isinstance(tax_row, dict)
239305
else tax_row.get("item_wise_tax_detail")
240306
)
241307

242-
# Steuerzeilen ohne Account oder ohne Details können nicht verwendet werden (z. B. leere/sonstige Charges)
243-
if not tax_account or not item_wise_tax_detail_json:
244-
continue
308+
if item_wise_tax_detail_json:
309+
# JSON aus item_wise_tax_detail parsen
310+
item_wise_details = frappe.parse_json(item_wise_tax_detail_json)
311+
if not isinstance(item_wise_details, dict):
312+
continue
245313

246-
# Tax Account -> VAT Code (über Mapping DocType), mit Cache
247-
vat_code = vat_code_by_tax_account.get(tax_account)
248-
if not vat_code:
249-
vat_code = frappe.db.get_value("TSE VAT Rate", {"account": tax_account}, "vat_rate_code")
250-
if not vat_code:
251-
frappe.throw(_("No TSE VAT Rate mapping found for Tax Account '{0}'.").format(tax_account))
252-
vat_code_by_tax_account[tax_account] = vat_code
314+
# VAT-Mapping erst auflösen wenn wir tatsächlich Daten haben
315+
vat_code = _resolve_vat_code(tax_account)
253316

254-
# JSON aus item_wise_tax_detail parsen
255-
item_wise_details = frappe.parse_json(item_wise_tax_detail_json)
256-
if not isinstance(item_wise_details, dict):
257-
continue
317+
# Pro Item auswerten
318+
for item_code, detail_value in item_wise_details.items():
319+
# Wenn Keys nicht matchen (z. B. Item Name statt Item Code), wird dieses Item übersprungen
320+
if item_code not in net_amount_by_item_code:
321+
continue
258322

259-
# Pro Item auswerten
260-
for item_code, detail_value in item_wise_details.items():
261-
# Wenn Keys nicht matchen (z. B. Item Name statt Item Code), wird dieses Item übersprungen
262-
if item_code not in net_amount_by_item_code:
263-
continue
323+
rate_percent, tax_amount = parse_item_wise_detail(detail_value)
264324

265-
rate_percent, tax_amount = parse_item_wise_detail(detail_value)
325+
# Aktuell nur 19% / 7%
326+
if rate_percent not in (19.0, 7.0):
327+
continue
266328

267-
# Aktuell nur 19% / 7%
268-
if rate_percent not in (19.0, 7.0):
269-
continue
329+
# Bruttoanteil je Item: net + tax
330+
item_net_amount = net_amount_by_item_code[item_code]
331+
item_gross_amount = item_net_amount + float(tax_amount or 0)
270332

271-
# Bruttoanteil je Item: net + tax
272-
item_net_amount = net_amount_by_item_code[item_code]
273-
item_gross_amount = item_net_amount + float(tax_amount or 0)
333+
gross_amount_by_vat_code[vat_code] = (
334+
gross_amount_by_vat_code.get(vat_code, 0.0) + item_gross_amount
335+
)
274336

275-
gross_amount_by_vat_code[vat_code] = (
276-
gross_amount_by_vat_code.get(vat_code, 0.0) + item_gross_amount
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.
340+
tax_row_name = getattr(tax_row, "name", None) or (
341+
tax_row.get("name") if isinstance(tax_row, dict) else None
277342
)
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))
346+
if not detail:
347+
continue
348+
rate_percent = detail["rate"]
349+
tax_amount = detail["amount"]
350+
if rate_percent not in (19.0, 7.0):
351+
continue
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)
355+
item_gross_amount = item_net_amount + float(tax_amount or 0)
356+
gross_amount_by_vat_code[vat_code] = (
357+
gross_amount_by_vat_code.get(vat_code, 0.0) + item_gross_amount
358+
)
278359

279360
if not gross_amount_by_vat_code:
280361
frappe.throw(_("Could not derive VAT amounts (no 19%/7% data found in item_wise_tax_detail)."))

0 commit comments

Comments
 (0)