643 lines
26 KiB
Python
643 lines
26 KiB
Python
"""
|
|
Serial number → Make, Model, and Class classification for canteen assets.
|
|
|
|
Uses serial number prefix patterns cross-referenced against Cantaloupe export data
|
|
to identify Unknown machines and classify GF Bev vs GF Food.
|
|
|
|
GF = Glass Front (vending industry term).
|
|
- GF Bev = glass-front beverage machines (mostly DN/Dixie Narco)
|
|
- GF Food = glass-front food/snack machines (ALL Crane)
|
|
|
|
Usage:
|
|
python3 classify_makes.py # dry-run (report only)
|
|
python3 classify_makes.py --apply # update DB
|
|
python3 classify_makes.py --stats # show classification stats
|
|
"""
|
|
|
|
import sqlite3
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Optional, Tuple
|
|
|
|
DB_PATH = str(Path(__file__).parent / "assets.db")
|
|
|
|
|
|
# ─── Universal identifier normalization (for photo→DB matching) ───────────
|
|
|
|
def normalize_identifier(raw: str) -> str:
|
|
"""
|
|
Normalize any asset identifier (serial number, barcode, equipment ID,
|
|
connect ID, machine ID) for comparison.
|
|
|
|
- Strips leading label prefixes (S/N:, ID#, Machine ID:, Monyx ID, etc.)
|
|
- Removes dots, dashes, spaces, slashes, colons
|
|
- Uppercases
|
|
- Returns just the alphanumeric core for matching
|
|
|
|
Examples:
|
|
'2500.0100.0025534' → '2500010000255534'
|
|
'201037BA00039' → '201037BA00039'
|
|
'S/N: 2500.0100.0025534' → '2500010000255534'
|
|
'ID# 4434331624226353' → '4434331624226353'
|
|
'Monyx ID 48602143' → '48602143'
|
|
'RY10006338' → 'RY10006338'
|
|
'201037BA00039' → '201037BA00039'
|
|
"""
|
|
if not raw:
|
|
return ''
|
|
s = raw.strip().upper()
|
|
# Strip common label prefixes
|
|
s = re.sub(
|
|
r'^(S/N|SN|SERIAL|SERIAL\s*NO|ID|UID|MACHINE\s*ID|MACHINE|'
|
|
r'EQUIPMENT\s*ID|EQ\s*ID|ASSET\s*ID|ITEM|MODEL|PART\s*NO|'
|
|
r'MONYX\s*ID|PROPERTY\s*OF|BARCODE)\s*[:=#]\s*',
|
|
'', s, flags=re.IGNORECASE
|
|
)
|
|
# Strip leading non-alphanumeric (leftover label debris)
|
|
s = re.sub(r'^[^A-Z0-9]+', '', s)
|
|
# Remove all non-alphanumeric (dots, dashes, spaces, etc.)
|
|
s = re.sub(r'[^A-Z0-9]', '', s)
|
|
return s
|
|
|
|
|
|
def find_asset_by_normalized_id(db_path: str, normalized: str) -> list:
|
|
"""
|
|
Search assets.db for any asset whose serial_number, machine_id, connect_id,
|
|
equipment_id, or barcode matches the given normalized identifier.
|
|
|
|
Supports:
|
|
- Exact match: normalized string equals the DB field (after normalization)
|
|
- Suffix match: if the normalized value is all digits and >= 7 chars,
|
|
match against the last N digits of connect_id and equipment_id (for
|
|
partial OCR reads that capture only the numeric tail of a longer ID).
|
|
|
|
Returns a list of matching rows (dicts).
|
|
"""
|
|
if not normalized or len(normalized) < 3:
|
|
return []
|
|
|
|
import sqlite3
|
|
conn = sqlite3.connect(db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
|
|
# Suffix matching: if the scanned text is purely numeric and >= 7 chars,
|
|
# search for assets whose connect_id or equipment_id ends with those digits.
|
|
# Connect IDs often look like VM-05064-0000099387; OCR might only read "0000099387".
|
|
suffix_conditions = ''
|
|
suffix_params = []
|
|
if len(normalized) >= 7 and normalized.isdigit():
|
|
# Match against the last N digits of connect_id and equipment_id.
|
|
# Use LIKE with rightmost-anchored pattern: %<digits>
|
|
suffix_conditions = """
|
|
OR replace(replace(replace(replace(upper(connect_id), '-', ''), '.', ''), ' ', ''), '/', '') LIKE ?
|
|
OR replace(replace(replace(replace(upper(equipment_id), '-', ''), '.', ''), ' ', ''), '/', '') LIKE ?
|
|
"""
|
|
suffix_params = ['%' + normalized, '%' + normalized]
|
|
|
|
rows = conn.execute(f"""
|
|
SELECT id, machine_id, name, serial_number, connect_id, equipment_id,
|
|
barcode, make, model, category
|
|
FROM assets
|
|
WHERE replace(replace(replace(replace(upper(serial_number), '-', ''), '.', ''), ' ', ''), '/', '') = ?
|
|
OR replace(replace(replace(replace(upper(connect_id), '-', ''), '.', ''), ' ', ''), '/', '') = ?
|
|
OR replace(replace(replace(replace(upper(equipment_id), '-', ''), '.', ''), ' ', ''), '/', '') = ?
|
|
OR replace(replace(replace(replace(upper(machine_id), '-', ''), '.', ''), ' ', ''), '/', '') = ?
|
|
OR replace(replace(replace(replace(upper(barcode), '-', ''), '.', ''), ' ', ''), '/', '') = ?
|
|
{suffix_conditions}
|
|
""", (normalized, normalized, normalized, normalized, normalized, *suffix_params)).fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def find_assets_by_scanned_text(db_path: str, raw_text: str) -> list:
|
|
"""
|
|
Given raw OCR text from a label photo, extract all plausible identifiers
|
|
and search the DB for matches. Returns list of (normalized, field, asset) tuples.
|
|
"""
|
|
if not raw_text:
|
|
return []
|
|
|
|
results = []
|
|
|
|
# 1. Try each line as a potential identifier
|
|
lines = raw_text.strip().split('\n')
|
|
for line in lines:
|
|
line = line.strip()
|
|
if not line or len(line) < 4:
|
|
continue
|
|
norm = normalize_identifier(line)
|
|
if len(norm) >= 4:
|
|
matches = find_asset_by_normalized_id(db_path, norm)
|
|
for m in matches:
|
|
results.append((norm, line.strip(), m))
|
|
|
|
# 2. Also try individual number-like tokens on each line (space-separated values on a line)
|
|
for line in lines:
|
|
tokens = re.findall(r'[A-Z0-9]{4,}', line.upper())
|
|
for token in tokens:
|
|
if len(token) >= 4:
|
|
matches = find_asset_by_normalized_id(db_path, token)
|
|
for m in matches:
|
|
results.append((token, line.strip(), m))
|
|
|
|
# Deduplicate by asset id
|
|
seen = set()
|
|
unique = []
|
|
for norm, src, asset in results:
|
|
if asset['id'] not in seen:
|
|
seen.add(asset['id'])
|
|
unique.append({'normalized': norm, 'source_text': src, 'asset': asset})
|
|
return unique
|
|
|
|
|
|
# ─── Character substitution (human entry errors) ──────────────────────────
|
|
|
|
def normalise_serial(sn: str) -> str:
|
|
"""
|
|
Normalise a serial number, applying common human-entry substitutions.
|
|
S→5, I→1, O→0 are the most common errors when hand-entering serial plates.
|
|
|
|
Also handles double-strike errors (I5SS → I5S, i.e. extra S typed twice).
|
|
Returns the cleaned serial.
|
|
"""
|
|
if not sn:
|
|
return ''
|
|
s = sn.strip().upper()
|
|
# Common substitutions (order matters — do before other processing)
|
|
s = s.replace('I', '1') # I→1
|
|
s = s.replace('O', '0') # O→0
|
|
s = s.replace('S', '5') # S→5
|
|
|
|
# Handle double-strike: I5SS → 1555 → collapse extra 5
|
|
# Pattern: 1555 → 155 (extra digit from double-strike or repeat key)
|
|
s = re.sub(r'1555+', '155', s)
|
|
# Remove dashes for pattern matching
|
|
return s
|
|
|
|
|
|
def clean_for_pattern(sn: str) -> str:
|
|
"""Remove dashes and whitespace for pattern matching."""
|
|
return normalise_serial(sn).replace('-', '').strip()
|
|
|
|
|
|
# ─── Rule definitions ─────────────────────────────────────────────────────
|
|
|
|
# Each rule is: (pattern_fn, make, model, gf_class, confidence)
|
|
# pattern_fn takes cleaned serial and returns True/False
|
|
# gf_class is the inferred GF classification (GF Bev, GF Food, Bev, Snack, etc.)
|
|
|
|
def _make_rule(prefixes, make, model, gf_class, confidence='high',
|
|
min_len=None, max_len=None):
|
|
"""Factory for prefix-based rules."""
|
|
def match(sn_clean):
|
|
if min_len and len(sn_clean) < min_len:
|
|
return False
|
|
if max_len and len(sn_clean) > max_len:
|
|
return False
|
|
for p in prefixes:
|
|
if sn_clean.startswith(p):
|
|
return True
|
|
return False
|
|
return (match, make, model, gf_class, confidence)
|
|
|
|
|
|
# ─── VENDO ─────────────────────────────────────────────────────────────────
|
|
# Serial format: 000XXXXXX (9-digit, starts with 000)
|
|
VENDO_RULE = _make_rule(['000'], 'Vendo', '621/721/821', 'Bev',
|
|
min_len=8, max_len=10)
|
|
|
|
# ─── DN / DIXIE NARCO ─────────────────────────────────────────────────────
|
|
# Serial format: 11XXXXXXXXXX (12-13 digit, starts with 11, various sub-prefixes)
|
|
DN_PREFIXES = [
|
|
'112301', '112304', '112402', '112404', '112408', '112503',
|
|
'112601', '112602', '112603', '112006', '112010', '112011',
|
|
'111808', '111812', '111904', '111905', '111906', '112111',
|
|
'112206', '112510',
|
|
# Other DN 11-prefixed: 11 + 4-digit year/week code
|
|
]
|
|
DN_RULE = _make_rule(DN_PREFIXES, 'DN', 'BevMax/5800/3800/200E', 'Bev',
|
|
min_len=10, max_len=14)
|
|
|
|
# Broader DN catch: any 12-13 digit serial starting with '11'
|
|
def _dn_broad(sn_clean):
|
|
return len(sn_clean) >= 11 and sn_clean.startswith('11') and sn_clean.isdigit()
|
|
DN_BROAD = (_dn_broad, 'DN', 'Unknown', 'Bev', 'medium')
|
|
|
|
# ─── CRANE ─────────────────────────────────────────────────────────────────
|
|
# Multiple serial formats for Crane/National machines
|
|
|
|
# 9-digit: 167XXXXXX, 168XXXXXX (National 167/168 series)
|
|
CRANE_167_168 = _make_rule(['167', '168'], 'Crane', '15x/16x', 'Snack',
|
|
min_len=9, max_len=10)
|
|
|
|
# 9-digit: 186XXXXXX, 187XXXXXX (Crane Merchant Media)
|
|
CRANE_186_187 = _make_rule(['186', '187'], 'Crane', 'Merchant Media', 'Snack',
|
|
min_len=9, max_len=10)
|
|
|
|
# 9-digit: 180XXXXXX, 181XXXXXX
|
|
CRANE_180_181 = _make_rule(['180', '181'], 'Crane', 'Merchant Media', 'Snack',
|
|
min_len=9, max_len=10)
|
|
|
|
# 12-digit: 222XXXXXXXXX (Crane Merchant Media, 186, 187)
|
|
def _crane_222(sn_clean):
|
|
return len(sn_clean) >= 11 and sn_clean.startswith('222') and sn_clean.isdigit()
|
|
CRANE_222 = (_crane_222, 'Crane', 'Merchant Media', 'Snack', 'high')
|
|
|
|
# 12-digit: 221XXXXXXXXX (Crane Merchant Media, 472)
|
|
def _crane_221(sn_clean):
|
|
return len(sn_clean) >= 11 and sn_clean.startswith('221') and sn_clean.isdigit()
|
|
CRANE_221 = (_crane_221, 'Crane', 'Merchant Media', 'Snack/Food', 'high')
|
|
|
|
# 471/472: dash or 9-digit
|
|
def _crane_47(sn_clean):
|
|
return (sn_clean.startswith('471') or sn_clean.startswith('472')) and len(sn_clean) >= 8
|
|
CRANE_47 = (_crane_47, 'Crane', '471/472', 'Food', 'high')
|
|
|
|
# Dash format: 168-XXXXXX, 167-XXXXXX
|
|
def _crane_dash(orig_sn):
|
|
"""Check original serial (with dashes) for Crane dash patterns."""
|
|
if not orig_sn:
|
|
return False
|
|
s = orig_sn.strip()
|
|
for prefix in ['168-', '167-', '472-', '471-', '449-', '186-', '187-']:
|
|
if s.startswith(prefix):
|
|
return True
|
|
return False
|
|
CRANE_DASH = (_crane_dash, 'Crane', '15x/16x', 'Snack', 'high')
|
|
|
|
# ─── ROYAL ─────────────────────────────────────────────────────────────────
|
|
# Format 1: 20YYMMCAXXXXX or 20YYWWBAXXXXX (year+week+code+sequence)
|
|
def _royal_20xx(sn_clean):
|
|
return (sn_clean.startswith('200') or sn_clean.startswith('201')) and len(sn_clean) >= 10
|
|
ROYAL_20XX = (_royal_20xx, 'Royal', 'GIII', 'Bev', 'high')
|
|
|
|
# Format 2: 1[5-9]WW [AL/BL/etc] XXXXX (old Royal format)
|
|
def _royal_old(sn_clean):
|
|
"""Match Royal old format: 15WW AL XXXXX etc."""
|
|
return bool(re.match(r'^1[5-9]\d{2}[A-Z]{2}\d{5}$', sn_clean))
|
|
ROYAL_OLD = (_royal_old, 'Royal', 'GIII', 'Bev', 'medium')
|
|
|
|
# 20xx with BA/CA/PA codes in original format
|
|
def _royal_code(orig_sn):
|
|
if not orig_sn:
|
|
return False
|
|
s = orig_sn.strip().upper()
|
|
return bool(re.search(r'(BA|CA|PA)\d{5}', s))
|
|
ROYAL_CODE = (_royal_code, 'Royal', 'GIII', 'Bev', 'high')
|
|
|
|
# ─── USI ───────────────────────────────────────────────────────────────────
|
|
# 12-digit serials, often starting with 12, 14, 15
|
|
def _usi_12digit(sn_clean):
|
|
return len(sn_clean) == 12 and sn_clean.isdigit() and sn_clean[:2] in ('12', '14', '15')
|
|
USI_12 = (_usi_12digit, 'USI', 'Mercato/Evoke/30xx', 'Snack', 'medium')
|
|
|
|
# 7-digit serials (older USI)
|
|
def _usi_7digit(sn_clean):
|
|
return len(sn_clean) == 7 and sn_clean.isdigit() and sn_clean.startswith('13')
|
|
USI_7 = (_usi_7digit, 'USI', '30xx', 'Snack', 'medium')
|
|
|
|
# ─── AMS ───────────────────────────────────────────────────────────────────
|
|
# Dash format: 1-XXXXXXXX or 1-XXXX-XXXX
|
|
def _ams_dash(orig_sn):
|
|
if not orig_sn:
|
|
return False
|
|
s = orig_sn.strip()
|
|
return bool(re.match(r'^1-\d{4,8}', s)) or bool(re.match(r'^1-\d{4}-\d{4}', s))
|
|
AMS_DASH = (_ams_dash, 'AMS', '3561/Sensit 3', 'Snack', 'high')
|
|
|
|
# AMS 11-digit: 1118XXXXXXXX, 1121XXXXXXXX
|
|
AMS_LONG = _make_rule(['111809', '111811', '112111', '112034'], 'AMS', '3561/Sensit 3', 'Snack',
|
|
min_len=10, max_len=14)
|
|
|
|
# ─── VE ────────────────────────────────────────────────────────────────────
|
|
# VE serials: often short, with revision patterns
|
|
def _ve_pattern(sn_clean):
|
|
return bool(re.match(r'^[A-Z]\d{7}', sn_clean))
|
|
VE_PATTERN = (_ve_pattern, 'VE', 'Revision Door', 'Snack', 'low')
|
|
|
|
# ─── Edge cases / near-misses ──────────────────────────────────────────────
|
|
|
|
# 8-digit 00XXXXXX → likely Vendo missing one leading zero (Vendo is 000XXXXXX)
|
|
def _vendo_8digit(sn_clean):
|
|
return len(sn_clean) == 8 and sn_clean.startswith('00') and sn_clean.isdigit()
|
|
VENDO_8 = (_vendo_8digit, 'Vendo', '621/721/821', 'Bev', 'medium')
|
|
|
|
# BA/PA suffix without year prefix → Royal
|
|
def _royal_suffix(orig_sn):
|
|
if not orig_sn:
|
|
return False
|
|
s = orig_sn.strip().upper()
|
|
return bool(re.search(r'\d{6,8}(BA|PA|CA)$', s))
|
|
ROYAL_SUFFIX = (_royal_suffix, 'Royal', 'GIII', 'Bev', 'medium')
|
|
|
|
# RY prefix → Royal abbreviation
|
|
def _royal_ry(orig_sn):
|
|
if not orig_sn:
|
|
return False
|
|
s = orig_sn.strip().upper()
|
|
return s.startswith('RY') and len(s) >= 6
|
|
ROYAL_RY = (_royal_ry, 'Royal', 'GIII', 'Bev', 'low')
|
|
|
|
# ─── Short DN serals (8-digit formats seen in DB) ──────────────────────────
|
|
# DN has 8-digit serials: 11440039, 11495117, 24110016, 24220045, etc.
|
|
# Narrow prefix rules to avoid false positives with Royal (1100xxxx) or Crane
|
|
|
|
def _dn_8digit_short(sn_clean):
|
|
"""8-digit DN serials with specific known prefixes."""
|
|
return (len(sn_clean) == 8 and sn_clean.isdigit() and
|
|
(sn_clean.startswith('114') or sn_clean.startswith('241') or
|
|
sn_clean.startswith('1149') or sn_clean.startswith('2411')))
|
|
DN_SHORT_8 = (_dn_8digit_short, 'DN', 'BevMax/5800/3800/200E', 'Bev', 'medium')
|
|
|
|
# 10-digit 76/77 prefix → DN (e.g., 76820565BD, 76920191BD, 77090262AE)
|
|
def _dn_76_77(sn_clean):
|
|
"""10-digit serials starting with 76 or 77 → DN."""
|
|
return len(sn_clean) == 10 and sn_clean.isdigit() and sn_clean[:2] in ('76', '77')
|
|
DN_76_77 = (_dn_76_77, 'DN', 'BevMax/5800/3800/200E', 'Bev', 'medium')
|
|
|
|
# ─── RULE COLLECTION (ordered: first match wins) ───────────────────────────
|
|
|
|
RULES = [
|
|
# (description, rule_tuple)
|
|
('Vendo 000-', VENDO_RULE),
|
|
('Crane 167/168', CRANE_167_168),
|
|
('Crane 186/187', CRANE_186_187),
|
|
('Crane 180/181', CRANE_180_181),
|
|
('Crane 222-', CRANE_222),
|
|
('Crane 221-', CRANE_221),
|
|
('Crane 47x', CRANE_47),
|
|
('Crane dash', CRANE_DASH),
|
|
('Royal 20xx', ROYAL_20XX),
|
|
('Royal old format', ROYAL_OLD),
|
|
('Royal BA/CA code', ROYAL_CODE),
|
|
('USI 12-digit', USI_12),
|
|
('USI 7-digit', USI_7),
|
|
('AMS dash', AMS_DASH),
|
|
('AMS long', AMS_LONG),
|
|
('DN specific prefixes', DN_RULE),
|
|
('DN short 8-digit', DN_SHORT_8),
|
|
('DN 76/77 10-digit', DN_76_77),
|
|
('DN broad 11x', DN_BROAD),
|
|
('Vendo 8-digit 00', VENDO_8),
|
|
('Royal BA/PA suffix', ROYAL_SUFFIX),
|
|
('Royal RY prefix', ROYAL_RY),
|
|
('VE pattern', VE_PATTERN),
|
|
]
|
|
|
|
|
|
# ─── Classification logic ──────────────────────────────────────────────────
|
|
|
|
def classify_by_serial(serial_number: str) -> Optional[dict]:
|
|
"""
|
|
Attempt to classify an asset by its serial number.
|
|
Returns a dict with make, model, gf_class, confidence, rule_name
|
|
or None if no rule matches.
|
|
"""
|
|
if not serial_number or not serial_number.strip():
|
|
return None
|
|
|
|
orig = serial_number.strip()
|
|
clean = clean_for_pattern(orig)
|
|
|
|
for rule_name, (pattern_fn, make, model, gf_class, confidence) in RULES:
|
|
try:
|
|
if pattern_fn(clean if 'dash' not in rule_name.lower() and
|
|
'code' not in rule_name.lower()
|
|
else orig):
|
|
return {
|
|
'make': make,
|
|
'model': model,
|
|
'gf_class': gf_class,
|
|
'confidence': confidence,
|
|
'rule': rule_name,
|
|
}
|
|
except Exception:
|
|
continue
|
|
|
|
return None
|
|
|
|
|
|
def classify_unknown_assets(db_path: str, apply: bool = False) -> dict:
|
|
"""
|
|
Find all assets with Unknown/empty make and attempt to classify by serial.
|
|
|
|
Args:
|
|
db_path: Path to assets.db
|
|
apply: If True, actually UPDATE the DB. If False, dry-run report.
|
|
|
|
Returns a report dict.
|
|
"""
|
|
conn = sqlite3.connect(db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
|
|
# Find Unknown-make assets with non-empty serials
|
|
rows = conn.execute("""
|
|
SELECT id, machine_id, name, serial_number, make, model, category
|
|
FROM assets
|
|
WHERE (make = 'Unknown' OR make IS NULL OR make = '')
|
|
AND serial_number IS NOT NULL
|
|
AND serial_number != ''
|
|
ORDER BY serial_number
|
|
""").fetchall()
|
|
|
|
results = {
|
|
'total_unknown': len(rows),
|
|
'classified': [],
|
|
'unmatched': [],
|
|
'by_make': {},
|
|
'by_rule': {},
|
|
'by_confidence': {'high': 0, 'medium': 0, 'low': 0},
|
|
}
|
|
|
|
for row in rows:
|
|
classification = classify_by_serial(row['serial_number'])
|
|
|
|
if classification and classification['confidence'] != 'low':
|
|
entry = {
|
|
'id': row['id'],
|
|
'machine_id': row['machine_id'],
|
|
'name': row['name'],
|
|
'serial': row['serial_number'],
|
|
'current_make': row['make'],
|
|
'current_model': row['model'],
|
|
'current_category': row['category'],
|
|
**classification,
|
|
}
|
|
|
|
# Infer the best category/class if current is generic
|
|
if row['category'] in ('Other', 'Unknown', '', None):
|
|
entry['suggested_category'] = classification['gf_class']
|
|
else:
|
|
entry['suggested_category'] = row['category']
|
|
|
|
results['classified'].append(entry)
|
|
results['by_make'][classification['make']] = \
|
|
results['by_make'].get(classification['make'], 0) + 1
|
|
results['by_rule'][classification['rule']] = \
|
|
results['by_rule'].get(classification['rule'], 0) + 1
|
|
results['by_confidence'][classification['confidence']] += 1
|
|
else:
|
|
results['unmatched'].append({
|
|
'id': row['id'],
|
|
'machine_id': row['machine_id'],
|
|
'name': row['name'],
|
|
'serial': row['serial_number'],
|
|
'reason': classification['rule'] if classification else 'no rule matched',
|
|
})
|
|
|
|
# Apply updates if requested
|
|
if apply and results['classified']:
|
|
updated = 0
|
|
for entry in results['classified']:
|
|
if entry['confidence'] == 'low':
|
|
continue # Skip low-confidence matches
|
|
conn.execute("""
|
|
UPDATE assets
|
|
SET make = ?,
|
|
model = CASE WHEN model = 'Unknown' OR model IS NULL OR model = ''
|
|
THEN ? ELSE model END,
|
|
category = CASE WHEN category = 'Other' OR category IS NULL OR category = ''
|
|
THEN ? ELSE category END,
|
|
updated_at = datetime('now')
|
|
WHERE id = ?
|
|
""", (
|
|
entry['make'],
|
|
entry['model'],
|
|
entry['suggested_category'],
|
|
entry['id'],
|
|
))
|
|
updated += 1
|
|
conn.commit()
|
|
results['applied'] = updated
|
|
|
|
conn.close()
|
|
return results
|
|
|
|
|
|
def get_stats(db_path: str) -> dict:
|
|
"""Get current classification statistics."""
|
|
conn = sqlite3.connect(db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
|
|
total = conn.execute("SELECT COUNT(*) as c FROM assets").fetchone()['c']
|
|
unknown = conn.execute(
|
|
"SELECT COUNT(*) as c FROM assets WHERE make = 'Unknown' OR make IS NULL OR make = ''"
|
|
).fetchone()['c']
|
|
|
|
by_make = {}
|
|
for r in conn.execute(
|
|
"SELECT make, COUNT(*) as cnt FROM assets GROUP BY make ORDER BY cnt DESC"
|
|
):
|
|
by_make[r['make']] = r['cnt']
|
|
|
|
by_category = {}
|
|
for r in conn.execute(
|
|
"SELECT category, COUNT(*) as cnt FROM assets GROUP BY category ORDER BY cnt DESC"
|
|
):
|
|
by_category[r['category']] = r['cnt']
|
|
|
|
conn.close()
|
|
return {
|
|
'total': total,
|
|
'unknown_make': unknown,
|
|
'by_make': by_make,
|
|
'by_category': by_category,
|
|
}
|
|
|
|
|
|
# ─── CLI ────────────────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
import argparse
|
|
parser = argparse.ArgumentParser(
|
|
description='Classify Unknown machines by serial number pattern'
|
|
)
|
|
parser.add_argument('--apply', action='store_true',
|
|
help='Actually update the database (default: dry-run)')
|
|
parser.add_argument('--stats', action='store_true',
|
|
help='Show classification statistics and exit')
|
|
parser.add_argument('--db', default=DB_PATH,
|
|
help=f'Database path (default: {DB_PATH})')
|
|
args = parser.parse_args()
|
|
|
|
if args.stats:
|
|
stats = get_stats(args.db)
|
|
print("=== Classification Statistics ===")
|
|
print(f"Total assets: {stats['total']}")
|
|
print(f"Unknown make: {stats['unknown_make']} "
|
|
f"({stats['unknown_make']/stats['total']*100:.1f}%)")
|
|
print()
|
|
print("By Make:")
|
|
for make, cnt in sorted(stats['by_make'].items(),
|
|
key=lambda x: x[1], reverse=True):
|
|
bar = '█' * (cnt // 20)
|
|
print(f" {make:<15} {cnt:>5} {bar}")
|
|
print()
|
|
print("By Category:")
|
|
for cat, cnt in sorted(stats['by_category'].items(),
|
|
key=lambda x: x[1], reverse=True):
|
|
print(f" {cat:<15} {cnt:>5}")
|
|
return
|
|
|
|
mode = "DRY-RUN" if not args.apply else "APPLY"
|
|
print(f"=== Serial Number Classification ({mode}) ===\n")
|
|
|
|
result = classify_unknown_assets(args.db, apply=args.apply)
|
|
|
|
print(f"Unknown-make assets checked: {result['total_unknown']}")
|
|
print(f"Classified: {len(result['classified'])}")
|
|
print(f"Unmatched: {len(result['unmatched'])}")
|
|
print()
|
|
|
|
if result['classified']:
|
|
print("=== By Make ===")
|
|
for make, cnt in sorted(result['by_make'].items(),
|
|
key=lambda x: x[1], reverse=True):
|
|
print(f" → {make}: {cnt}")
|
|
|
|
print()
|
|
print("=== By Rule ===")
|
|
for rule, cnt in sorted(result['by_rule'].items(),
|
|
key=lambda x: x[1], reverse=True):
|
|
print(f" {rule}: {cnt}")
|
|
|
|
print()
|
|
print("=== By Confidence ===")
|
|
for level in ['high', 'medium', 'low']:
|
|
cnt = result['by_confidence'].get(level, 0)
|
|
if cnt:
|
|
print(f" {level}: {cnt}")
|
|
|
|
print()
|
|
print("=== Classified Assets ===")
|
|
for e in result['classified']:
|
|
flag = ''
|
|
if e['confidence'] == 'medium':
|
|
flag = ' ⚠️'
|
|
print(f" MID={e['machine_id']:>8} SN={e['serial']:>16} "
|
|
f"→ {e['make']:<8} {e['model']:<25} "
|
|
f"({e['rule']}) [{e['confidence']}]{flag}")
|
|
|
|
if result['unmatched']:
|
|
print()
|
|
print("=== Unmatched (needs manual review) ===")
|
|
for e in result['unmatched']:
|
|
print(f" MID={e['machine_id']:>8} SN={e['serial']:>16} "
|
|
f"Name={e['name'][:50]}")
|
|
|
|
if args.apply:
|
|
print()
|
|
print(f"✅ Applied {result.get('applied', 0)} updates to database.")
|
|
|
|
print()
|
|
|
|
# Show post-classification stats
|
|
stats = get_stats(args.db)
|
|
print(f"After classification: {stats['unknown_make']} Unknown make remaining "
|
|
f"(of {stats['total']} total)")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|