dy-dashboard/import_dingtalk_latest.py
2026-10-28 11:14:17 +08:00

204 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""钉盘增量导入(通用 diff 版):按钉盘 vs DB 差异补录出库与库存。"""
import csv, os, json, subprocess, pymysql, importlib.util, sys
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
s = importlib.util.spec_from_file_location('config', 'config.py')
m = importlib.util.module_from_spec(s); s.loader.exec_module(m)
DB = m.DB_CONFIG
SPACE_ID = '28814565790'
OB_FOLDER = '3NwLYZXWynyBjRB4tZjwNxDvVkyEqBQm'
IV_FOLDER = 'NkDwLng8ZLGrXmrnFxYq3vRqVKMEvZBY'
TMP = 'tmp_csv/update_latest'
os.makedirs(TMP, exist_ok=True)
MIN_DATE = '2026-09-05'
def si(v):
try: return int(float(str(v or 0).replace('"','').replace('=','').strip()))
except: return 0
def sf(v):
try: return float(str(v or 0).replace('"','').replace('=','').strip())
except: return 0.0
def clean(v): return str(v or '').replace('"','').replace('=','').strip()
def bad(c):
n = clean(c).upper().rstrip(':')
return not n or n in ('NA','N/A','合计')
SPECIALS = {'POZ-FM-0184-TM-SM':.055,'POZ-XFS-9014-TM-MD':.062,'POZ-FM-9742-TM-MD':.181,
'POZ-MM-0580-TM-MD':.22,'POZ-SKS-3374-LD':.1,'POZ-SKS-3367-LD':.1,
'POZ-SKS-3350-LD':.1,'POZ-SKS-3343-LD':.1}
def dws_list(folder):
out=[]; cursor=None
while True:
args=['dws','drive','list','--space-id',SPACE_ID,'--folder',folder,'--limit','50','--format','json']
if cursor: args+=['--cursor',cursor]
r=subprocess.run(args,capture_output=True,text=True)
if r.returncode!=0: print(' [list err]',r.stderr[:150]); break
d=json.loads(r.stdout).get('result',{})
out+=d.get('items',[])
cursor=d.get('nextToken')
if not cursor: break
return out
def dl(name, fileId):
dest=os.path.join(TMP,name)
if os.path.exists(dest) and os.path.getsize(dest)>10: return dest
r=subprocess.run(['dws','drive','download','--node',fileId,'--space-id',SPACE_ID,'--output',dest,'--format','json'],capture_output=True,text=True)
if r.returncode!=0 or not os.path.exists(dest) or os.path.getsize(dest)<10:
print(' [dl err]',name,r.stderr[:120]); return None
return dest
def import_outbound(conn, date, src, path):
with conn.cursor() as x:
x.execute('DELETE FROM daily_outbound WHERE 日期=%s AND 数据来源=%s',(date,src))
ins=0
with conn.cursor() as x:
if src=='wdt':
# wdt 文件编码可能为 UTF-8(BOM) 或 GBK,自动检测
raw = open(path, 'rb').read()
text = None
for enc in ('utf-8-sig', 'gb18030', 'gbk'):
try:
text = raw.decode(enc); break
except UnicodeDecodeError:
continue
if text is None:
print(' [编码识别失败]', path); return 0
import io as _io
for row in csv.DictReader(_io.StringIO(text)):
code=clean(row.get('货品编号'))
if bad(code): continue
x.execute('INSERT INTO daily_outbound (货品编号,货品名称,品牌,仓库,店铺,一级类目,二级类目,三级类目,货品数量,货品成本,日期,数据来源,数据类型) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)',
(code,clean(row.get('货品名称')),clean(row.get('品牌')),clean(row.get('仓库')),clean(row.get('店铺')),
clean(row.get('一级类目')),clean(row.get('二级类目')),clean(row.get('三级类目')),
si(row.get('货品数量')),sf(row.get('货品成本')),date,src,'出库数据')); ins+=1
else:
with open(path, encoding='utf-8-sig') as f:
rr=list(csv.reader(f))
for raw in rr[1:]:
if not raw or len(raw)<2: continue
code=clean(raw[0]); qty=si(raw[1])
if bad(code): continue
x.execute('SELECT product_name, brand, category1, category2, category3, cost_price FROM products WHERE product_code=%s LIMIT 1',(code,))
p=x.fetchone()
if p:
pname=p['product_name'] or ''
if clean(pname) in ('-','--'): continue
brand=p['brand'] or ''; c1=p['category1'] or ''; c2=p['category2'] or ''; c3=p['category3'] or ''
cost=float(p['cost_price']) if p['cost_price'] else 0
else:
pname=brand=c1=c2=c3=''; cost=0
x.execute('INSERT INTO daily_outbound (货品编号,货品名称,品牌,仓库,店铺,一级类目,二级类目,三级类目,货品数量,货品成本,日期,数据来源,数据类型) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)',
(code,pname,brand,'','',c1,c2,c3,qty,cost,date,src,'出库数据')); ins+=1
return ins
def import_inventory(conn, date, path, wh):
# 必须在删除旧快照前验证列完整性。缺失采购在途≠采购在途为0;
# 这种精简导出会把在途成本和依赖它的总成本错误地归零。
import io as _io
raw = open(path, 'rb').read()
text = None
for enc in ('utf-8-sig', 'gb18030', 'gbk'):
try:
text = raw.decode(enc)
break
except UnicodeDecodeError:
continue
if text is None:
raise ValueError(f'{path}: 无法识别文件编码')
reader = csv.DictReader(_io.StringIO(text))
required = {'货品编号', '货品名称', '库存', '成本价', '采购在途', '生产在途量'}
missing = required.difference(reader.fieldnames or ())
if missing:
raise ValueError(f'{path}: 库存文件缺少必要列 {sorted(missing)};本次不导入,以免将缺失数据写成0')
with conn.cursor() as x:
x.execute('DELETE FROM inventory_detail WHERE data_date=%s AND warehouse_code=%s',(date,wh))
ins=0
with conn.cursor() as x:
for row in reader:
code=clean(row.get('货品编号'))
if bad(code) or clean(row.get('货品名称')) in ('-','--'): continue
cost=SPECIALS.get(code, sf(row.get('成本价')))
ct=clean(row.get('单品创建时间')); dt=None
for fmt in ('%Y-%m-%d %H:%M:%S','%Y-%m-%d'):
try: dt=datetime.strptime(ct,fmt); break
except: pass
x.execute('INSERT INTO inventory_detail (data_date,source,warehouse_code,goods_code,product_code,product_name,brand,cost_price,available_qty,production_in_transit,purchase_in_transit,category1,category2,category3,created_time) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)',
(date,'wdt',wh,clean(row.get('商家编码')),code,clean(row.get('货品名称')),clean(row.get('品牌')),cost,
si(row.get('库存')),si(row.get('生产在途量')),si(row.get('采购在途')),
clean(row.get('一级类目')),clean(row.get('二级类目')),clean(row.get('三级类目')),dt)); ins+=1
return ins
def main():
ob = dws_list(OB_FOLDER); iv = dws_list(IV_FOLDER)
ob_files={x['name']:x['fileId'] for x in ob if x['type']=='FILE' and x['name'].endswith('.csv')}
iv_files={x['name']:x['fileId'] for x in iv if x['type']=='FILE' and x['name'].endswith('.csv')}
conn = pymysql.connect(**DB, cursorclass=pymysql.cursors.DictCursor)
existing_ob=set()
with conn.cursor() as x:
x.execute("SELECT 日期,数据来源 FROM daily_outbound")
for r in x.fetchall():
d=r['日期'].strftime('%Y-%m-%d') if hasattr(r['日期'],'strftime') else str(r['日期'])[:10]
existing_ob.add((d,r['数据来源']))
x.execute("SELECT DISTINCT data_date FROM inventory_detail")
existing_iv=set(str(r['data_date'])[:10] for r in x.fetchall())
need_ob=[]; need_iv=[]
for name,fid in sorted(ob_files.items()):
p=name.split('-')
if len(p)<3 or not p[0].isdigit() or len(p[0])!=8: continue
date=f'{p[0][:4]}-{p[0][4:6]}-{p[0][6:8]}'; src=p[1]
if date < MIN_DATE: continue
if (date,src) in existing_ob: continue
need_ob.append((name,fid,date,src))
for name,fid in sorted(iv_files.items()):
p=name.split('-')
if not (len(p)>=3 and p[0].isdigit() and len(p[0])==8 and p[1]=='wdt' and p[2]=='kcmx'): continue
date=f'{p[0][:4]}-{p[0][4:6]}-{p[0][6:8]}'; wh=name.rsplit('-',1)[1][:-4]
if date < MIN_DATE: continue
if date in existing_iv: continue
need_iv.append((name,fid,date,wh))
print(f'需导入 出库 {len(need_ob)} 文件, 库存 {len(need_iv)} 文件')
# 先检查本批所有库存文件的列,再进行任何数据库写入;禁止部分成功后提交。
for name,fid,date,wh in need_iv:
p=dl(name,fid)
if not p:
raise RuntimeError(f'库存文件下载失败,取消本批导入: {name}')
with open(p, 'rb') as f:
raw=f.read()
text=None
for enc in ('utf-8-sig', 'gb18030', 'gbk'):
try:
text=raw.decode(enc)
break
except UnicodeDecodeError:
continue
if text is None:
raise ValueError(f'{name}: 无法识别文件编码')
columns=csv.DictReader(__import__('io').StringIO(text)).fieldnames or []
missing={'货品编号','货品名称','库存','成本价','采购在途','生产在途量'} - set(columns)
if missing:
raise ValueError(f'{name}: 缺少必要列 {sorted(missing)};已中止导入,不会将缺失列记为0')
ob_ins=0
for name,fid,date,src in need_ob:
p=dl(name,fid)
if not p: continue
n=import_outbound(conn,date,src,p); ob_ins+=n
print(f' 出库 {name} ({date} {src}) 导入 {n} 条')
iv_ins=0
for name,fid,date,wh in need_iv:
p=dl(name,fid)
if not p: continue
n=import_inventory(conn,date,p,wh); iv_ins+=n
print(f' 库存 {name} (仓库{wh}) 导入 {n} 条')
conn.commit(); conn.close()
print(f'完成: 出库新增 {ob_ins} 条, 库存新增 {iv_ins} 条')
if __name__=='__main__':
main()