MEGAINFRA-WEBAPP/scripts/test_po_email_intake.py
DEls 2c97f36ff4 fix(po-email): read the site off the PO's own lines, not just its subject
The site was only looked for in the PO's Subject field and in the email
subject, so a PO that names its site in the Description -- or only in the
project code on its line items -- could not be placed and was flagged for
review. MEGCPO9547 ("Chamberlain PO", Chamberlain is the supplier) gave the
site three ways: Description "Tools (Nellmapius EXT25)" and C26/27 on
every line, while its email subject named nothing.

The site is now resolved from the document itself, in order of how reliable
each signal is: the project code on the PO's own lines, then the site named
in its Description, then its Subject, then the email subject. Only the
line-item block is scanned for project codes, so the "Head office" in a PO's
contact block cannot be mistaken for the HEAD site code, and lines naming
two different sites are left for a human, as MEGA's own finalisation step
treats them.

Also splits the document parsing out of parse_pdf so it can be tested, and
covers it with scripts/test_po_email_intake.py.
2026-11-05 11:15:32 +02:00

176 lines
8 KiB
Python

"""Tests for the MEGA PO email intake parser and site matching.
Run: python3 scripts/test_po_email_intake.py
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from po_email_intake import ( # noqa: E402
parse_po_text,
resolve_site,
resolve_site_by_code,
)
# MEGA's sites, as in scripts/data/po-email-intake/sites.json
SITES = [
{"id": "8qyqytyl62rysu1", "name": "The Hills", "code": "C26/09"},
{"id": "u6n5fye4czinw6o", "name": "Cedar Gate", "code": "C26/13"},
{"id": "v61qv4s1omqqkc6", "name": "Mega Head Office", "code": "HEAD"},
{"id": "dmqybquea190z22", "name": "Nellmapius Ext 25", "code": "C26/27"},
{"id": "1d4hqov69qzajcp", "name": "Munyaka 2", "code": "C26/14"},
]
# The real layout of a MEGA PO, trimmed but faithful to rptViewer output.
# MEGCPO9547 "Chamberlain PO" -- the site appears in the description and in the
# project code on each line, but nowhere in the email subject.
PO_9547 = """Mega Infrastructure (Pty) Ltd
Mega Park Business Park
70 Motor street
Silverton
Pretoria
0184
Telephone : (012) 803 4456
Vat Registration : 4280307796
Company Registration : 2022/615504/07
Purchase Order(s) #MEGCPO9547
Subject : MEGCREQ11948 a CO on MEGCREQ11947 Description : Tools
(Nellmapius EXT25)
To : FH CHAMBERLAINS PO Creation Date : 05/11/2026
Required By : 10/11/2026
Req No : MEGCREQ11948
Ordered By : Julius Mnisi
Code : 05CHA001 Telephone :
Address : Fax :
Email : julius@megainfra.co.za
Currency : (ZAR) South African Rand
Contact : FH CHAMBERLAINS Payment In : Current
Telephone :
Fax :
Cell Phone :
Email :
Bus No. Reg. : VAT Number :
Contact : Head office, Tel : (012) 803 4456, Fax : , Cell : (012) 803
4456
Delivery Address : Mega Park Business Park, 70 Motor street,
Silverton, Pretoria, 0184
No Material Cost Code Description Quantity Unit RateDISC% VAT% Amount
Code
1 513999 C26/27, 0001 Sail Easy Shade Kalahari 3m 1 each 694.7800 0.00 15.00 694.78
Square
2 513999 C26/27, 0001 Wheelbarrow Concrete 1 755.6500 0.00 15.00 755.65
S/Duty 0.8mm Pan 65L
3 513999 C26/27, 0001 Rope SKI 10MMx15M FGSK 1 55.5700 0.00 15.00 55.57
Rop Pluset
Sub Total Amount : 1,506.00
VAT Amount : 225.91
Total Amount : 1,731.91
Authorised Signatory : ________________________________
Subject to our terms and conditions of purchase ( Available on request )
Printed 2026-11-05 10:12 Page 1 of 1
Construction Computer Software (Pty) Ltd Ref: STD_po_DN.rpt (Std/Link) Mod: 12-March-2024
"""
class ParsePoText(unittest.TestCase):
def test_reads_the_header_fields(self):
parsed = parse_po_text(PO_9547)
self.assertEqual(parsed["po_number"], "MEGCPO9547")
self.assertEqual(parsed["supplier"], "FH CHAMBERLAINS")
self.assertEqual(parsed["description"], "Tools (Nellmapius EXT25)")
self.assertEqual(parsed["order_date"], "05/11/2026")
self.assertEqual(parsed["required_by"], "10/11/2026")
def test_reads_the_project_code_off_each_line(self):
self.assertEqual(parse_po_text(PO_9547)["cost_codes"], ["C26/27"])
def test_the_letterhead_is_not_mistaken_for_a_project_code(self):
# "Head office" in the contact block is a MEGA Head Office code, HEAD.
# It sits in the header, before the line items, so it must not be read
# as the site of this PO.
codes = parse_po_text(PO_9547)["cost_codes"]
self.assertNotIn("HEAD", codes)
self.assertEqual(resolve_site_by_code(codes, SITES)["name"], "Nellmapius Ext 25")
def test_a_document_with_no_line_items_has_no_project_code(self):
self.assertEqual(parse_po_text("Purchase Order(s) #MEGCPO1\n")["cost_codes"], [])
class ResolveSite(unittest.TestCase):
def test_one_project_code_resolves_to_that_site(self):
site = resolve_site_by_code(["C26/27"], SITES)
self.assertEqual(site["name"], "Nellmapius Ext 25")
def test_two_sites_in_the_lines_are_left_for_a_human(self):
self.assertIsNone(resolve_site_by_code(["C26/27", "C26/09"], SITES))
def test_no_project_code_resolves_to_nothing(self):
self.assertIsNone(resolve_site_by_code([], SITES))
def test_the_description_names_the_site(self):
site = resolve_site("Tools (Nellmapius EXT25)", SITES)
self.assertEqual(site["name"], "Nellmapius Ext 25")
def test_a_site_named_in_the_subject_line_still_resolves(self):
site = resolve_site("MEGCREQ11928 a CO on MEGCREQ11927 (Nellmapius EXT25)", SITES)
self.assertEqual(site["name"], "Nellmapius Ext 25")
def test_the_hills_is_still_found(self):
site = resolve_site("MEGCREQ11911 The Hills Ext12", SITES)
self.assertEqual(site["name"], "The Hills")
def test_head_office_is_found_by_its_code(self):
self.assertEqual(resolve_site("HEAD", SITES)["name"], "Mega Head Office")
def test_a_supplier_name_is_not_a_site(self):
self.assertIsNone(resolve_site("Chamberlain PO", SITES))
class SiteOfAPo(unittest.TestCase):
"""The order the intake uses: project code, then description, then subject."""
def resolve(self, parsed, email_subject=""):
return (
resolve_site_by_code(parsed["cost_codes"], SITES)
or resolve_site(parsed["description"], SITES)
or resolve_site(parsed["subject_line"], SITES)
or resolve_site(email_subject, SITES)
)
def test_a_po_whose_email_subject_names_no_site_is_still_filed(self):
parsed = parse_po_text(PO_9547)
site = self.resolve(parsed, "Chamberlain PO")
self.assertIsNotNone(site, "MEGCPO9547 names no site in its email subject")
self.assertEqual(site["name"], "Nellmapius Ext 25")
def test_a_po_naming_two_sites_in_its_lines_is_left_for_review(self):
parsed = parse_po_text(PO_9547)
parsed["cost_codes"] = ["C26/27", "C26/09"]
parsed["description"] = ""
parsed["subject_line"] = ""
self.assertIsNone(self.resolve(parsed, "Chamberlain PO"))
if __name__ == "__main__":
unittest.main()