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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user