Files
fdesk/scripts/stock_deal_report.py
T
mzl92 e096157063 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>
2026-08-14 09:49:25 +08:00

310 lines
17 KiB
Python
Raw 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
# -*- 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()