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.
176 lines
8 KiB
Python
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()
|