Add suite/room/trailer extraction + ID-based class correction

Parser enhancements:
- Add SUITE_PATTERNS: extract Suite, Ste, STE (case-insensitive)
- Add ROOM_PATTERNS: extract Room, RM, Mailroom, Showroom, etc.
- Add TRAILER_PATTERNS: extract Trailer, TRLR, MASH, Safari, etc.
- Add _extract_suite(), _extract_room(), _extract_trailer() functions
- Update _parse_pattern_a() to extract and strip new fields
- Update parse_place() result dict to include suite/room/trailer
- Add correct_class_by_machine_id() post-processing:
  - Bev model in Bev ID range with Snack class → fix to Bev
  - Unknown class in 9x range → set to Bev
  - Unknown class in 6x-7x range → set to Snack
- Update parse_excel() to include new fields and apply correction

DB writer:
- Add suite, room, trailer to ALL_COLUMNS and COLUMN_TYPES
- Add ALTER TABLE migration for existing databases
This commit is contained in:
2026-05-24 23:22:35 -04:00
parent be9c34f51d
commit af566bc368
2 changed files with 171 additions and 4 deletions
+24 -2
View File
@@ -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):
+147 -2
View File
@@ -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'(?<!Break)(?:Mailroom|MAILROOM|Showroom|SHOWROOM|Game Room|Game Room|Green Room|Emergency Room|Waiting Room|WAITINGROOM|WaitRoom|ICU WAITING ROOM|CARDIO WAITINGROOM|Conf Room|Driver.s Room|Laundry Room)'), lambda m: m.group(0).strip()),
]
# Trailer extraction patterns
TRAILER_PATTERNS = [
(re.compile(r'(?:Trailer|TRAILER|TRLR)\s*#?\s*(\d+)'), lambda m: f"Trailer {m.group(1)}"),
(re.compile(r'(?:Trailer|TRAILER)\s+(\w+)'), lambda m: f"Trailer {m.group(1)}"),
(re.compile(r'MASH Trailer', re.IGNORECASE), lambda m: "Trailer MASH"),
(re.compile(r'Safari Trailer', re.IGNORECASE), lambda m: "Trailer Safari"),
(re.compile(r'^Trailer$', re.IGNORECASE), lambda m: "Trailer"),
]
# ─── Helper: safe string ──────────────────────────────────────────────────────
@@ -529,6 +553,9 @@ def parse_place(place_val, customer_val=None):
"floor": None,
"zone": None,
"zone_type": None,
"suite": None,
"room": None,
"trailer": None,
}
if not raw:
@@ -637,6 +664,9 @@ def _parse_cast_place(raw):
"floor": None,
"zone": None,
"zone_type": "cast",
"suite": None,
"room": None,
"trailer": None,
}
# Last part is the zone/area
@@ -648,6 +678,9 @@ def _parse_cast_place(raw):
"floor": None,
"zone": last,
"zone_type": "cast",
"suite": None,
"room": None,
"trailer": None,
}
@@ -662,6 +695,9 @@ def _parse_g_part(g_part, prefix):
"floor": None,
"zone": None,
"zone_type": "guest",
"suite": None,
"room": None,
"trailer": None,
}
# Extract floor
@@ -702,6 +738,9 @@ def _parse_remainder(remainder):
"floor": None,
"zone": None,
"zone_type": None,
"suite": None,
"room": None,
"trailer": None,
}
if not remainder:
@@ -831,11 +870,41 @@ def _parse_pattern_a(raw):
if building_info["raw"]:
text_no_building = text_no_floor.replace(building_info["raw"], "", 1).strip()
# Extract suite info
suite_info = _extract_suite(text_no_building)
text_no_extra = text_no_building
if suite_info["raw"]:
text_no_extra = text_no_building.replace(suite_info["raw"], "", 1).strip()
# Extract room info
room_info = _extract_room(text_no_extra)
text_no_extra2 = text_no_extra
if room_info["raw"]:
text_no_extra2 = text_no_extra.replace(room_info["raw"], "", 1).strip()
# Extract trailer info
trailer_info = _extract_trailer(text_no_extra2)
text_no_extra3 = text_no_extra2
if trailer_info["raw"]:
text_no_extra3 = text_no_extra2.replace(trailer_info["raw"], "", 1).strip()
# Clean up
remainder = re.sub(r'\s+', ' ', text_no_building).strip()
remainder = re.sub(r'\s+', ' ', text_no_extra3).strip()
# If everything was consumed by extractions (last segment was
# purely floor/building/suite/trailer info), fall back to
# the second-to-last segment as the place
if not remainder and len(parts) >= 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()