Skip to content

Commit 90be39f

Browse files
authored
Merge pull request #42 from prilk-consulting/fix/incoming-negative-qty-and-tax-template-guess
fix: handle negative qty, use tax templates, and fix company detection
2 parents 0dee95f + 6bbe5f6 commit 90be39f

File tree

2 files changed

+94
-29
lines changed

2 files changed

+94
-29
lines changed

edocument/edocument/doctype/edocument/edocument.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -277,16 +277,16 @@ def before_save(self):
277277
)
278278

279279
# Auto-detect EDocument fields (company, etc.) from XML using profile-specific detector
280-
if self.edocument_profile and has_xml and not self.company:
280+
if self.edocument_profile and has_xml:
281281
try:
282282
xml_bytes = self._get_xml_from_attached_files()
283283
if xml_bytes:
284284
from edocument.edocument.detector import get_edocument_fields
285285

286286
detected_fields = get_edocument_fields(xml_bytes, self.edocument_profile)
287-
# Set detected fields on the document
287+
# Set detected fields — override defaults from Frappe
288288
for field, value in detected_fields.items():
289-
if hasattr(self, field) and not getattr(self, field):
289+
if hasattr(self, field) and value:
290290
setattr(self, field, value)
291291
except Exception as e:
292292
frappe.log_error(
@@ -363,16 +363,16 @@ def on_update(self):
363363
)
364364

365365
# Auto-detect EDocument fields (company, etc.) from XML using profile-specific detector
366-
if self.edocument_profile and has_xml and not self.company:
366+
if self.edocument_profile and has_xml:
367367
try:
368368
xml_bytes = self._get_xml_from_attached_files()
369369
if xml_bytes:
370370
from edocument.edocument.detector import get_edocument_fields
371371

372372
detected_fields = get_edocument_fields(xml_bytes, self.edocument_profile)
373-
# Update detected fields directly in database
373+
# Update detected fields — override defaults from Frappe
374374
for field, value in detected_fields.items():
375-
if hasattr(self, field) and not getattr(self, field):
375+
if hasattr(self, field) and value:
376376
self.db_set(field, value, update_modified=False)
377377
except Exception as e:
378378
frappe.log_error(

edocument/edocument/profiles/peppol/parser.py

Lines changed: 88 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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

278279
def 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

636701
def guess_po_details(pi_data):

0 commit comments

Comments
 (0)