@@ -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
333335def _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
500584def _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
0 commit comments