import collections, csv, json, re, unicodedata, xml.etree.ElementTree as ET from decimal import Decimal from pathlib import Path ROOT = Path('scripts/stock-import-202609/material-repair') ROOT.mkdir(exist_ok=True) db = ET.parse(ROOT.parent / 'remote-duplicate-references-snapshot.xml').getroot() def rows(tag): return [{x.tag: x.text for x in row} for row in db.find(tag)] materials = rows('materials') source = json.loads((ROOT.parent / 'source.json').read_text(encoding='utf-8')) raw = {str(r[0]).strip(): r for r in source[0]['rows'][1:] if r[0]} units = {u['id']: u for u in rows('units')} warehouses = {w['id']: w['code'] for w in rows('warehouses')} stock_warehouses = collections.defaultdict(set) for s in rows('stock'): stock_warehouses[s['item_id']].add(warehouses[s['warehouse_id']]) def norm(value): return re.sub(r'\s+', '', unicodedata.normalize('NFKC', value or '')).lower().replace('克', 'g').replace('×', '*') def q(value): if value is None: return 'NULL' return "'" + str(value).replace('\\', '\\\\').replace("'", "''") + "'" fixes = [] for m in materials: if m['creator'] != 'stock-import-202609': continue r = raw[m['code']] name, spec = str(r[18] or '').strip(), str(r[19] or '').strip() or None # The source's brand/name column contains inconsistent packaging labels in seven rows. # Preserve the ledger description for these instead of changing a box/film into a product. if not norm(str(r[1])).startswith(norm(name)): name = m['name'] wh = stock_warehouses[m['id']] if m['code'].startswith('74') or 'BCPK' in wh: type_code = 'MAT06' elif m['code'].startswith('81'): type_code = 'MAT05' if any(t in name for t in ['标签', '底贴', '防伪', '二维码']) else ('MAT03' if 'NBCK' in wh else 'MAT04') elif m['code'].startswith(('61', '62', '71')) or wh == {'YLK'}: type_code = 'MAT01' else: type_code = 'FG' fixes.append(dict(id=int(m['id']), code=m['code'], old_name=m['name'], old_spec=m['specification'], name=name, specification=spec, type_code=type_code)) m['name'], m['specification'] = name, spec assert len(fixes) == 582 groups = collections.defaultdict(list) for m in materials: groups[norm(m['name']), norm(m['specification'])].append(m) merges = [] for group in groups.values(): if len(group) < 2 or not any(m['creator'] == 'stock-import-202609' for m in group): continue group.sort(key=lambda m: (m['create_time'], int(m['id']))) original = group[0] for duplicate in group[1:]: assert duplicate['creator'] == 'stock-import-202609', 'Do not merge pre-existing independent records' from_unit, to_unit = units[duplicate['unit_measure_id']], units[original['unit_measure_id']] mass = {'GM': Decimal('1'), 'KG': Decimal('1000')} if from_unit['code'] in mass and to_unit['code'] in mass: factor = mass[from_unit['code']] / mass[to_unit['code']] else: assert from_unit['code'] == to_unit['code'] or {from_unit['code'], to_unit['code']} <= {'BT', 'BX', 'EA'}, 'Unknown unit conversion' factor = Decimal('1') # User instructed ignoring packaging-unit differences; identical name/spec. r = raw[duplicate['code']] provenance = f"归并前物料编码:{duplicate['code']};名称:{duplicate['name']};规格:{duplicate['specification'] or '原表未填写'};单位:{from_unit['name'].strip()};产地:{str(r[36] or '').strip()};原描述:{str(r[1] or '').strip()};归并至:{original['code']};数量换算系数:{factor}" merges.append(dict(old_id=int(duplicate['id']), old_code=duplicate['code'], new_id=int(original['id']), new_code=original['code'], name=original['name'], specification=original['specification'], old_unit=from_unit['name'].strip(), new_unit=to_unit['name'].strip(), factor=str(factor), provenance=provenance)) assert len(merges) == 10 assert {m['old_id'] for m in merges} == {969,970,971,972,1003,1004,1006,1009,1043,1528} (ROOT / 'plan.json').write_text(json.dumps(dict(fixes=fixes, merges=merges), ensure_ascii=False, indent=2), encoding='utf-8') for name, data in [('名称规格分类修正清单.csv', fixes), ('重复物料归并清单.csv', merges)]: with (ROOT / name).open('w', encoding='utf-8-sig', newline='') as f: writer = csv.DictWriter(f, fieldnames=list(data[0])); writer.writeheader(); writer.writerows(data) sql = ["CREATE TEMPORARY TABLE stage_material_fix(id BIGINT PRIMARY KEY,code VARCHAR(100),old_name VARCHAR(200),old_spec VARCHAR(255),name VARCHAR(200),specification VARCHAR(255),type_code VARCHAR(64)) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"] for m in fixes: sql.append('INSERT INTO stage_material_fix VALUES(' + ','.join(q(m[k]) for k in ['id','code','old_name','old_spec','name','specification','type_code']) + ')') sql.append("CREATE TEMPORARY TABLE stage_material_merge(old_id BIGINT PRIMARY KEY,old_code VARCHAR(100),new_id BIGINT,new_code VARCHAR(100),factor DECIMAL(24,9),provenance VARCHAR(700)) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") for m in merges: sql.append('INSERT INTO stage_material_merge VALUES(' + ','.join(q(m[k]) for k in ['old_id','old_code','new_id','new_code','factor','provenance']) + ')') (ROOT / 'repair-stage.sql').write_text(';\n'.join(sql) + ';\n', encoding='utf-8') print(f'Prepared {len(fixes)} field/class repairs, {sum(bool(m["specification"]) for m in fixes)} source specifications, {len(merges)} duplicate merges; source batch provenance retained.')