@@ -187,10 +187,11 @@ def parse_peppol_xml(xml_bytes, edocument_profile, edocument=None):
187187 pi_data .get ("supplier" ),
188188 document_elements ,
189189 matched_items = matched_items ,
190+ is_return = pi_data .get ("is_return" ),
190191 )
191192
192- # Parse taxes
193- pi_data [ "taxes" ] = parse_peppol_taxes ( root , namespaces )
193+ # Remove taxes - will be populated from templates by guess_tax_templates
194+ pi_data . pop ( "taxes" , None )
194195
195196 # Post-process: guess missing values
196197 guess_missing_values (pi_data )
@@ -276,7 +277,13 @@ def parse_peppol_buyer(root, namespaces):
276277
277278
278279def parse_peppol_line_items (
279- root , namespaces , purchase_order = None , supplier = None , document_elements = None , matched_items = None
280+ root ,
281+ namespaces ,
282+ purchase_order = None ,
283+ supplier = None ,
284+ document_elements = None ,
285+ matched_items = None ,
286+ is_return = False ,
280287):
281288 """
282289 Parse line items from PEPPOL XML.
@@ -343,8 +350,14 @@ def parse_peppol_line_items(
343350
344351 # Quantity and UOM (use document-specific quantity element name)
345352 qty_elem = invoice_line .find (f".//cbc:{ quantity_elem_name } " , namespaces )
353+ negative_qty = False
346354 if qty_elem is not None and qty_elem .text :
347355 item ["qty" ] = flt_or_none (qty_elem .text )
356+ # ERPNext does not allow negative quantities on non-return invoices
357+ # Move the sign from quantity to rate to preserve the line total
358+ if item ["qty" ] is not None and item ["qty" ] < 0 and not is_return :
359+ negative_qty = True
360+ item ["qty" ] = abs (item ["qty" ])
348361 unit_code = qty_elem .get ("unitCode" )
349362 if unit_code :
350363 # Store unit_code for later UOM guessing
@@ -360,11 +373,14 @@ def parse_peppol_line_items(
360373 # rate = PriceAmount / BaseQuantity
361374 net_rate = float (price_text )
362375 base_qty = float (base_qty_text ) if base_qty_text else 1.0
363- item ["rate" ] = net_rate / base_qty if base_qty else net_rate
376+ rate = net_rate / base_qty if base_qty else net_rate
377+ item ["rate" ] = - rate if negative_qty else rate
364378
365379 # Line total
366380 if line_total_text :
367381 item ["amount" ] = flt_or_none (line_total_text )
382+ if negative_qty and item ["amount" ] is not None :
383+ item ["amount" ] = - abs (item ["amount" ])
368384
369385 # Tax rate (for reference, actual tax is in taxes table)
370386 tax_category = invoice_line .find (".//cac:Item/cac:ClassifiedTaxCategory" , namespaces )
@@ -610,27 +626,76 @@ def guess_missing_values(pi_data):
610626 if not item .get ("purchase_order" ):
611627 item ["purchase_order" ] = pi_data ["purchase_order" ]
612628
613- # Guess tax accounts for taxes from existing Purchase Taxes templates
629+ # Set tax templates based on item tax rates
630+ guess_tax_templates (pi_data )
631+
632+
633+ def guess_tax_templates (pi_data ):
634+ """Set header or item tax templates based on item tax rates.
635+
636+ If all items share the same tax rate, set a header Purchase Taxes and Charges Template.
637+ If items have different tax rates, set Item Tax Template on each item.
638+ """
614639 company = pi_data .get ("company" )
615- for tax in pi_data .get ("taxes" , []):
616- if not tax .get ("account_head" ) and tax .get ("rate" ) and company :
617- tax_rate = tax ["rate" ]
618- account_head = frappe .db .sql (
619- """SELECT child.account_head
620- FROM `tabPurchase Taxes and Charges` child
621- JOIN `tabPurchase Taxes and Charges Template` parent ON child.parent = parent.name
622- WHERE child.rate = %s AND parent.company = %s AND parent.disabled = 0
623- ORDER BY parent.is_default DESC
624- LIMIT 1""" ,
625- (tax_rate , company ),
640+ if not company :
641+ return
642+
643+ items = pi_data .get ("items" , [])
644+ if not items :
645+ return
646+
647+ # Collect unique tax rates from items
648+ tax_rates = {item ["tax_rate" ] for item in items if item .get ("tax_rate" ) is not None }
649+ if not tax_rates :
650+ return
651+
652+ if len (tax_rates ) == 1 :
653+ # All items have the same rate — use header template
654+ rate = tax_rates .pop ()
655+ template = frappe .db .get_value (
656+ "Purchase Taxes and Charges" ,
657+ {
658+ "rate" : rate ,
659+ "charge_type" : "On Net Total" ,
660+ "parenttype" : "Purchase Taxes and Charges Template" ,
661+ "parent" : [
662+ "in" ,
663+ frappe .get_all (
664+ "Purchase Taxes and Charges Template" ,
665+ filters = {"company" : company , "disabled" : 0 },
666+ pluck = "name" ,
667+ ),
668+ ],
669+ },
670+ "parent" ,
671+ )
672+ if template :
673+ pi_data ["taxes_and_charges" ] = template
674+ else :
675+ # Mixed rates — set Item Tax Template per item
676+ for item in items :
677+ rate = item .get ("tax_rate" )
678+ if rate is None :
679+ continue
680+
681+ item_tax_template = frappe .db .get_value (
682+ "Item Tax Template Detail" ,
683+ {
684+ "tax_rate" : rate ,
685+ "parenttype" : "Item Tax Template" ,
686+ "parent" : [
687+ "in" ,
688+ frappe .get_all (
689+ "Item Tax Template" ,
690+ filters = {"company" : company , "disabled" : 0 },
691+ pluck = "name" ,
692+ ),
693+ ],
694+ },
695+ "parent" ,
626696 )
627- if account_head :
628- tax ["account_head" ] = account_head [0 ][0 ]
629- if not tax .get ("description" ):
630- if tax .get ("rate" ):
631- tax ["description" ] = _ ("VAT {0}%" ).format (tax ["rate" ])
632- else :
633- tax ["description" ] = _ ("Tax" )
697+ if item_tax_template :
698+ item ["item_tax_template" ] = item_tax_template
634699
635700
636701def guess_po_details (pi_data ):
0 commit comments