feat: 备货单成交统计报告生成脚本(固定工具)

scripts/stock_deal_report.py:从 Navicat 全库 dump 流式解析 stock_orders/items,
按单据日期统计成交(有备货单即成交),支持单月/区间:
- 剔除草稿/拒绝/作废;初终版(object_id 互指)同月并存只计终版
- 新车/二手车按明细 mileage 判定;国家取清关目的国
- 输出 PDF 报告(月度分布/车型/国家/业务员/详单)+ 详单 CSV
- 每个 dump 自动建解析缓存,重跑秒级

用法:python3 scripts/stock_deal_report.py --year 2026 --month 7
      python3 scripts/stock_deal_report.py --frm 2026-01 --to 2026-06

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
mzl
2026-08-14 09:49:25 +08:00
parent 06b0647fc4
commit e096157063
+309
View File
@@ -0,0 +1,309 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
备货单成交统计报告生成器(固定脚本)
口径:
成交 = 有备货单(stock_orders);时间按单据日期 biz_date;
剔除 草稿/未提交(approval_status=0)、已拒绝(3)、已作废(4);排除软删除;
初版与关联终版(object_id 互指)同月并存时只计终版,跨月各计各月;
车况:明细 mileage>0 为二手车、=0 为新车;国家取清关目的国(去英文括号后缀)。
用法:
python3 scripts/stock_deal_report.py --year 2026 --month 7 # 单月
python3 scripts/stock_deal_report.py --from 2026-01 --to 2026-06 # 区间
可选:--file runtime/fdesk-20260813.sql(默认)
输出:runtime/<期间>成交统计报告.pdf + runtime/<期间>成交详单.csv
依赖:python3 + weasyprint(首次对每个 dump 建解析缓存,之后秒级重跑)
"""
import argparse, re, csv, html, os, pickle
from collections import Counter, defaultdict
from datetime import datetime, timezone, timedelta
from weasyprint import HTML
TZ = timezone(timedelta(hours=8))
EXCLUDE = {0, 3, 4} # 草稿/拒绝/作废
DEFAULT_DUMP = 'runtime/fdesk-20260813.sql'
# ---------------- SQL dump 解析 ----------------
def pvl(s, i):
vals = []; cur = ''; instr = False; n = len(s)
while i < n:
c = s[i]
if instr:
if c == '\\' and i + 1 < n: cur += c + s[i + 1]; i += 2; continue
if c == "'":
if i + 1 < n and s[i + 1] == "'": cur += "''"; i += 2; continue
cur += "'"; i += 1; instr = False; continue
cur += c; i += 1; continue
else:
if c == "'": instr = True; cur += "'"; i += 1; continue
if c == ',': vals.append(cur.strip()); cur = ''; i += 1; continue
if c == ')': vals.append(cur.strip()); i += 1; break
cur += c; i += 1
return vals, i
def unesc(raw):
raw = raw.strip()
if raw.upper() == 'NULL': return None
if len(raw) >= 2 and raw[0] == "'" and raw[-1] == "'":
inner = raw[1:-1]; out = []; i = 0; L = len(inner)
mp = {'n': '\n', 'r': '\r', 't': '\t', '0': '\x00', '\\': '\\', "'": "'", '"': '"', 'b': '\b', 'Z': '\x1a'}
while i < L:
c = inner[i]
if c == '\\' and i + 1 < L: out.append(mp.get(inner[i + 1], inner[i + 1])); i += 2
elif c == "'" and i + 1 < L and inner[i + 1] == "'": out.append("'"); i += 2
else: out.append(c); i += 1
return ''.join(out)
try: return int(raw)
except:
try: return float(raw)
except: return raw
def load_dump(path):
"""返回 (orders: id->dict, items: [(order_id, model, qty, mileage)])"""
cache = path + '.cache.pkl'
if os.path.exists(cache):
with open(cache, 'rb') as f:
return pickle.load(f)
cols = {}; cur_create = None
orders = {}; items = []
with open(path, encoding='utf-8', errors='replace') as f:
for line in f:
if cur_create is not None:
m = re.match(r'\s*`(\w+)`\s', line)
if m: cols[cur_create].append(m.group(1))
if re.search(r'\)\s*ENGINE', line): cur_create = None
continue
if line.startswith('CREATE TABLE `'):
t = line[14:line.index('`', 14)]
if t in ('stock_orders', 'stock_order_items'):
cur_create = t; cols[t] = []
continue
if line.startswith('INSERT INTO `stock_orders` VALUES ('):
v = [unesc(x) for x in pvl(line, line.index('VALUES (') + 8)[0]]
ci = {c: i for i, c in enumerate(cols['stock_orders'])}
orders[v[ci['id']]] = dict(
order_no=v[ci['order_no']], biz=v[ci['biz_date']] or 0,
fin=v[ci['is_final']], oid=v[ci['object_id']] or 0,
ap=v[ci['approval_status']], deleted=v[ci['deleted_at']] is not None,
country=str(v[ci['clearance_country_snapshot']] or '').strip(),
sales=str(v[ci['salesperson_snapshot']] or '').strip(),
)
elif line.startswith('INSERT INTO `stock_order_items` VALUES ('):
v = [unesc(x) for x in pvl(line, line.index('VALUES (') + 8)[0]]
ci = {c: i for i, c in enumerate(cols['stock_order_items'])}
items.append((v[ci['order_id']], str(v[ci['model_snapshot']] or '').strip(),
int(v[ci['quantity']] or 0), int(v[ci['mileage']] or 0)))
with open(cache, 'wb') as f:
pickle.dump((orders, items), f)
return orders, items
# ---------------- 统计 ----------------
_PAREN = re.compile(r'\((?:[^()]|\([^()]*\))*\)\s*$')
def clean_country(c):
while True:
c2 = _PAREN.sub('', c).strip()
if c2 == c: return c
c = c2
def month_key(ts):
return datetime.fromtimestamp(ts, TZ).strftime('%Y-%m')
def build_report(orders, items, months):
"""months: [(y, m), ...] 连续月份列表"""
for o in orders.values():
o['country'] = clean_country(o['country'])
t0 = int(datetime(months[0][0], months[0][1], 1, tzinfo=TZ).timestamp())
ly, lm = months[-1]
t1 = int((datetime(ly, lm, 1, tzinfo=TZ) + timedelta(days=32)).replace(day=1).timestamp())
def in_range(o):
return (not o['deleted']) and t0 <= o['biz'] < t1
cand = {i: o for i, o in orders.items() if in_range(o) and o['ap'] not in EXCLUDE}
excluded = {a: sum(1 for o in orders.values() if in_range(o) and o['ap'] == a) for a in sorted(EXCLUDE)}
# 初终版去重(按月):初版的关联终版与初版同月 -> 跳过初版
sel = {}; skipped = []; cross_month_fin = 0
for i, o in cand.items():
if o['fin'] == 0 and o['oid'] > 0:
fin = orders.get(o['oid'])
if fin and fin['fin'] == 1 and not fin['deleted']:
if month_key(fin['biz']) == month_key(o['biz']):
skipped.append(i); continue
cross_month_fin += 1
sel[i] = o
order_ids = set(sel)
items_by_order = defaultdict(list)
for oid, model, qty, mile in items:
if oid in order_ids:
items_by_order[oid].append((model, qty, mile))
by_model = Counter(); by_mc = defaultdict(Counter); by_country = Counter()
by_sales = Counter(); by_sales_orders = Counter(); by_cond = Counter()
by_model_cond = defaultdict(Counter); by_country_cond = defaultdict(Counter)
by_sales_cond = defaultdict(Counter); by_month = defaultdict(lambda: Counter())
detail = []
for oid, o in sel.items():
sp = o['sales'] or '(未填)'
c = o['country'] or '(未填)'
mk = month_key(o['biz'])
by_sales_orders[sp] += 1
by_month[mk]['orders'] += 1
for model, qty, mile in items_by_order.get(oid, []):
m = model or '(未填车型)'
cd = '二手车' if (mile or 0) > 0 else '新车'
by_model[m] += qty; by_mc[m][c] += qty; by_country[c] += qty; by_sales[sp] += qty
by_cond[cd] += qty
by_model_cond[m][cd] += qty; by_country_cond[c][cd] += qty; by_sales_cond[sp][cd] += qty
by_month[mk][cd] += qty; by_month[mk]['veh'] += qty
detail.append(dict(model=m, country=c, qty=qty, sales=sp, month=mk,
cond=cd if cd == '新车' else f'二手车({mile:,}km)',
order_no=o['order_no'], date=datetime.fromtimestamp(o['biz'], TZ).strftime('%m-%d')))
detail.sort(key=lambda r: (r['month'], r['sales'], r['order_no']))
return dict(sel=sel, excluded=excluded, skipped=skipped, cross=cross_month_fin,
by_model=by_model, by_mc=by_mc, by_country=by_country, by_sales=by_sales,
by_sales_orders=by_sales_orders, by_cond=by_cond,
by_model_cond=by_model_cond, by_country_cond=by_country_cond,
by_sales_cond=by_sales_cond, by_month=by_month, detail=detail)
# ---------------- 渲染 ----------------
def esc(x): return html.escape(str(x))
def tr(rows):
return ''.join('<tr><td>' + '</td><td>'.join(esc(c) for c in r) + '</td></tr>' for r in rows)
def render_pdf(R, months, label, out_pdf):
total = sum(R['by_model'].values())
new_n = R['by_cond'].get('新车', 0); used_n = R['by_cond'].get('二手车', 0)
multi = len(months) > 1
model_rows = [(m, R['by_model_cond'][m].get('新车', 0), R['by_model_cond'][m].get('二手车', 0), n,
'、'.join(f'{c}×{k}' for c, k in R['by_mc'][m].most_common()))
for m, n in sorted(R['by_model'].items(), key=lambda x: -x[1])]
country_rows = [(c, R['by_country_cond'][c].get('新车', 0), R['by_country_cond'][c].get('二手车', 0), n, f'{n / total * 100:.1f}%')
for c, n in R['by_country'].most_common()]
sales_rows = [(s, R['by_sales_orders'][s], R['by_sales_cond'][s].get('新车', 0), R['by_sales_cond'][s].get('二手车', 0),
R['by_sales'][s], f'{R["by_sales"][s] / total * 100:.1f}%')
for s, _ in sorted(R['by_sales'].items(), key=lambda x: -R['by_sales'][x[0]])]
month_rows = [(mk, c['orders'], c['veh'], c.get('新车', 0), c.get('二手车', 0))
for mk, c in sorted(R['by_month'].items())]
dcol = [(r['sales'], r['month'], r['model'], r['country'], r['cond'], r['qty'], r['order_no'], r['date']) for r in R['detail']] \
if multi else \
[(r['sales'], r['model'], r['country'], r['cond'], r['qty'], r['order_no'], r['date']) for r in R['detail']]
sec = []
if multi:
sec.append(('月度分布', '<table><thead><tr><th style="width:14%">月份</th><th style="width:14%">单数</th>'
'<th style="width:16%">车辆数</th><th style="width:14%">新车</th><th>二手车</th></tr></thead>'
f'<tbody>{tr(month_rows)}</tbody></table>'))
sec.append(('车型成交排行', '<table><thead><tr><th style="width:36%">车型</th><th style="width:6%">新车</th>'
'<th style="width:6%">二手</th><th style="width:6%">合计</th><th>成交国家(国家×辆数)</th></tr></thead>'
f'<tbody>{tr(model_rows)}</tbody></table>'))
sec.append(('国家成交排行', '<table><thead><tr><th style="width:22%">国家(清关目的国)</th><th style="width:8%">新车</th>'
'<th style="width:8%">二手</th><th style="width:8%">合计</th><th>占比</th></tr></thead>'
f'<tbody>{tr(country_rows)}</tbody></table>'))
sec.append(('业务员成交排行', '<table><thead><tr><th style="width:14%">业务员</th><th style="width:8%">单数</th>'
'<th style="width:8%">新车</th><th style="width:8%">二手</th><th style="width:10%">辆数合计</th>'
'<th>占车辆数比</th></tr></thead>'
f'<tbody>{tr(sales_rows)}</tbody></table>'))
dhead = ('<th style="width:8%">业务员</th><th style="width:7%">月份</th><th>车型</th><th style="width:10%">国家</th>'
'<th style="width:11%">车况</th><th style="width:5%">数量</th><th style="width:12%">单号</th><th style="width:7%">单据日期</th>') if multi else \
('<th style="width:8%">业务员</th><th>车型</th><th style="width:10%">国家</th><th style="width:11%">车况</th>'
'<th style="width:5%">数量</th><th style="width:12%">单号</th><th style="width:7%">单据日期</th>')
sec.append(('成交详单(按月份、业务员)', f'<table><thead><tr>{dhead}</tr></thead><tbody>{tr(dcol)}</tbody></table>'))
sections = ''.join(f'<h2>{i}、{t}</h2>{b}' for i, (t, b) in enumerate(sec, 1))
ex = R['excluded']
doc = f"""<!doctype html><html><head><meta charset="utf-8"><style>
@page {{ size: A4 landscape; margin: 1.3cm; }}
body {{ font-family:"WenQuanYi Zen Hei","Noto Sans CJK SC",sans-serif; color:#222; font-size:10.5px; }}
h1 {{ font-size:19px; margin:0 0 2px; }}
.meta {{ color:#666; font-size:9px; margin-bottom:5px; }}
.summary {{ background:#f5f7fa; border:1px solid #e3e8ef; padding:7px 10px; border-radius:4px; margin:5px 0 10px; font-size:10.5px; line-height:1.7; }}
h2 {{ font-size:12.5px; margin:12px 0 5px; border-left:4px solid #BB0010; padding-left:7px; }}
table {{ border-collapse:collapse; width:100%; margin-bottom:4px; }}
th,td {{ border:1px solid #cfd6e0; padding:3px 5px; vertical-align:top; font-size:9px; }}
th {{ background:#eef1f6; text-align:left; }}
thead {{ display:table-header-group; }}
tr {{ page-break-inside:avoid; }}
.kpis {{ width:100%; margin:8px 0 10px; }}
.kpis td {{ border:none; text-align:center; padding:6px 2px; }}
.kpis .v {{ font-size:19px; font-weight:bold; color:#BB0010; }}
.kpis .l {{ font-size:9px; color:#666; }}
.foot {{ color:#888; font-size:8.5px; margin-top:8px; line-height:1.6; }}
</style></head><body>
<h1>备货单成交统计报告 · {label}</h1>
<div class="meta">数据来源:stock_orders / stock_order_items · 生成日期 {datetime.now(TZ).date().isoformat()} · 统计口径:有备货单即成交</div>
<div class="summary"><b>口径说明:</b>单据日期(biz_date)落在 {label};已剔除 草稿/未提交({ex.get(0, 0)} 单)、已拒绝({ex.get(3, 0)} 单)、已作废({ex.get(4, 0)} 单);
初版与关联终版(object_id 互指)同月并存时只计终版(本期跳过初版 {len(R['skipped'])} 单);跨月关联终版 {R['cross']} 单按所属月份计入。
<b>车况判定:</b>系统无显式新车/二手车字段,按明细 mileage 判定:里程&gt;0 为<b>二手车</b>、=0 为新车。国家按清关目的国。</div>
<table class="kpis"><tr>
<td><div class="v">{len(R['sel'])}</div><div class="l">成交单数</div></td>
<td><div class="v">{total}</div><div class="l">成交车辆数</div></td>
<td><div class="v">{new_n}</div><div class="l">新车(辆)</div></td>
<td><div class="v">{used_n}</div><div class="l">二手车(辆)</div></td>
<td><div class="v">{len(R['by_model'])}</div><div class="l">成交车型数</div></td>
<td><div class="v">{len(R['by_country'])}</div><div class="l">覆盖国家数</div></td>
</tr></table>
{sections}
<p class="foot">注:明细按 stock_order_items 逐行展开(数量取 quantity);车况括号内为二手车明细里程(km);业务员为建单时快照(salesperson_snapshot)。</p>
</body></html>"""
HTML(string=doc).write_pdf(out_pdf)
def write_csv(R, label, multi):
path = f'runtime/{label}成交详单.csv'
with open(path, 'w', newline='', encoding='utf-8-sig') as f:
w = csv.writer(f)
w.writerow(['业务员', '月份', '车型', '国家', '车况', '数量', '单号', '单据日期'] if multi
else ['业务员', '车型', '国家', '车况', '数量', '单号', '单据日期'])
for r in R['detail']:
row = [r['sales'], r['month'], r['model'], r['country'], r['cond'], r['qty'], r['order_no'], r['date']] if multi \
else [r['sales'], r['model'], r['country'], r['cond'], r['qty'], r['order_no'], r['date']]
w.writerow(row)
return path
def main():
ap = argparse.ArgumentParser(description='备货单成交统计报告')
ap.add_argument('--file', default=DEFAULT_DUMP, help='Navicat 全库 dump 路径')
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument('--year', type=int); ap.add_argument('--month', type=int, default=None)
g.add_argument('--frm', '--from', dest='frm', default=None, help='起始月 2026-01')
ap.add_argument('--to', default=None, help='结束月 2026-06')
args = ap.parse_args()
if args.frm:
y1, m1 = map(int, args.frm.split('-'))
y2, m2 = map(int, (args.to or args.frm).split('-'))
months = []
y, m = y1, m1
while (y, m) <= (y2, m2):
months.append((y, m)); m += 1
if m == 13: y, m = y + 1, 1
else:
months = [(args.year, args.month or datetime.now(TZ).month)]
if len(months) == 1:
y, m = months[0]; label = f'{y}年{m}月'
else:
(y1, m1), (y2, m2) = months[0], months[-1]
label = f'{y1}年{m1}-{m2}月' if y1 == y2 else f'{y1}年{m1}月-{y2}年{m2}月'
orders, items = load_dump(args.file)
print(f'dump: {args.file}(orders {len(orders)} / items {len(items)}),期间: {label}')
R = build_report(orders, items, months)
total = sum(R['by_model'].values())
print(f"入选 {len(R['sel'])} 单 / {total} 辆(新车 {R['by_cond'].get('新车', 0)} / 二手 {R['by_cond'].get('二手车', 0)}),"
f"车型 {len(R['by_model'])},国家 {len(R['by_country'])},剔除 {R['excluded']},同月终版跳过初版 {len(R['skipped'])} 单")
pdf = f'runtime/{label}成交统计报告.pdf'
render_pdf(R, months, label, pdf)
csvp = write_csv(R, label, len(months) > 1)
print('已生成:', pdf, '|', csvp)
if __name__ == '__main__':
main()