diff --git a/db_writer.py b/db_writer.py index 27d413d..d3a3348 100644 --- a/db_writer.py +++ b/db_writer.py @@ -60,7 +60,7 @@ AUTO_FIELDS = { "has_cashless", } -# All 33 DB columns (Section 11 of the handbook) +# All 36 DB columns (Section 11 of the handbook + suite/room/trailer) # machine_id is the primary key — handled separately ALL_COLUMNS = [ "machine_id", # preserve — primary key @@ -73,6 +73,9 @@ ALL_COLUMNS = [ "company", # auto "place", # auto "building_name", # auto + "suite", # auto (new) + "room", # auto (new) + "trailer", # auto (new) "floor", # auto "zone", # auto "zone_type", # auto @@ -111,6 +114,9 @@ COLUMN_TYPES = { "company": "TEXT", "place": "TEXT", "building_name": "TEXT", + "suite": "TEXT", + "room": "TEXT", + "trailer": "TEXT", "floor": "INTEGER", "zone": "TEXT", "zone_type": "TEXT", @@ -202,7 +208,7 @@ class DatabaseWriter: self.conn.row_factory = sqlite3.Row def create_table(self): - """Create the 'assets' table with all columns from Section 11.""" + """Create the 'assets' table with all columns from Section 11 + suite/room/trailer.""" columns_defs = [] for col in ALL_COLUMNS: col_type = COLUMN_TYPES.get(col, "TEXT") @@ -217,6 +223,22 @@ class DatabaseWriter: + "\n)" ) self.conn.execute(create_sql) + + # Migrate existing table: add any missing columns + existing_cols = set( + r[1] for r in self.conn.execute("PRAGMA table_info(assets)").fetchall() + ) + for col in ALL_COLUMNS + EXTRA_COLUMNS: + if col not in existing_cols: + col_type = COLUMN_TYPES.get(col, "TEXT") + try: + self.conn.execute( + f'ALTER TABLE assets ADD COLUMN "{col}" {col_type}' + ) + print(f" ➕ Added column: {col} ({col_type})") + except Exception as e: + print(f" ⚠️ Could not add column {col}: {e}") + self.conn.commit() def _extract_row_values(self, machine): diff --git a/parser.py b/parser.py index c051807..899b960 100644 --- a/parser.py +++ b/parser.py @@ -133,6 +133,30 @@ BUILDING_PATTERNS = [ re.compile(r'(\w+\s+(?:VILLAS?|BLDG|BUILDING))', re.IGNORECASE), ] +# Suite extraction patterns +SUITE_PATTERNS = [ + (re.compile(r'(?:Suite|SUITE|suite|Ste|STE|ste)\s*#?\s*(\d+)'), lambda m: m.group(1)), + (re.compile(r'(\d+)\s*(?:Suite|SUITE|suite|Ste|STE|ste)'), lambda m: m.group(1)), +] + +# Room extraction patterns +ROOM_PATTERNS = [ + (re.compile(r'(?:Room|ROOM|room)\s*#?\s*(\d+)'), lambda m: f"Room {m.group(1)}"), + (re.compile(r'(?:RM|Rm)\s*#?\s*(\d+)'), lambda m: f"Room {m.group(1)}"), + (re.compile(r'Hotel by (Room \d+)', re.IGNORECASE), lambda m: m.group(1)), + # Room-type locations (not breakrooms) + (re.compile(r'(?= 2: + second_last = _strip_place_suffix(parts[-2]) + second_last = re.sub(r'\s+', ' ', second_last).strip() if second_last else None + if second_last: + remainder = second_last result["floor"] = floor_info["floor"] result["building"] = building_info["building"] + result["suite"] = suite_info["suite"] + result["room"] = room_info["room"] + result["trailer"] = trailer_info["trailer"] result["zone"] = remainder if remainder else None result["place"] = remainder if remainder else last @@ -920,6 +989,33 @@ def _extract_building(text): return {"building": None, "raw": None} +def _extract_suite(text): + """Extract suite number from text. Returns dict with suite and raw match.""" + for pattern, extractor in SUITE_PATTERNS: + m = pattern.search(text) + if m: + return {"suite": extractor(m), "raw": m.group(0)} + return {"suite": None, "raw": None} + + +def _extract_room(text): + """Extract room info from text. Returns dict with room and raw match.""" + for pattern, extractor in ROOM_PATTERNS: + m = pattern.search(text) + if m: + return {"room": extractor(m), "raw": m.group(0)} + return {"room": None, "raw": None} + + +def _extract_trailer(text): + """Extract trailer info from text. Returns dict with trailer and raw match.""" + for pattern, extractor in TRAILER_PATTERNS: + m = pattern.search(text) + if m: + return {"trailer": extractor(m), "raw": m.group(0)} + return {"trailer": None, "raw": None} + + # ─── 4. Customer Parser (Section 1) ─────────────────────────────────────────── def parse_customer(customer_val): @@ -1175,7 +1271,50 @@ def compute_priority(yearly_sales): return "High" -# ─── 9. Main Parser ─────────────────────────────────────────────────────────── +# ─── 9. Machine ID → Class Correction ─────────────────────────────────────── + +def correct_class_by_machine_id(machine): + """ + Post-processing: correct class based on machine_id prefix patterns. + + Rules from data analysis: + - 90xxx-99xxx → Bev (Beverage machine ID range) + - 60xxx-73xxx → Snack or Food (Snack/Food machine ID range) + + Only corrects when the model is clearly a beverage machine (BevMax, Media) + or the class is Unknown and the ID range gives a strong signal. + """ + mid = str(machine.get("machine_id", "")) + cls = machine.get("class", "Unknown") + model = machine.get("model", "") + make = machine.get("make", "") + + # Determine ID range + is_bev_range = mid.startswith(("90", "91", "92", "93", "94", "95", "96", "97", "98", "99")) + is_snack_range = mid.startswith(("60", "61", "62", "63", "64", "65", "66", "67", "68", "69", "70", "71", "72", "73")) + + # Rule 1: In Bev range but classed as Snack with BevMax/Media model → fix to Bev + if is_bev_range and cls in ("Snack", "Snack/Bev") and model and ("BevMax" in model or "Media" in model): + machine["class"] = "Bev" + machine["class_corrected"] = True + machine["class_correction_reason"] = f"Model '{model}' in Bev ID range" + + # Rule 2: Unknown class in Bev range → set to Bev + elif is_bev_range and cls == "Unknown": + machine["class"] = "Bev" + machine["class_corrected"] = True + machine["class_correction_reason"] = "Unknown in Bev ID range" + + # Rule 3: Unknown class in Snack/Food range → set to Snack + elif is_snack_range and cls == "Unknown": + machine["class"] = "Snack" + machine["class_corrected"] = True + machine["class_correction_reason"] = "Unknown in Snack ID range" + + return machine + + +# ─── 10. Main Parser ─────────────────────────────────────────────────────────── # Full column mapping: Excel column index (1-based) → DB field name COLUMN_MAPPING = { @@ -1337,6 +1476,9 @@ def parse_excel(filepath): "place": place_info["place"], "building_name": place_info["building"], "floor": place_info["floor"], + "suite": place_info["suite"], + "room": place_info["room"], + "trailer": place_info["trailer"], "zone": place_info["zone"], "zone_type": place_info["zone_type"], "address": _s(raw.get("Address")), @@ -1391,6 +1533,9 @@ def parse_excel(filepath): "valid_address": _s(raw.get("Valid Address")), } + # Apply machine ID → class correction + machine = correct_class_by_machine_id(machine) + machines.append(machine) wb.close()