diff --git a/inventory-reconciliation/NOTES.md b/inventory-reconciliation/NOTES.md new file mode 100644 index 0000000..1749a75 --- /dev/null +++ b/inventory-reconciliation/NOTES.md @@ -0,0 +1,55 @@ +# Inventory Reconciliation – Notes + +## Approach + +I started by pulling both CSVs and reading them in full before writing any code. The first thing that jumped out was that the two files use **different column names** (`name` vs `product_name`, `quantity` vs `qty`, etc.), so a naive join on column position would silently mangle the data. I built a `COLUMN_MAP` alias layer to normalise headers before any processing, making the reconciliation logic itself header-agnostic. + +The reconciliation logic is a straightforward set difference + intersection: +- **Both snapshots** → classify as `unchanged` or `quantity_changed` (and flag name changes separately) +- **Snapshot 1 only** → `removed` +- **Snapshot 2 only** → `added` + +All data quality issues are collected as typed records (`DataQualityIssue`) and surfaced in the JSON report alongside the reconciliation results, rather than silently dropped or corrected without trace. + +## Data Quality Issues Found + +| Issue | Count | Example | +|---|---|---| +| Non-standard SKU format (missing dash) | 2 | `SKU005` → `SKU-005`, `SKU018` → `SKU-018` | +| Lowercase SKU | 1 | `sku-008` → `SKU-008` | +| Leading/trailing whitespace in name | 5 | `" Widget B"`, `"Mounting Bracket Large "`, `" HDMI Cable 3ft "`, `" Compressed Air Can"` | +| Float quantity instead of integer | 2 | `70.0`, `80.00` | +| Negative quantity | 1 | SKU-045 has qty `-5` (second duplicate row) | +| Non-ISO date format | 1 | `01/15/2024` on SKU-035 in snapshot 2 | +| Duplicate SKU | 1 | SKU-045 appears twice in snapshot 2 with different names and quantities | +| Name discrepancy (same SKU, different name) | 1 | `Multimeter Pro` → `Multimeter Professional` | + +The duplicate SKU-045 was the most notable issue: snapshot 2 contains it once as `"Multimeter Professional"` (qty 23) and again as `"Multimeter Pro"` (qty -5). I keep the first occurrence, flag both problems (duplicate + negative quantity), and surface both in the quality report. This is a judgment call — in production I'd want to confirm the intended resolution with the data owner. + +## Key Decisions + +**SKU as join key** — SKUs are the only stable identifier across both snapshots; product names drift (see Multimeter Pro/Professional). Normalising SKUs (uppercase, insert dash where missing) before joining prevents false "removed/added" mismatches for what are clearly the same item. + +**Collect-don't-throw on quality issues** — Rather than raising exceptions on bad data, every issue is recorded with its raw and resolved value. This gives the downstream consumer a complete picture and makes the reconciliation idempotent. + +**First-row-wins on duplicate SKUs** — Without domain knowledge about which duplicate is correct, keeping the first occurrence is the safest default. The issue is logged so nothing is silently discarded. + +**Name changes are advisory, not status-changing** — A name change on an existing SKU doesn't change `status`; it's flagged separately as `name_changed: true` with both names. Renaming a product shouldn't look like a removal + addition. + +## AI Tooling + +I used Claude throughout this exercise, which is consistent with how I work day-to-day. Specifically: +- **Data inspection**: asked Claude to scan both CSVs and enumerate all quality issues before writing a line of code — this produced the complete issue catalogue above in one pass. +- **Scaffolding**: used Claude to generate the initial dataclass model and column-alias approach, then reviewed and adjusted the normalisation edge cases. +- **Test generation**: Claude drafted the pytest suite from the module's public interface; I reviewed each test case for correctness and added the edge-case tests for `None` quantity delta and result ordering. +- **Bug catching**: Claude caught the `70.0 == 70` Python equality pitfall (float detection checking value rather than raw string) before I ran the tests. + +The commit history reflects this iterative, AI-assisted flow: data inspection → structure → implementation → tests → fix. + +I also flag warehouse location changes separately from quantity changes — a SKU can be "unchanged" in quantity but physically transferred between warehouses, which is meaningful to operations teams. + +Items with zero quantity are flagged as out_of_stock — they exist in the system but are operationally unavailable. This is distinct from removed (not in snapshot at all). + +Quantity direction (increased/decreased) is tracked separately from the status — a restock and a consumption both show as `quantity_changed` but mean very different things operationally. + +Warehouse location changes are flagged independently — an item can be `unchanged` in quantity but physically transferred between warehouses, which is a meaningful operational event. \ No newline at end of file diff --git a/inventory-reconciliation/output/reconciliation_report.csv b/inventory-reconciliation/output/reconciliation_report.csv new file mode 100644 index 0000000..6b50838 --- /dev/null +++ b/inventory-reconciliation/output/reconciliation_report.csv @@ -0,0 +1,81 @@ +sku,name,status,snap1_qty,snap2_qty,qty_delta,location,name_changed,snap1_name,snap2_name +SKU-001,Widget A,quantity_changed,150.0,145.0,-5.0,Warehouse A,False,, +SKU-002,Widget B,quantity_changed,75.0,70.0,-5.0,Warehouse A,False,, +SKU-003,Gadget Pro,quantity_changed,200.0,185.0,-15.0,Warehouse B,False,, +SKU-004,Gadget Lite,quantity_changed,50.0,48.0,-2.0,Warehouse A,False,, +SKU-005,Connector Cable 6ft,quantity_changed,500.0,480.0,-20.0,Warehouse C,False,, +SKU-006,Connector Cable 10ft,unchanged,350.0,350.0,0.0,Warehouse C,False,, +SKU-007,Power Supply Unit,unchanged,80.0,80.0,0.0,Warehouse A,False,, +SKU-008,Power Supply Unit Pro,quantity_changed,45.0,42.0,-3.0,Warehouse A,False,, +SKU-009,Mounting Bracket Small,quantity_changed,1000.0,975.0,-25.0,Warehouse B,False,, +SKU-010,Mounting Bracket Large,quantity_changed,750.0,720.0,-30.0,Warehouse B,False,, +SKU-011,LED Panel 12x12,quantity_changed,120.0,115.0,-5.0,Warehouse A,False,, +SKU-012,LED Panel 24x24,quantity_changed,90.0,85.0,-5.0,Warehouse A,False,, +SKU-013,Thermal Paste Tube,quantity_changed,2000.0,1850.0,-150.0,Warehouse C,False,, +SKU-014,Cooling Fan 80mm,quantity_changed,300.0,290.0,-10.0,Warehouse B,False,, +SKU-015,Cooling Fan 120mm,quantity_changed,250.0,245.0,-5.0,Warehouse B,False,, +SKU-016,USB Hub 4-Port,quantity_changed,180.0,165.0,-15.0,Warehouse A,False,, +SKU-017,USB Hub 7-Port,quantity_changed,95.0,88.0,-7.0,Warehouse A,False,, +SKU-018,Ethernet Cable Cat5,quantity_changed,800.0,750.0,-50.0,Warehouse C,False,, +SKU-019,Ethernet Cable Cat6,quantity_changed,600.0,580.0,-20.0,Warehouse C,False,, +SKU-020,Ethernet Cable Cat6a,quantity_changed,400.0,390.0,-10.0,Warehouse C,False,, +SKU-021,HDMI Cable 3ft,quantity_changed,450.0,425.0,-25.0,Warehouse A,False,, +SKU-022,HDMI Cable 6ft,quantity_changed,380.0,365.0,-15.0,Warehouse A,False,, +SKU-023,HDMI Cable 10ft,quantity_changed,220.0,210.0,-10.0,Warehouse A,False,, +SKU-024,DisplayPort Cable,quantity_changed,175.0,170.0,-5.0,Warehouse A,False,, +SKU-025,VGA Cable,removed,50.0,,,Warehouse B,False,, +SKU-026,DVI Cable,removed,35.0,,,Warehouse B,False,, +SKU-027,Audio Cable 3.5mm,quantity_changed,600.0,575.0,-25.0,Warehouse C,False,, +SKU-028,Audio Cable RCA,quantity_changed,400.0,385.0,-15.0,Warehouse C,False,, +SKU-029,Optical Audio Cable,quantity_changed,150.0,145.0,-5.0,Warehouse C,False,, +SKU-030,Surge Protector 6-Outlet,quantity_changed,200.0,188.0,-12.0,Warehouse A,False,, +SKU-031,Surge Protector 12-Outlet,quantity_changed,120.0,112.0,-8.0,Warehouse A,False,, +SKU-032,Extension Cord 10ft,quantity_changed,300.0,285.0,-15.0,Warehouse B,False,, +SKU-033,Extension Cord 25ft,quantity_changed,180.0,172.0,-8.0,Warehouse B,False,, +SKU-034,Power Strip,quantity_changed,250.0,240.0,-10.0,Warehouse A,False,, +SKU-035,Cable Ties 100pk,quantity_changed,1500.0,1420.0,-80.0,Warehouse C,False,, +SKU-036,Cable Ties 500pk,quantity_changed,400.0,385.0,-15.0,Warehouse C,False,, +SKU-037,Velcro Straps 50pk,quantity_changed,800.0,765.0,-35.0,Warehouse C,False,, +SKU-038,Label Maker,quantity_changed,25.0,22.0,-3.0,Warehouse A,False,, +SKU-039,Label Tape,quantity_changed,200.0,185.0,-15.0,Warehouse A,False,, +SKU-040,Screwdriver Set,quantity_changed,150.0,142.0,-8.0,Warehouse B,False,, +SKU-041,Precision Screwdriver Set,quantity_changed,100.0,95.0,-5.0,Warehouse B,False,, +SKU-042,Wire Stripper,quantity_changed,75.0,70.0,-5.0,Warehouse B,False,, +SKU-043,Crimping Tool,quantity_changed,60.0,58.0,-2.0,Warehouse B,False,, +SKU-044,Multimeter Basic,quantity_changed,40.0,35.0,-5.0,Warehouse A,False,, +SKU-045,Multimeter Professional,quantity_changed,25.0,23.0,-2.0,Warehouse A,True,Multimeter Pro,Multimeter Professional +SKU-046,Soldering Iron,quantity_changed,35.0,32.0,-3.0,Warehouse B,False,, +SKU-047,Solder Wire,quantity_changed,300.0,280.0,-20.0,Warehouse B,False,, +SKU-048,Heat Shrink Tubing,quantity_changed,500.0,475.0,-25.0,Warehouse C,False,, +SKU-049,Electrical Tape,quantity_changed,800.0,760.0,-40.0,Warehouse C,False,, +SKU-050,Anti-Static Wrist Strap,quantity_changed,200.0,190.0,-10.0,Warehouse A,False,, +SKU-051,Anti-Static Mat,quantity_changed,50.0,48.0,-2.0,Warehouse A,False,, +SKU-052,Compressed Air Can,quantity_changed,400.0,375.0,-25.0,Warehouse C,False,, +SKU-053,Isopropyl Alcohol 99%,quantity_changed,150.0,140.0,-10.0,Warehouse C,False,, +SKU-054,Microfiber Cloth 10pk,quantity_changed,300.0,285.0,-15.0,Warehouse C,False,, +SKU-055,Screen Cleaner,quantity_changed,250.0,235.0,-15.0,Warehouse C,False,, +SKU-056,Keyboard Cleaner Gel,quantity_changed,180.0,168.0,-12.0,Warehouse C,False,, +SKU-057,Monitor Stand,quantity_changed,45.0,42.0,-3.0,Warehouse A,False,, +SKU-058,Laptop Stand,quantity_changed,60.0,58.0,-2.0,Warehouse A,False,, +SKU-059,Tablet Stand,quantity_changed,80.0,75.0,-5.0,Warehouse A,False,, +SKU-060,Phone Stand,quantity_changed,120.0,115.0,-5.0,Warehouse A,False,, +SKU-061,Desk Organizer,quantity_changed,90.0,85.0,-5.0,Warehouse B,False,, +SKU-062,Cable Management Box,quantity_changed,110.0,105.0,-5.0,Warehouse B,False,, +SKU-063,Headphone Hook,quantity_changed,200.0,192.0,-8.0,Warehouse B,False,, +SKU-064,Webcam Mount,quantity_changed,75.0,72.0,-3.0,Warehouse A,False,, +SKU-065,Ring Light 10in,quantity_changed,40.0,38.0,-2.0,Warehouse A,False,, +SKU-066,Ring Light 18in,quantity_changed,25.0,22.0,-3.0,Warehouse A,False,, +SKU-067,Tripod Small,quantity_changed,55.0,52.0,-3.0,Warehouse B,False,, +SKU-068,Tripod Large,quantity_changed,35.0,33.0,-2.0,Warehouse B,False,, +SKU-069,Green Screen,quantity_changed,20.0,18.0,-2.0,Warehouse A,False,, +SKU-070,Backdrop Stand,quantity_changed,15.0,12.0,-3.0,Warehouse A,False,, +SKU-071,USB Microphone,quantity_changed,30.0,28.0,-2.0,Warehouse A,False,, +SKU-072,XLR Microphone,quantity_changed,20.0,18.0,-2.0,Warehouse A,False,, +SKU-073,Pop Filter,quantity_changed,100.0,95.0,-5.0,Warehouse B,False,, +SKU-074,Boom Arm,quantity_changed,45.0,42.0,-3.0,Warehouse B,False,, +SKU-075,Shock Mount,quantity_changed,40.0,38.0,-2.0,Warehouse B,False,, +SKU-076,Stream Deck Mini,added,,15.0,,Warehouse A,False,, +SKU-077,Stream Deck XL,added,,8.0,,Warehouse A,False,, +SKU-078,Capture Card,added,,12.0,,Warehouse A,False,, +SKU-079,USB-C Hub,added,,45.0,,Warehouse A,False,, +SKU-080,Thunderbolt Cable,added,,30.0,,Warehouse A,False,, diff --git a/inventory-reconciliation/output/reconciliation_report.json b/inventory-reconciliation/output/reconciliation_report.json new file mode 100644 index 0000000..2b6707c --- /dev/null +++ b/inventory-reconciliation/output/reconciliation_report.json @@ -0,0 +1,1075 @@ +{ + "summary": { + "generated_at": "2026-03-11T18:34:53.559933+00:00", + "totals": { + "unchanged": 2, + "quantity_changed": 71, + "added": 5, + "removed": 2, + "name_changed": 1 + }, + "data_quality": { + "snapshot_1_issues": 2, + "snapshot_2_issues": 11 + } + }, + "reconciliation": [ + { + "sku": "SKU-001", + "name": "Widget A", + "status": "quantity_changed", + "snap1_qty": 150.0, + "snap2_qty": 145.0, + "qty_delta": -5.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-002", + "name": "Widget B", + "status": "quantity_changed", + "snap1_qty": 75.0, + "snap2_qty": 70.0, + "qty_delta": -5.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-003", + "name": "Gadget Pro", + "status": "quantity_changed", + "snap1_qty": 200.0, + "snap2_qty": 185.0, + "qty_delta": -15.0, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-004", + "name": "Gadget Lite", + "status": "quantity_changed", + "snap1_qty": 50.0, + "snap2_qty": 48.0, + "qty_delta": -2.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-005", + "name": "Connector Cable 6ft", + "status": "quantity_changed", + "snap1_qty": 500.0, + "snap2_qty": 480.0, + "qty_delta": -20.0, + "location": "Warehouse C", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-006", + "name": "Connector Cable 10ft", + "status": "unchanged", + "snap1_qty": 350.0, + "snap2_qty": 350.0, + "qty_delta": 0.0, + "location": "Warehouse C", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-007", + "name": "Power Supply Unit", + "status": "unchanged", + "snap1_qty": 80.0, + "snap2_qty": 80.0, + "qty_delta": 0.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-008", + "name": "Power Supply Unit Pro", + "status": "quantity_changed", + "snap1_qty": 45.0, + "snap2_qty": 42.0, + "qty_delta": -3.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-009", + "name": "Mounting Bracket Small", + "status": "quantity_changed", + "snap1_qty": 1000.0, + "snap2_qty": 975.0, + "qty_delta": -25.0, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-010", + "name": "Mounting Bracket Large", + "status": "quantity_changed", + "snap1_qty": 750.0, + "snap2_qty": 720.0, + "qty_delta": -30.0, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-011", + "name": "LED Panel 12x12", + "status": "quantity_changed", + "snap1_qty": 120.0, + "snap2_qty": 115.0, + "qty_delta": -5.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-012", + "name": "LED Panel 24x24", + "status": "quantity_changed", + "snap1_qty": 90.0, + "snap2_qty": 85.0, + "qty_delta": -5.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-013", + "name": "Thermal Paste Tube", + "status": "quantity_changed", + "snap1_qty": 2000.0, + "snap2_qty": 1850.0, + "qty_delta": -150.0, + "location": "Warehouse C", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-014", + "name": "Cooling Fan 80mm", + "status": "quantity_changed", + "snap1_qty": 300.0, + "snap2_qty": 290.0, + "qty_delta": -10.0, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-015", + "name": "Cooling Fan 120mm", + "status": "quantity_changed", + "snap1_qty": 250.0, + "snap2_qty": 245.0, + "qty_delta": -5.0, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-016", + "name": "USB Hub 4-Port", + "status": "quantity_changed", + "snap1_qty": 180.0, + "snap2_qty": 165.0, + "qty_delta": -15.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-017", + "name": "USB Hub 7-Port", + "status": "quantity_changed", + "snap1_qty": 95.0, + "snap2_qty": 88.0, + "qty_delta": -7.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-018", + "name": "Ethernet Cable Cat5", + "status": "quantity_changed", + "snap1_qty": 800.0, + "snap2_qty": 750.0, + "qty_delta": -50.0, + "location": "Warehouse C", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-019", + "name": "Ethernet Cable Cat6", + "status": "quantity_changed", + "snap1_qty": 600.0, + "snap2_qty": 580.0, + "qty_delta": -20.0, + "location": "Warehouse C", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-020", + "name": "Ethernet Cable Cat6a", + "status": "quantity_changed", + "snap1_qty": 400.0, + "snap2_qty": 390.0, + "qty_delta": -10.0, + "location": "Warehouse C", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-021", + "name": "HDMI Cable 3ft", + "status": "quantity_changed", + "snap1_qty": 450.0, + "snap2_qty": 425.0, + "qty_delta": -25.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-022", + "name": "HDMI Cable 6ft", + "status": "quantity_changed", + "snap1_qty": 380.0, + "snap2_qty": 365.0, + "qty_delta": -15.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-023", + "name": "HDMI Cable 10ft", + "status": "quantity_changed", + "snap1_qty": 220.0, + "snap2_qty": 210.0, + "qty_delta": -10.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-024", + "name": "DisplayPort Cable", + "status": "quantity_changed", + "snap1_qty": 175.0, + "snap2_qty": 170.0, + "qty_delta": -5.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-025", + "name": "VGA Cable", + "status": "removed", + "snap1_qty": 50.0, + "snap2_qty": null, + "qty_delta": null, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-026", + "name": "DVI Cable", + "status": "removed", + "snap1_qty": 35.0, + "snap2_qty": null, + "qty_delta": null, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-027", + "name": "Audio Cable 3.5mm", + "status": "quantity_changed", + "snap1_qty": 600.0, + "snap2_qty": 575.0, + "qty_delta": -25.0, + "location": "Warehouse C", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-028", + "name": "Audio Cable RCA", + "status": "quantity_changed", + "snap1_qty": 400.0, + "snap2_qty": 385.0, + "qty_delta": -15.0, + "location": "Warehouse C", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-029", + "name": "Optical Audio Cable", + "status": "quantity_changed", + "snap1_qty": 150.0, + "snap2_qty": 145.0, + "qty_delta": -5.0, + "location": "Warehouse C", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-030", + "name": "Surge Protector 6-Outlet", + "status": "quantity_changed", + "snap1_qty": 200.0, + "snap2_qty": 188.0, + "qty_delta": -12.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-031", + "name": "Surge Protector 12-Outlet", + "status": "quantity_changed", + "snap1_qty": 120.0, + "snap2_qty": 112.0, + "qty_delta": -8.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-032", + "name": "Extension Cord 10ft", + "status": "quantity_changed", + "snap1_qty": 300.0, + "snap2_qty": 285.0, + "qty_delta": -15.0, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-033", + "name": "Extension Cord 25ft", + "status": "quantity_changed", + "snap1_qty": 180.0, + "snap2_qty": 172.0, + "qty_delta": -8.0, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-034", + "name": "Power Strip", + "status": "quantity_changed", + "snap1_qty": 250.0, + "snap2_qty": 240.0, + "qty_delta": -10.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-035", + "name": "Cable Ties 100pk", + "status": "quantity_changed", + "snap1_qty": 1500.0, + "snap2_qty": 1420.0, + "qty_delta": -80.0, + "location": "Warehouse C", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-036", + "name": "Cable Ties 500pk", + "status": "quantity_changed", + "snap1_qty": 400.0, + "snap2_qty": 385.0, + "qty_delta": -15.0, + "location": "Warehouse C", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-037", + "name": "Velcro Straps 50pk", + "status": "quantity_changed", + "snap1_qty": 800.0, + "snap2_qty": 765.0, + "qty_delta": -35.0, + "location": "Warehouse C", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-038", + "name": "Label Maker", + "status": "quantity_changed", + "snap1_qty": 25.0, + "snap2_qty": 22.0, + "qty_delta": -3.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-039", + "name": "Label Tape", + "status": "quantity_changed", + "snap1_qty": 200.0, + "snap2_qty": 185.0, + "qty_delta": -15.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-040", + "name": "Screwdriver Set", + "status": "quantity_changed", + "snap1_qty": 150.0, + "snap2_qty": 142.0, + "qty_delta": -8.0, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-041", + "name": "Precision Screwdriver Set", + "status": "quantity_changed", + "snap1_qty": 100.0, + "snap2_qty": 95.0, + "qty_delta": -5.0, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-042", + "name": "Wire Stripper", + "status": "quantity_changed", + "snap1_qty": 75.0, + "snap2_qty": 70.0, + "qty_delta": -5.0, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-043", + "name": "Crimping Tool", + "status": "quantity_changed", + "snap1_qty": 60.0, + "snap2_qty": 58.0, + "qty_delta": -2.0, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-044", + "name": "Multimeter Basic", + "status": "quantity_changed", + "snap1_qty": 40.0, + "snap2_qty": 35.0, + "qty_delta": -5.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-045", + "name": "Multimeter Professional", + "status": "quantity_changed", + "snap1_qty": 25.0, + "snap2_qty": 23.0, + "qty_delta": -2.0, + "location": "Warehouse A", + "name_changed": true, + "snap1_name": "Multimeter Pro", + "snap2_name": "Multimeter Professional" + }, + { + "sku": "SKU-046", + "name": "Soldering Iron", + "status": "quantity_changed", + "snap1_qty": 35.0, + "snap2_qty": 32.0, + "qty_delta": -3.0, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-047", + "name": "Solder Wire", + "status": "quantity_changed", + "snap1_qty": 300.0, + "snap2_qty": 280.0, + "qty_delta": -20.0, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-048", + "name": "Heat Shrink Tubing", + "status": "quantity_changed", + "snap1_qty": 500.0, + "snap2_qty": 475.0, + "qty_delta": -25.0, + "location": "Warehouse C", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-049", + "name": "Electrical Tape", + "status": "quantity_changed", + "snap1_qty": 800.0, + "snap2_qty": 760.0, + "qty_delta": -40.0, + "location": "Warehouse C", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-050", + "name": "Anti-Static Wrist Strap", + "status": "quantity_changed", + "snap1_qty": 200.0, + "snap2_qty": 190.0, + "qty_delta": -10.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-051", + "name": "Anti-Static Mat", + "status": "quantity_changed", + "snap1_qty": 50.0, + "snap2_qty": 48.0, + "qty_delta": -2.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-052", + "name": "Compressed Air Can", + "status": "quantity_changed", + "snap1_qty": 400.0, + "snap2_qty": 375.0, + "qty_delta": -25.0, + "location": "Warehouse C", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-053", + "name": "Isopropyl Alcohol 99%", + "status": "quantity_changed", + "snap1_qty": 150.0, + "snap2_qty": 140.0, + "qty_delta": -10.0, + "location": "Warehouse C", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-054", + "name": "Microfiber Cloth 10pk", + "status": "quantity_changed", + "snap1_qty": 300.0, + "snap2_qty": 285.0, + "qty_delta": -15.0, + "location": "Warehouse C", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-055", + "name": "Screen Cleaner", + "status": "quantity_changed", + "snap1_qty": 250.0, + "snap2_qty": 235.0, + "qty_delta": -15.0, + "location": "Warehouse C", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-056", + "name": "Keyboard Cleaner Gel", + "status": "quantity_changed", + "snap1_qty": 180.0, + "snap2_qty": 168.0, + "qty_delta": -12.0, + "location": "Warehouse C", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-057", + "name": "Monitor Stand", + "status": "quantity_changed", + "snap1_qty": 45.0, + "snap2_qty": 42.0, + "qty_delta": -3.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-058", + "name": "Laptop Stand", + "status": "quantity_changed", + "snap1_qty": 60.0, + "snap2_qty": 58.0, + "qty_delta": -2.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-059", + "name": "Tablet Stand", + "status": "quantity_changed", + "snap1_qty": 80.0, + "snap2_qty": 75.0, + "qty_delta": -5.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-060", + "name": "Phone Stand", + "status": "quantity_changed", + "snap1_qty": 120.0, + "snap2_qty": 115.0, + "qty_delta": -5.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-061", + "name": "Desk Organizer", + "status": "quantity_changed", + "snap1_qty": 90.0, + "snap2_qty": 85.0, + "qty_delta": -5.0, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-062", + "name": "Cable Management Box", + "status": "quantity_changed", + "snap1_qty": 110.0, + "snap2_qty": 105.0, + "qty_delta": -5.0, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-063", + "name": "Headphone Hook", + "status": "quantity_changed", + "snap1_qty": 200.0, + "snap2_qty": 192.0, + "qty_delta": -8.0, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-064", + "name": "Webcam Mount", + "status": "quantity_changed", + "snap1_qty": 75.0, + "snap2_qty": 72.0, + "qty_delta": -3.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-065", + "name": "Ring Light 10in", + "status": "quantity_changed", + "snap1_qty": 40.0, + "snap2_qty": 38.0, + "qty_delta": -2.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-066", + "name": "Ring Light 18in", + "status": "quantity_changed", + "snap1_qty": 25.0, + "snap2_qty": 22.0, + "qty_delta": -3.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-067", + "name": "Tripod Small", + "status": "quantity_changed", + "snap1_qty": 55.0, + "snap2_qty": 52.0, + "qty_delta": -3.0, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-068", + "name": "Tripod Large", + "status": "quantity_changed", + "snap1_qty": 35.0, + "snap2_qty": 33.0, + "qty_delta": -2.0, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-069", + "name": "Green Screen", + "status": "quantity_changed", + "snap1_qty": 20.0, + "snap2_qty": 18.0, + "qty_delta": -2.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-070", + "name": "Backdrop Stand", + "status": "quantity_changed", + "snap1_qty": 15.0, + "snap2_qty": 12.0, + "qty_delta": -3.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-071", + "name": "USB Microphone", + "status": "quantity_changed", + "snap1_qty": 30.0, + "snap2_qty": 28.0, + "qty_delta": -2.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-072", + "name": "XLR Microphone", + "status": "quantity_changed", + "snap1_qty": 20.0, + "snap2_qty": 18.0, + "qty_delta": -2.0, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-073", + "name": "Pop Filter", + "status": "quantity_changed", + "snap1_qty": 100.0, + "snap2_qty": 95.0, + "qty_delta": -5.0, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-074", + "name": "Boom Arm", + "status": "quantity_changed", + "snap1_qty": 45.0, + "snap2_qty": 42.0, + "qty_delta": -3.0, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-075", + "name": "Shock Mount", + "status": "quantity_changed", + "snap1_qty": 40.0, + "snap2_qty": 38.0, + "qty_delta": -2.0, + "location": "Warehouse B", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-076", + "name": "Stream Deck Mini", + "status": "added", + "snap1_qty": null, + "snap2_qty": 15.0, + "qty_delta": null, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-077", + "name": "Stream Deck XL", + "status": "added", + "snap1_qty": null, + "snap2_qty": 8.0, + "qty_delta": null, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-078", + "name": "Capture Card", + "status": "added", + "snap1_qty": null, + "snap2_qty": 12.0, + "qty_delta": null, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-079", + "name": "USB-C Hub", + "status": "added", + "snap1_qty": null, + "snap2_qty": 45.0, + "qty_delta": null, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + }, + { + "sku": "SKU-080", + "name": "Thunderbolt Cable", + "status": "added", + "snap1_qty": null, + "snap2_qty": 30.0, + "qty_delta": null, + "location": "Warehouse A", + "name_changed": false, + "snap1_name": null, + "snap2_name": null + } + ], + "data_quality_issues": { + "snapshot_1": [ + { + "sku": "SKU-035", + "field": "name", + "issue": "Leading/trailing whitespace in name", + "raw_value": "'Cable Ties 100pk '", + "resolved_value": "Cable Ties 100pk" + }, + { + "sku": "SKU-052", + "field": "name", + "issue": "Leading/trailing whitespace in name", + "raw_value": "' Compressed Air Can'", + "resolved_value": "Compressed Air Can" + } + ], + "snapshot_2": [ + { + "sku": "SKU-002", + "field": "name", + "issue": "Leading/trailing whitespace in name", + "raw_value": "' Widget B'", + "resolved_value": "Widget B" + }, + { + "sku": "SKU-002", + "field": "quantity", + "issue": "Quantity stored as float instead of integer", + "raw_value": "70.0", + "resolved_value": "70" + }, + { + "sku": "SKU-005", + "field": "sku", + "issue": "Non-standard SKU format", + "raw_value": "SKU005", + "resolved_value": "SKU-005" + }, + { + "sku": "SKU-007", + "field": "quantity", + "issue": "Quantity stored as float instead of integer", + "raw_value": "80.00", + "resolved_value": "80" + }, + { + "sku": "SKU-008", + "field": "sku", + "issue": "Non-standard SKU format", + "raw_value": "sku-008", + "resolved_value": "SKU-008" + }, + { + "sku": "SKU-010", + "field": "name", + "issue": "Leading/trailing whitespace in name", + "raw_value": "'Mounting Bracket Large '", + "resolved_value": "Mounting Bracket Large" + }, + { + "sku": "SKU-018", + "field": "sku", + "issue": "Non-standard SKU format", + "raw_value": "SKU018", + "resolved_value": "SKU-018" + }, + { + "sku": "SKU-021", + "field": "name", + "issue": "Leading/trailing whitespace in name", + "raw_value": "' HDMI Cable 3ft '", + "resolved_value": "HDMI Cable 3ft" + }, + { + "sku": "SKU-035", + "field": "last_counted", + "issue": "Non-ISO date format", + "raw_value": "01/15/2024", + "resolved_value": "2024-01-15" + }, + { + "sku": "SKU-045", + "field": "quantity", + "issue": "Negative quantity", + "raw_value": "-5", + "resolved_value": "-5.0" + }, + { + "sku": "SKU-045", + "field": "sku", + "issue": "Duplicate SKU \u2013 second occurrence ignored", + "raw_value": "SKU-045", + "resolved_value": "SKU-045" + } + ] + } +} \ No newline at end of file diff --git a/inventory-reconciliation/reconcile.py b/inventory-reconciliation/reconcile.py new file mode 100644 index 0000000..d963250 --- /dev/null +++ b/inventory-reconciliation/reconcile.py @@ -0,0 +1,436 @@ +""" +Inventory Reconciliation Script +Compares two warehouse inventory snapshots and produces a structured report. +""" + +import csv +import json +import re +import sys +from dataclasses import dataclass, field, asdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + +@dataclass +class InventoryItem: + sku: str + name: str + quantity: Optional[float] + location: str + last_counted: str + raw_sku: str = field(repr=False, default="") # original before normalisation + + +@dataclass +class DataQualityIssue: + sku: str + field: str + issue: str + raw_value: str + resolved_value: str + + +@dataclass +class ReconciliationResult: + sku: str + name: str + status: str # unchanged | quantity_changed | out_of_stock | added | removed + snap1_qty: Optional[float] + snap2_qty: Optional[float] + qty_delta: Optional[float] + qty_direction: Optional[str] # increased | decreased | None + location: str + location_changed: bool + snap1_location: Optional[str] + snap2_location: Optional[str] + name_changed: bool + snap1_name: Optional[str] + snap2_name: Optional[str] + + +# --------------------------------------------------------------------------- +# Normalisation helpers +# --------------------------------------------------------------------------- + +def normalise_sku(raw: str) -> str: + """ + Canonicalise a SKU to uppercase with a single dash after the prefix. + Handles: 'sku-008' → 'SKU-008', 'SKU005' → 'SKU-005'. + """ + s = raw.strip().upper() + # Insert dash if letters run directly into digits (e.g. SKU005 → SKU-005) + s = re.sub(r"([A-Z]+)(\d+)$", r"\1-\2", s) + return s + + +def normalise_name(raw: str) -> str: + """Strip surrounding whitespace from product names.""" + return raw.strip() + + +def parse_quantity(raw: str) -> Optional[float]: + """ + Convert quantity string to float. + Returns None if the value is non-numeric. + """ + try: + return float(str(raw).strip()) + except (ValueError, TypeError): + return None + + +def normalise_date(raw: str) -> str: + """ + Accept both ISO (2024-01-15) and US (01/15/2024) date formats. + Always returns ISO format, or the original string on failure. + """ + raw = raw.strip() + for fmt in ("%Y-%m-%d", "%m/%d/%Y"): + try: + return datetime.strptime(raw, fmt).strftime("%Y-%m-%d") + except ValueError: + continue + return raw + + +# --------------------------------------------------------------------------- +# Column aliases (handles differing header names between snapshots) +# --------------------------------------------------------------------------- + +COLUMN_MAP = { + "sku": "sku", + "name": "name", + "product_name": "name", + "quantity": "quantity", + "qty": "quantity", + "location": "location", + "warehouse": "location", + "last_counted": "last_counted", + "updated_at": "last_counted", +} + + +def normalise_row(row: dict) -> dict: + """Remap header variants to canonical field names.""" + return {COLUMN_MAP.get(k.strip().lower(), k.strip().lower()): v + for k, v in row.items()} + + +# --------------------------------------------------------------------------- +# Loading +# --------------------------------------------------------------------------- + +def load_snapshot(path: Path) -> tuple[dict[str, InventoryItem], list[DataQualityIssue]]: + """ + Load a CSV snapshot. + + Returns: + items – dict of normalised_sku → InventoryItem + If a SKU appears more than once, the *first* row is kept and + the duplicate is recorded as a quality issue. + issues – list of DataQualityIssue + """ + items: dict[str, InventoryItem] = {} + issues: list[DataQualityIssue] = [] + + with open(path, newline="", encoding="utf-8") as fh: + reader = csv.DictReader(fh) + for row in reader: + row = normalise_row(row) + + raw_sku = row.get("sku", "").strip() + norm_sku = normalise_sku(raw_sku) + + # --- SKU formatting --- + if raw_sku != norm_sku: + issues.append(DataQualityIssue( + sku=norm_sku, field="sku", + issue="Non-standard SKU format", + raw_value=raw_sku, resolved_value=norm_sku, + )) + + # --- Name whitespace --- + raw_name = row.get("name", "") + norm_name = normalise_name(raw_name) + if raw_name != norm_name: + issues.append(DataQualityIssue( + sku=norm_sku, field="name", + issue="Leading/trailing whitespace in name", + raw_value=repr(raw_name), resolved_value=norm_name, + )) + + # --- Quantity --- + raw_qty = row.get("quantity", "") + qty = parse_quantity(raw_qty) + if qty is None: + issues.append(DataQualityIssue( + sku=norm_sku, field="quantity", + issue="Non-numeric quantity", + raw_value=str(raw_qty), resolved_value="null", + )) + elif qty < 0: + issues.append(DataQualityIssue( + sku=norm_sku, field="quantity", + issue="Negative quantity", + raw_value=str(raw_qty), resolved_value=str(qty), + )) + elif "." in str(raw_qty).strip(): + # Float like 70.0 or 80.00 – record but normalise to int value + issues.append(DataQualityIssue( + sku=norm_sku, field="quantity", + issue="Quantity stored as float instead of integer", + raw_value=str(raw_qty), resolved_value=str(int(qty)), + )) + qty = float(int(qty)) + + # --- Date --- + raw_date = row.get("last_counted", "") + norm_date = normalise_date(raw_date) + if raw_date.strip() != norm_date: + issues.append(DataQualityIssue( + sku=norm_sku, field="last_counted", + issue="Non-ISO date format", + raw_value=raw_date, resolved_value=norm_date, + )) + + item = InventoryItem( + sku=norm_sku, + name=norm_name, + quantity=qty, + location=normalise_name(row.get("location", "")), + last_counted=norm_date, + raw_sku=raw_sku, + ) + + # --- Duplicate SKU --- + if norm_sku in items: + issues.append(DataQualityIssue( + sku=norm_sku, field="sku", + issue="Duplicate SKU – second occurrence ignored", + raw_value=raw_sku, resolved_value=norm_sku, + )) + else: + items[norm_sku] = item + + return items, issues + + +# --------------------------------------------------------------------------- +# Reconciliation +# --------------------------------------------------------------------------- + +def reconcile( + snap1: dict[str, InventoryItem], + snap2: dict[str, InventoryItem], +) -> list[ReconciliationResult]: + """Compare two snapshots and classify every SKU.""" + results: list[ReconciliationResult] = [] + + all_skus = sorted(snap1.keys() | snap2.keys()) + + for sku in all_skus: + in1 = sku in snap1 + in2 = sku in snap2 + + if in1 and in2: + item1, item2 = snap1[sku], snap2[sku] + qty_delta = ( + (item2.quantity - item1.quantity) + if item1.quantity is not None and item2.quantity is not None + else None + ) + qty_changed = qty_delta != 0 if qty_delta is not None else False + name_changed = item1.name != item2.name + location_changed = item1.location != item2.location + + # qty_direction tells ops whether this was a consumption or restock + if qty_delta is None: + qty_direction = None + elif qty_delta > 0: + qty_direction = "increased" + elif qty_delta < 0: + qty_direction = "decreased" + else: + qty_direction = None + + # out_of_stock: item exists but has hit zero — operationally unavailable + # distinct from "removed" (not in snapshot at all) + if item2.quantity == 0: + status = "out_of_stock" + elif qty_changed: + status = "quantity_changed" + else: + status = "unchanged" + + results.append(ReconciliationResult( + sku=sku, + name=item2.name, + status=status, + snap1_qty=item1.quantity, + snap2_qty=item2.quantity, + qty_delta=qty_delta, + qty_direction=qty_direction, + location=item2.location, + location_changed=location_changed, + snap1_location=item1.location if location_changed else None, + snap2_location=item2.location if location_changed else None, + name_changed=name_changed, + snap1_name=item1.name if name_changed else None, + snap2_name=item2.name if name_changed else None, + )) + + elif in1: + item1 = snap1[sku] + results.append(ReconciliationResult( + sku=sku, + name=item1.name, + status="removed", + snap1_qty=item1.quantity, + snap2_qty=None, + qty_delta=None, + qty_direction=None, + location=item1.location, + location_changed=False, + snap1_location=None, + snap2_location=None, + name_changed=False, + snap1_name=None, + snap2_name=None, + )) + + else: + item2 = snap2[sku] + results.append(ReconciliationResult( + sku=sku, + name=item2.name, + status="added", + snap1_qty=None, + snap2_qty=item2.quantity, + qty_delta=None, + qty_direction=None, + location=item2.location, + location_changed=False, + snap1_location=None, + snap2_location=None, + name_changed=False, + snap1_name=None, + snap2_name=None, + )) + + return results + + +# --------------------------------------------------------------------------- +# Output writers +# --------------------------------------------------------------------------- + +def write_csv(results: list[ReconciliationResult], path: Path) -> None: + fields = [ + "sku", "name", "status", + "snap1_qty", "snap2_qty", "qty_delta", "qty_direction", + "location", "location_changed", "snap1_location", "snap2_location", + "name_changed", "snap1_name", "snap2_name", + ] + with open(path, "w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=fields) + writer.writeheader() + for r in results: + writer.writerow(asdict(r)) + + +def write_json( + results: list[ReconciliationResult], + issues1: list[DataQualityIssue], + issues2: list[DataQualityIssue], + path: Path, +) -> None: + summary = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "totals": { + "unchanged": sum(1 for r in results if r.status == "unchanged"), + "quantity_changed": sum(1 for r in results if r.status == "quantity_changed"), + "out_of_stock": sum(1 for r in results if r.status == "out_of_stock"), + "added": sum(1 for r in results if r.status == "added"), + "removed": sum(1 for r in results if r.status == "removed"), + "name_changed": sum(1 for r in results if r.name_changed), + "location_changed": sum(1 for r in results if r.location_changed), + }, + "data_quality": { + "snapshot_1_issues": len(issues1), + "snapshot_2_issues": len(issues2), + }, + } + + payload = { + "summary": summary, + "reconciliation": [asdict(r) for r in results], + "data_quality_issues": { + "snapshot_1": [asdict(i) for i in issues1], + "snapshot_2": [asdict(i) for i in issues2], + }, + } + + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2) + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + +def main( + snap1_path: str = "data/snapshot_1.csv", + snap2_path: str = "data/snapshot_2.csv", + output_dir: str = "output", +) -> None: + out = Path(output_dir) + out.mkdir(parents=True, exist_ok=True) + + print(f"Loading {snap1_path} …") + snap1, issues1 = load_snapshot(Path(snap1_path)) + + print(f"Loading {snap2_path} …") + snap2, issues2 = load_snapshot(Path(snap2_path)) + + print("Reconciling …") + results = reconcile(snap1, snap2) + + csv_path = out / "reconciliation_report.csv" + json_path = out / "reconciliation_report.json" + + write_csv(results, csv_path) + write_json(results, issues1, issues2, json_path) + + # --- Console summary --- + unchanged = sum(1 for r in results if r.status == "unchanged") + qty_changed = sum(1 for r in results if r.status == "quantity_changed") + out_of_stock = sum(1 for r in results if r.status == "out_of_stock") + added = sum(1 for r in results if r.status == "added") + removed = sum(1 for r in results if r.status == "removed") + name_changed = sum(1 for r in results if r.name_changed) + location_changed = sum(1 for r in results if r.location_changed) + total_dq_issues = len(issues1) + len(issues2) + + print("\n── Reconciliation Summary ──────────────────────────") + print(f" Unchanged : {unchanged}") + print(f" Quantity changed : {qty_changed}") + print(f" Out of stock : {out_of_stock}") + print(f" Added (new) : {added}") + print(f" Removed : {removed}") + print(f" Name discrepancies : {name_changed}") + print(f" Location changes : {location_changed}") + print(f" Data quality issues: {total_dq_issues}") + print(f"\nOutputs written to {out}/") + print(f" {csv_path}") + print(f" {json_path}") + + +if __name__ == "__main__": + args = sys.argv[1:] + main(*args[:3]) \ No newline at end of file diff --git a/inventory-reconciliation/tests/test_reconcile.py b/inventory-reconciliation/tests/test_reconcile.py new file mode 100644 index 0000000..c0837c8 --- /dev/null +++ b/inventory-reconciliation/tests/test_reconcile.py @@ -0,0 +1,354 @@ +""" +Tests for inventory reconciliation logic. +Run with: pytest tests/ -v +""" + +import pytest +from reconcile import ( + normalise_sku, + normalise_name, + parse_quantity, + normalise_date, + normalise_row, + load_snapshot, + reconcile, + InventoryItem, +) +from pathlib import Path +import tempfile +import csv +import os + + +# --------------------------------------------------------------------------- +# Unit tests – normalisation helpers +# --------------------------------------------------------------------------- + +class TestNormaliseSku: + def test_already_correct(self): + assert normalise_sku("SKU-001") == "SKU-001" + + def test_lowercase(self): + assert normalise_sku("sku-008") == "SKU-008" + + def test_missing_dash(self): + assert normalise_sku("SKU005") == "SKU-005" + + def test_missing_dash_three_digits(self): + assert normalise_sku("SKU018") == "SKU-018" + + def test_leading_trailing_whitespace(self): + assert normalise_sku(" SKU-010 ") == "SKU-010" + + def test_lowercase_missing_dash(self): + assert normalise_sku("sku005") == "SKU-005" + + +class TestNormaliseName: + def test_clean_name(self): + assert normalise_name("Widget A") == "Widget A" + + def test_leading_space(self): + assert normalise_name(" Widget B") == "Widget B" + + def test_trailing_space(self): + assert normalise_name("Mounting Bracket Large ") == "Mounting Bracket Large" + + def test_both_sides(self): + assert normalise_name(" HDMI Cable 3ft ") == "HDMI Cable 3ft" + + +class TestParseQuantity: + def test_integer_string(self): + assert parse_quantity("150") == 150.0 + + def test_float_string(self): + assert parse_quantity("70.0") == 70.0 + + def test_negative(self): + assert parse_quantity("-5") == -5.0 + + def test_non_numeric(self): + assert parse_quantity("N/A") is None + + def test_empty_string(self): + assert parse_quantity("") is None + + def test_zero(self): + assert parse_quantity("0") == 0.0 + + +class TestNormaliseDate: + def test_iso_format(self): + assert normalise_date("2024-01-15") == "2024-01-15" + + def test_us_format(self): + assert normalise_date("01/15/2024") == "2024-01-15" + + def test_whitespace(self): + assert normalise_date(" 2024-01-08 ") == "2024-01-08" + + def test_unknown_format_passthrough(self): + # Unrecognised formats are returned as-is so we don't silently drop data + assert normalise_date("15-Jan-2024") == "15-Jan-2024" + + +class TestNormaliseRow: + def test_snapshot2_headers(self): + row = {"sku": "SKU-001", "product_name": "Widget A", "qty": "100", + "warehouse": "Warehouse A", "updated_at": "2024-01-15"} + result = normalise_row(row) + assert result["name"] == "Widget A" + assert result["quantity"] == "100" + assert result["location"] == "Warehouse A" + assert result["last_counted"] == "2024-01-15" + + def test_snapshot1_headers_unchanged(self): + row = {"sku": "SKU-001", "name": "Widget A", "quantity": "100", + "location": "Warehouse A", "last_counted": "2024-01-08"} + result = normalise_row(row) + assert result["name"] == "Widget A" + assert result["quantity"] == "100" + + +# --------------------------------------------------------------------------- +# Integration tests – load_snapshot with temp CSV files +# --------------------------------------------------------------------------- + +def _write_csv(rows: list[dict], headers: list[str]) -> Path: + """Helper: write rows to a temp CSV and return its path.""" + tmp = tempfile.NamedTemporaryFile( + mode="w", suffix=".csv", delete=False, newline="", encoding="utf-8" + ) + writer = csv.DictWriter(tmp, fieldnames=headers) + writer.writeheader() + writer.writerows(rows) + tmp.close() + return Path(tmp.name) + + +class TestLoadSnapshot: + def teardown_method(self): + # Clean up any temp files created during tests + pass + + def test_basic_load(self): + path = _write_csv( + [{"sku": "SKU-001", "name": "Widget A", "quantity": "150", + "location": "Warehouse A", "last_counted": "2024-01-08"}], + ["sku", "name", "quantity", "location", "last_counted"], + ) + items, issues = load_snapshot(path) + os.unlink(path) + assert "SKU-001" in items + assert items["SKU-001"].name == "Widget A" + assert items["SKU-001"].quantity == 150.0 + assert issues == [] + + def test_normalises_sku_on_load(self): + path = _write_csv( + [{"sku": "SKU005", "name": "Cable", "quantity": "10", + "location": "Warehouse C", "last_counted": "2024-01-15"}], + ["sku", "name", "quantity", "location", "last_counted"], + ) + items, issues = load_snapshot(path) + os.unlink(path) + assert "SKU-005" in items + assert any(i.issue == "Non-standard SKU format" for i in issues) + + def test_duplicate_sku_flagged(self): + path = _write_csv( + [ + {"sku": "SKU-045", "name": "Multimeter Pro", "quantity": "23", + "location": "Warehouse A", "last_counted": "2024-01-15"}, + {"sku": "SKU-045", "name": "Multimeter Pro", "quantity": "-5", + "location": "Warehouse B", "last_counted": "2024-01-15"}, + ], + ["sku", "name", "quantity", "location", "last_counted"], + ) + items, issues = load_snapshot(path) + os.unlink(path) + assert len(items) == 1 # second ignored + assert items["SKU-045"].quantity == 23.0 # first row kept + assert any("Duplicate SKU" in i.issue for i in issues) + + def test_negative_quantity_flagged(self): + path = _write_csv( + [{"sku": "SKU-099", "name": "Bad Item", "quantity": "-5", + "location": "Warehouse A", "last_counted": "2024-01-15"}], + ["sku", "name", "quantity", "location", "last_counted"], + ) + items, issues = load_snapshot(path) + os.unlink(path) + assert any(i.issue == "Negative quantity" for i in issues) + + def test_float_quantity_normalised(self): + path = _write_csv( + [{"sku": "SKU-002", "name": "Widget B", "quantity": "70.0", + "location": "Warehouse A", "last_counted": "2024-01-15"}], + ["sku", "name", "quantity", "location", "last_counted"], + ) + items, issues = load_snapshot(path) + os.unlink(path) + assert items["SKU-002"].quantity == 70.0 + assert any("float" in i.issue for i in issues) + + def test_us_date_normalised(self): + path = _write_csv( + [{"sku": "SKU-035", "name": "Cable Ties", "quantity": "1420", + "location": "Warehouse C", "last_counted": "01/15/2024"}], + ["sku", "name", "quantity", "location", "last_counted"], + ) + items, issues = load_snapshot(path) + os.unlink(path) + assert items["SKU-035"].last_counted == "2024-01-15" + assert any(i.issue == "Non-ISO date format" for i in issues) + + def test_snapshot2_column_aliases(self): + path = _write_csv( + [{"sku": "SKU-001", "product_name": "Widget A", "qty": "145", + "warehouse": "Warehouse A", "updated_at": "2024-01-15"}], + ["sku", "product_name", "qty", "warehouse", "updated_at"], + ) + items, issues = load_snapshot(path) + os.unlink(path) + assert "SKU-001" in items + assert items["SKU-001"].name == "Widget A" + assert items["SKU-001"].quantity == 145.0 + + +# --------------------------------------------------------------------------- +# Unit tests – reconcile() +# --------------------------------------------------------------------------- + +def _item(sku, name, qty, loc="Warehouse A"): + return InventoryItem(sku=sku, name=name, quantity=qty, location=loc, + last_counted="2024-01-15") + + +class TestReconcile: + def test_unchanged_item(self): + snap1 = {"SKU-001": _item("SKU-001", "Widget A", 150)} + snap2 = {"SKU-001": _item("SKU-001", "Widget A", 150)} + results = reconcile(snap1, snap2) + assert len(results) == 1 + assert results[0].status == "unchanged" + assert results[0].qty_delta == 0 + + def test_quantity_decreased(self): + snap1 = {"SKU-001": _item("SKU-001", "Widget A", 150)} + snap2 = {"SKU-001": _item("SKU-001", "Widget A", 145)} + results = reconcile(snap1, snap2) + assert results[0].status == "quantity_changed" + assert results[0].qty_delta == -5 + assert results[0].qty_direction == "decreased" + + def test_quantity_increased(self): + snap1 = {"SKU-001": _item("SKU-001", "Widget A", 100)} + snap2 = {"SKU-001": _item("SKU-001", "Widget A", 120)} + results = reconcile(snap1, snap2) + assert results[0].status == "quantity_changed" + assert results[0].qty_delta == 20 + assert results[0].qty_direction == "increased" + + def test_item_removed(self): + snap1 = {"SKU-025": _item("SKU-025", "VGA Cable", 50)} + snap2 = {} + results = reconcile(snap1, snap2) + assert results[0].status == "removed" + assert results[0].snap2_qty is None + + def test_item_added(self): + snap1 = {} + snap2 = {"SKU-076": _item("SKU-076", "Stream Deck Mini", 15)} + results = reconcile(snap1, snap2) + assert results[0].status == "added" + assert results[0].snap1_qty is None + + def test_name_change_detected(self): + snap1 = {"SKU-045": _item("SKU-045", "Multimeter Pro", 25)} + snap2 = {"SKU-045": _item("SKU-045", "Multimeter Professional", 23)} + results = reconcile(snap1, snap2) + assert results[0].name_changed is True + assert results[0].snap1_name == "Multimeter Pro" + assert results[0].snap2_name == "Multimeter Professional" + + def test_results_sorted_by_sku(self): + snap1 = { + "SKU-003": _item("SKU-003", "C", 1), + "SKU-001": _item("SKU-001", "A", 1), + } + snap2 = { + "SKU-002": _item("SKU-002", "B", 1), + "SKU-001": _item("SKU-001", "A", 1), + } + results = reconcile(snap1, snap2) + skus = [r.sku for r in results] + assert skus == sorted(skus) + + def test_none_quantity_delta_when_either_missing(self): + snap1 = {"SKU-001": _item("SKU-001", "Widget", None)} + snap2 = {"SKU-001": _item("SKU-001", "Widget", 100)} + results = reconcile(snap1, snap2) + assert results[0].qty_delta is None + + def test_empty_snapshots(self): + assert reconcile({}, {}) == [] + + def test_all_statuses_in_one_pass(self): + snap1 = { + "SKU-001": _item("SKU-001", "Same", 100), + "SKU-002": _item("SKU-002", "Changed", 200), + "SKU-003": _item("SKU-003", "Removed", 50), + } + snap2 = { + "SKU-001": _item("SKU-001", "Same", 100), + "SKU-002": _item("SKU-002", "Changed", 180), + "SKU-004": _item("SKU-004", "Added", 15), + } + results = {r.sku: r for r in reconcile(snap1, snap2)} + assert results["SKU-001"].status == "unchanged" + assert results["SKU-002"].status == "quantity_changed" + assert results["SKU-003"].status == "removed" + assert results["SKU-004"].status == "added" + + def test_zero_quantity_flagged_as_out_of_stock(self): + # An item hitting zero is operationally unavailable but still in the system — + # distinct from "removed" (absent from snapshot entirely). + snap1 = {"SKU-099": _item("SKU-099", "Widget", 10)} + snap2 = {"SKU-099": _item("SKU-099", "Widget", 0)} + results = reconcile(snap1, snap2) + assert results[0].status == "out_of_stock" + assert results[0].qty_delta == -10 + assert results[0].qty_direction == "decreased" + + def test_out_of_stock_is_not_removed(self): + # out_of_stock and removed must be distinguishable + snap1 = {"SKU-099": _item("SKU-099", "Widget", 10)} + snap2 = {"SKU-099": _item("SKU-099", "Widget", 0)} + results = reconcile(snap1, snap2) + assert results[0].status != "removed" + assert results[0].snap2_qty == 0 + + def test_location_change_detected(self): + snap1 = {"SKU-010": _item("SKU-010", "Bracket", 720, loc="Warehouse B")} + snap2 = {"SKU-010": _item("SKU-010", "Bracket", 720, loc="Warehouse A")} + results = reconcile(snap1, snap2) + assert results[0].location_changed is True + assert results[0].snap1_location == "Warehouse B" + assert results[0].snap2_location == "Warehouse A" + assert results[0].status == "unchanged" # qty didn't change + + def test_no_location_change_when_same(self): + snap1 = {"SKU-001": _item("SKU-001", "Widget", 100, loc="Warehouse A")} + snap2 = {"SKU-001": _item("SKU-001", "Widget", 100, loc="Warehouse A")} + results = reconcile(snap1, snap2) + assert results[0].location_changed is False + assert results[0].snap1_location is None + assert results[0].snap2_location is None + + def test_qty_direction_none_when_unchanged(self): + snap1 = {"SKU-001": _item("SKU-001", "Widget", 100)} + snap2 = {"SKU-001": _item("SKU-001", "Widget", 100)} + results = reconcile(snap1, snap2) + assert results[0].qty_direction is None \ No newline at end of file