feat: detect machine replacements in cantaloupe sync
Add detect_replacements() function that identifies when a new Asset ID appears at the same customer + address as an existing asset not in the current import. Uses two confidence levels: - high: exact address match - medium: same building_name or same street name Stored in diff_summary under 'replacements' key for admin review UI.
This commit is contained in:
@@ -275,6 +275,123 @@ def _fetch_live_data(conn: sqlite3.Connection) -> dict[str, list[dict]]:
|
|||||||
return {"assets": assets, "customers": customers, "locations": locations}
|
return {"assets": assets, "customers": customers, "locations": locations}
|
||||||
|
|
||||||
|
|
||||||
|
def detect_replacements(
|
||||||
|
imported_rows: list[dict],
|
||||||
|
live_assets: list[dict],
|
||||||
|
live_customers: list[dict],
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Detect potential machine replacements where a new Asset ID appears
|
||||||
|
at the same customer + address as an existing asset not in the import.
|
||||||
|
|
||||||
|
Confidence levels:
|
||||||
|
- "high": exact address match (same street address string)
|
||||||
|
- "medium": same customer + same building_name, or same street name
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Build lookups
|
||||||
|
live_assets_by_mid = {str(a.get("machine_id", "")).strip(): a for a in live_assets}
|
||||||
|
imported_mids = set()
|
||||||
|
|
||||||
|
# customer name (lower) → id
|
||||||
|
cust_name_to_id: dict[str, int] = {}
|
||||||
|
for c in live_customers:
|
||||||
|
cname = str(c.get("name", "")).strip()
|
||||||
|
if cname:
|
||||||
|
cust_name_to_id[cname.lower()] = c["id"]
|
||||||
|
|
||||||
|
# Collect all imported machine IDs
|
||||||
|
for row in imported_rows:
|
||||||
|
mid = str(row.get("machine_id", "")).strip()
|
||||||
|
if mid:
|
||||||
|
imported_mids.add(mid)
|
||||||
|
|
||||||
|
replacements: list[dict] = []
|
||||||
|
seen_pairs: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
|
for row in imported_rows:
|
||||||
|
new_mid = str(row.get("machine_id", "")).strip()
|
||||||
|
if not new_mid:
|
||||||
|
continue
|
||||||
|
if new_mid in live_assets_by_mid:
|
||||||
|
continue # already exists, not a replacement
|
||||||
|
|
||||||
|
cust_name = str(row.get("_customer_name", "")).strip()
|
||||||
|
addr = str(row.get("address", "")).strip()
|
||||||
|
bldg = str(row.get("building_name", "")).strip()
|
||||||
|
|
||||||
|
if not cust_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cust_id = cust_name_to_id.get(cust_name.lower())
|
||||||
|
if cust_id is None:
|
||||||
|
continue # unknown customer, can't match
|
||||||
|
|
||||||
|
# Scan live assets at the same customer, not in the import
|
||||||
|
for old_mid, live in live_assets_by_mid.items():
|
||||||
|
if old_mid == new_mid:
|
||||||
|
continue
|
||||||
|
if live.get("customer_id") != cust_id:
|
||||||
|
continue
|
||||||
|
if old_mid in imported_mids:
|
||||||
|
continue # this asset IS in the import, not a leftover
|
||||||
|
|
||||||
|
# Avoid duplicate pairs
|
||||||
|
pair = (new_mid, old_mid)
|
||||||
|
if pair in seen_pairs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
live_addr = str(live.get("address", "")).strip()
|
||||||
|
live_bldg = str(live.get("building_name", "")).strip()
|
||||||
|
|
||||||
|
confidence = None
|
||||||
|
|
||||||
|
# High: exact address match
|
||||||
|
if addr and live_addr and addr.lower() == live_addr.lower():
|
||||||
|
confidence = "high"
|
||||||
|
# Medium: same building_name
|
||||||
|
elif bldg and live_bldg and bldg.lower() == live_bldg.lower():
|
||||||
|
confidence = "medium"
|
||||||
|
# Medium: same street name (numbers and suffixes stripped)
|
||||||
|
elif addr and live_addr and _same_street(addr, live_addr):
|
||||||
|
confidence = "medium"
|
||||||
|
|
||||||
|
if confidence:
|
||||||
|
seen_pairs.add(pair)
|
||||||
|
replacements.append({
|
||||||
|
"id": len(replacements) + 1,
|
||||||
|
"new_machine_id": new_mid,
|
||||||
|
"old_machine_id": old_mid,
|
||||||
|
"location_address": addr or live_addr or "",
|
||||||
|
"customer_name": cust_name,
|
||||||
|
"old_asset_name": str(live.get("name", "")).strip(),
|
||||||
|
"new_asset_name": str(row.get("name", "")).strip(),
|
||||||
|
"confidence": confidence,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"replacements": replacements}
|
||||||
|
|
||||||
|
|
||||||
|
def _same_street(addr1: str, addr2: str) -> bool:
|
||||||
|
"""Check if two addresses share the same street name, ignoring numbers and suffixes."""
|
||||||
|
import re
|
||||||
|
suffixes = {
|
||||||
|
"street", "st", "drive", "dr", "road", "rd", "avenue", "ave",
|
||||||
|
"blvd", "boulevard", "lane", "ln", "way", "court", "ct", "circle",
|
||||||
|
"cir", "place", "pl", "highway", "hwy", "suite", "ste", "unit",
|
||||||
|
"floor", "fl", "build", "bldg",
|
||||||
|
}
|
||||||
|
a1 = re.sub(r"\d+", "", addr1.lower())
|
||||||
|
a2 = re.sub(r"\d+", "", addr2.lower())
|
||||||
|
for suffix in suffixes:
|
||||||
|
a1 = re.sub(rf"\b{suffix}\b", "", a1)
|
||||||
|
a2 = re.sub(rf"\b{suffix}\b", "", a2)
|
||||||
|
a1 = re.sub(r"\s+", " ", a1).strip().strip(",").strip("#").strip()
|
||||||
|
a2 = re.sub(r"\s+", " ", a2).strip().strip(",").strip("#").strip()
|
||||||
|
return bool(a1 and a2 and a1 == a2)
|
||||||
|
|
||||||
|
|
||||||
def compute_diff(imported_rows: list[dict], live_data: dict) -> dict:
|
def compute_diff(imported_rows: list[dict], live_data: dict) -> dict:
|
||||||
"""
|
"""
|
||||||
Compare imported Cantaloupe rows against live data.
|
Compare imported Cantaloupe rows against live data.
|
||||||
@@ -445,6 +562,12 @@ async def run_sync(
|
|||||||
live_data = _fetch_live_data(conn)
|
live_data = _fetch_live_data(conn)
|
||||||
diff = compute_diff(mapped_rows, live_data)
|
diff = compute_diff(mapped_rows, live_data)
|
||||||
|
|
||||||
|
# Detect machine replacements
|
||||||
|
replacements = detect_replacements(
|
||||||
|
mapped_rows, live_data["assets"], live_data["customers"]
|
||||||
|
)
|
||||||
|
diff["replacements"] = replacements.get("replacements", [])
|
||||||
|
|
||||||
# Store batch
|
# Store batch
|
||||||
raw_json = _json.dumps(mapped_rows, default=str, ensure_ascii=False)
|
raw_json = _json.dumps(mapped_rows, default=str, ensure_ascii=False)
|
||||||
diff_json = _json.dumps(diff, default=str, ensure_ascii=False)
|
diff_json = _json.dumps(diff, default=str, ensure_ascii=False)
|
||||||
|
|||||||
Reference in New Issue
Block a user