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:
@@ -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 判定:里程>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()
|
||||||
Reference in New Issue
Block a user