import json,csv,collections,xml.etree.ElementTree as ET
|
from pathlib import Path
|
from decimal import Decimal,ROUND_HALF_UP
|
root=Path('scripts/stock-import-202609')
|
data=json.loads((root/'source.json').read_text(encoding='utf-8'),parse_float=Decimal)
|
plan=json.loads((root/'remote-plan.json').read_text(encoding='utf-8'))
|
db=ET.parse(root/'remote-snapshot.xml').getroot()
|
materials={m.findtext('code'):{c.tag:c.text for c in m} for m in db.find('materials')}
|
units={u.findtext('name').strip():int(u.findtext('id')) for u in db.find('units')}
|
unitcodes={u.findtext('code'):u.findtext('name').strip() for u in db.find('units')}
|
ledger=next(s for s in data if s['sheet']=='Sheet1')['rows']
|
detail=collections.defaultdict(list)
|
for r in next(s for s in data if s['sheet']=='整理表')['rows'][1:]:
|
if len(r)>5 and isinstance(r[5],(int,Decimal)):detail[str(r[0]).strip()].append(r)
|
warehouses=[
|
('CPK01','成品仓','销售成品及完成包装的产品,按批次和效期管理;对应原表CPK01'),
|
('YLK','原料仓','生产原辅料、食品添加剂及散装原料;对应原表YLK'),
|
('NBCK','内包材仓','直接接触产品的内袋、瓶、盖、膜等包材;对应原表NBCK'),
|
('WBCK','外包材仓','外盒、外箱、标签、手提袋等外包装;对应原表WBCK'),
|
('CCCP','虫草冷链仓','鲜草、冻干草及虫草散料专用;对应原表CCCP;实际温湿度条件按品种储存标准执行'),
|
('BCPK','半成品仓','待继续加工或包装的中间产品;对应原表BCPK'),
|
('QC_PENDING','待检仓','到货待检、复检及质量待确认物料;合格后办理移库'),
|
('THK','退货及不合格品仓','退货、拒收及不合格品隔离存放;对应原表THK,检验处置后再移库'),
|
('WIP_VIRTUAL_WAREHOUSE','虚拟线边仓','已发至车间但尚未消耗的在制库存;对应原表CCCJ、YFK,生产领料与实际消耗分开记账')]
|
mapping={k:k for k,_,_ in warehouses};mapping.update(CCCJ='WIP_VIRTUAL_WAREHOUSE',YFK='WIP_VIRTUAL_WAREHOUSE')
|
def fallback(r):
|
major=str(ledger[r['row']-1][2])
|
if major=='8':
|
name=r['name']
|
return 'NBCK' if any(k in name for k in ('复合膜','卷膜','玻璃瓶','瓶盖','内袋','铝箔袋','封口膜','垫片','光片')) else 'WBCK'
|
if major in ('6','7'):return 'CCCP' if any(k in r['name'] for k in ('虫草','鲜草','断草','肉节')) else 'YLK'
|
if major in ('1','2','Q','U','T'):return 'CPK01'
|
raise ValueError(('Unclassified',r))
|
def fmt(v):return format(Decimal(v).quantize(Decimal('.000001'),rounding=ROUND_HALF_UP),'f')
|
out=[];allocation=[]
|
for r in plan:
|
m=materials.get(r['code']);quantity=Decimal(r['quantity']);srcunit=r['unit'];note=''
|
if r['code']=='81013938':srcunit='克';note='台账单位个与整理表、包材盘点表GM冲突,按原表GM及系统克计量'
|
targetunit=unitcodes.get(next((u.findtext('code') for u in db.find('units') if m and u.findtext('id')==m['unit_measure_id']),''),srcunit) if m else srcunit
|
factor=Decimal(1)
|
if srcunit!=targetunit:
|
if srcunit=='公斤' and targetunit=='克':factor=Decimal(1000)
|
elif srcunit=='克' and targetunit=='公斤':factor=Decimal('.001')
|
else:
|
assert srcunit in ('盒','套','罐','袋','个') and targetunit in ('个','张'),(r,targetunit)
|
note+=';按同编码包装成品/包材的计数单位1:1录入'
|
if r['code'] in ('Q2100196','Q2100197'):note+=';用户确认按原编码2992个,原表900ml散货袋装,与系统规格不一致'
|
final=Decimal(fmt(quantity*factor));assert final>=0
|
groups=collections.defaultdict(Decimal)
|
sourceqty=Decimal(0)
|
for dr in detail[r['code']]:
|
loc=str(dr[4]).strip();qty=Decimal(dr[5])
|
# Allocation uses source quantities as proportions, preserving ledger target quantity.
|
sourceqty+=qty
|
if not loc:loc=fallback(r)
|
assert loc in mapping,(r['code'],loc)
|
groups[mapping[loc]]+=qty
|
if not groups:groups[fallback(r)]=Decimal(0)
|
if any(v<0 for v in groups.values()):
|
assert final==0,(r,groups)
|
groups={max(groups,key=groups.get):Decimal(0)}
|
positive={k:v for k,v in groups.items() if v>0}
|
if final==0:
|
groups={max(groups,key=groups.get):Decimal(0)}
|
else:
|
assert positive,(r,groups)
|
groups=positive
|
allocations={}
|
if len(groups)==1:allocations[next(iter(groups))]=final
|
else:
|
weight=sum(groups.values());assert weight>0
|
allocations={k:Decimal(fmt(final*v/weight)) for k,v in groups.items()}
|
allocations[max(groups,key=groups.get)]+=final-sum(allocations.values())
|
assert sum(allocations.values())==final
|
row=dict(r,target_unit=targetunit,unit_id=int(m['unit_measure_id']) if m else units[srcunit],target_quantity=fmt(final),expected_id=int(m['id']) if m else 0,expected_name=m['name'] if m else '',expected_batch=int(m['is_batch_managed']) if m else 1,note=note,source_locations=','.join(sorted({str(dr[4]).strip() or '空' for dr in detail[r['code']]})))
|
row['batch_code']='INIT202609-'+r['code']
|
out.append(row)
|
for wh,qty in allocations.items():allocation.append(dict(code=r['code'],name=r['name'],warehouse=wh,quantity=fmt(qty),unit=targetunit,batch_code=row['batch_code'],row=r['row'],note=note))
|
(root/'import-plan.json').write_text(json.dumps(dict(materials=out,stock=allocation,warehouses=warehouses),ensure_ascii=False,indent=2),encoding='utf-8')
|
with (root/'仓库分配清单.csv').open('w',encoding='utf-8-sig',newline='') as f:
|
w=csv.DictWriter(f,fieldnames=list(allocation[0]));w.writeheader();w.writerows(allocation)
|
def q(v):
|
if v is None:return 'NULL'
|
if isinstance(v,(int,Decimal)):return str(v)
|
return "'"+str(v).replace('\\','\\\\').replace("'","''").replace('\n','\\n').replace('\r','\\r')+"'"
|
sql=[]
|
sql.append('CREATE TEMPORARY TABLE stage_inventory_material(code VARCHAR(50) PRIMARY KEY,name VARCHAR(200),unit_id BIGINT,quantity DECIMAL(24,6),expected_id BIGINT,expected_name VARCHAR(200),batch_managed INT,batch_code VARCHAR(64),remark VARCHAR(500)) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci')
|
for r in out:
|
remark=f"来源2026年9月份盘点表(虫草核对确认).xls Sheet1第{r['row']}行;结存{r['quantity']}{r['unit']};{r['note']}"
|
sql.append('INSERT INTO stage_inventory_material VALUES('+','.join(q(v) for v in (r['code'],r['name'],r['unit_id'],Decimal(r['target_quantity']),r['expected_id'],r['expected_name'],r['expected_batch'],r['batch_code'],remark)) +')')
|
sql.append('CREATE TEMPORARY TABLE stage_inventory_warehouse(code VARCHAR(100) PRIMARY KEY,name VARCHAR(100),remark VARCHAR(255)) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci')
|
for r in warehouses:sql.append('INSERT INTO stage_inventory_warehouse VALUES('+','.join(q(v) for v in r)+')')
|
sql.append('CREATE TEMPORARY TABLE stage_inventory_stock(code VARCHAR(50),warehouse_code VARCHAR(100),quantity DECIMAL(24,6),remark VARCHAR(255),PRIMARY KEY(code,warehouse_code)) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci')
|
for r in allocation:
|
remark=f"2026年9月台账Sheet1第{r['row']}行,按结存覆盖;按原表库位分仓;INIT202609为期初汇总批次,不代表真实生产批次;{r['note']}"
|
assert len(remark)<=255
|
sql.append('INSERT INTO stage_inventory_stock VALUES('+','.join(q(v) for v in (r['code'],r['warehouse'],Decimal(r['quantity']),remark))+')')
|
(root/'remote-import.sql').write_text(';\n'.join(sql)+';\n',encoding='utf-8')
|
print('materials',len(out),'new',sum(not r['expected_id'] for r in out),'stock',len(allocation),'positive',sum(Decimal(r['quantity'])>0 for r in allocation),'warehouse allocation',dict(collections.Counter(r['warehouse'] for r in allocation)))
|