build_glass_report.py 37 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232
  1. from __future__ import annotations
  2. import argparse
  3. import html
  4. import math
  5. import re
  6. from collections import Counter, defaultdict
  7. from datetime import datetime
  8. from pathlib import Path
  9. from typing import Dict, Iterable, List, Tuple
  10. from openpyxl import load_workbook
  11. TOTAL_WORD_RE = re.compile(r"(总计|合计|小计|汇总|平均)")
  12. LM_CODE_RE = re.compile(r"^(LM-[0-9A-Za-z]+(?:[+\-][0-9A-Za-z]+)*)")
  13. PAREN_RE = re.compile(r"[((][^))]*[))]")
  14. TRAILING_NOTE_RE = re.compile(r"[-–—](左开|右开|见附图|开孔见图).*$")
  15. TRAILING_THICKNESS_RE = re.compile(r"\d+(?:\+\d+|\-\d+)?(?:\s*[mM]{1,2})$")
  16. LEADING_THICKNESS_RE = re.compile(r"^\s*(?:\d+(?:\+\d+|\-\d+)+|\d+)(?:\s*[mM]{1,2})[-–—]?\s*")
  17. WHITESPACE_RE = re.compile(r"\s+")
  18. PALETTE = [
  19. "#2563eb",
  20. "#14b8a6",
  21. "#f59e0b",
  22. "#ef4444",
  23. "#8b5cf6",
  24. "#06b6d4",
  25. "#10b981",
  26. "#f97316",
  27. "#64748b",
  28. "#db2777",
  29. ]
  30. def to_float(value) -> float:
  31. if value in (None, ""):
  32. return 0.0
  33. if isinstance(value, (int, float)):
  34. return float(value)
  35. try:
  36. return float(str(value).replace(",", "").strip())
  37. except Exception:
  38. return 0.0
  39. def clean_text(value: str) -> str:
  40. text = str(value).strip()
  41. text = text.replace("\u3000", "")
  42. text = text.replace("双蓉", "双绒")
  43. text = WHITESPACE_RE.sub("", text)
  44. return text
  45. def extract_code(name: str) -> str | None:
  46. match = LM_CODE_RE.match(name)
  47. if not match:
  48. return None
  49. return match.group(1)
  50. def normalize_group_key(raw_name: str) -> str:
  51. name = clean_text(raw_name)
  52. code = extract_code(name)
  53. if code:
  54. return code
  55. name = PAREN_RE.sub("", name)
  56. name = LEADING_THICKNESS_RE.sub("", name)
  57. name = TRAILING_NOTE_RE.sub("", name)
  58. name = TRAILING_THICKNESS_RE.sub("", name)
  59. name = name.strip("-_,,;;")
  60. name = WHITESPACE_RE.sub("", name)
  61. return name or clean_text(raw_name)
  62. def format_qty(value: float) -> str:
  63. return f"{int(round(value)):,.0f}"
  64. def format_area(value: float) -> str:
  65. return f"{value:,.2f}"
  66. def format_money(value: float) -> str:
  67. return f"¥{value:,.2f}"
  68. def truncate_text(text: str, limit: int = 22) -> str:
  69. if len(text) <= limit:
  70. return text
  71. return text[: max(0, limit - 1)] + "…"
  72. def count_decimals(value: float) -> int:
  73. if abs(value) >= 1000:
  74. return 0
  75. if abs(value) >= 100:
  76. return 1
  77. return 2
  78. def percent(value: float, total: float) -> str:
  79. if total <= 0:
  80. return "0.0%"
  81. return f"{value / total * 100:.1f}%"
  82. def label_tags(raw_name: str, sheet_name: str) -> List[str]:
  83. name = clean_text(raw_name)
  84. tags: List[str] = []
  85. if "夹丝" in name or sheet_name == "夹丝玻璃":
  86. tags.append("夹丝")
  87. if "透明" in name or sheet_name == "透明玻璃":
  88. tags.append("透明")
  89. if "烤漆" in name or sheet_name == "烤漆玻璃":
  90. tags.append("烤漆")
  91. art_keywords = [
  92. "长虹",
  93. "油砂",
  94. "玉砂",
  95. "热弯",
  96. "平四边",
  97. "蜂窝",
  98. "斜格",
  99. "小灯芯",
  100. "竹子图",
  101. "打印",
  102. ]
  103. if sheet_name == "艺术玻璃" or any(k in name for k in art_keywords):
  104. tags.append("艺术玻璃")
  105. # Keep tag order stable and unique.
  106. seen = set()
  107. ordered: List[str] = []
  108. for tag in tags:
  109. if tag not in seen:
  110. seen.add(tag)
  111. ordered.append(tag)
  112. return ordered
  113. def load_records(xlsx_path: Path):
  114. wb = load_workbook(xlsx_path, read_only=True, data_only=True)
  115. records = []
  116. excluded = []
  117. for ws in wb.worksheets:
  118. for row_idx, row in enumerate(ws.iter_rows(values_only=True), start=1):
  119. if row_idx == 1:
  120. continue
  121. name = row[0] if len(row) > 0 else None
  122. qty = to_float(row[1] if len(row) > 1 else None)
  123. area = to_float(row[2] if len(row) > 2 else None)
  124. amount = to_float(row[3] if len(row) > 3 else None)
  125. if not name or not isinstance(name, str) or not name.strip():
  126. if any(v not in (None, "") for v in (row[1] if len(row) > 1 else None, row[2] if len(row) > 2 else None, row[3] if len(row) > 3 else None)):
  127. excluded.append(
  128. {
  129. "sheet": ws.title,
  130. "row": row_idx,
  131. "name": "",
  132. "reason": "blank_name",
  133. }
  134. )
  135. continue
  136. clean_name = clean_text(name)
  137. if TOTAL_WORD_RE.search(clean_name):
  138. excluded.append(
  139. {
  140. "sheet": ws.title,
  141. "row": row_idx,
  142. "name": clean_name,
  143. "reason": "summary_row",
  144. }
  145. )
  146. continue
  147. if qty == 0 and area == 0 and amount == 0:
  148. excluded.append(
  149. {
  150. "sheet": ws.title,
  151. "row": row_idx,
  152. "name": clean_name,
  153. "reason": "zero_row",
  154. }
  155. )
  156. continue
  157. records.append(
  158. {
  159. "sheet": ws.title,
  160. "row": row_idx,
  161. "raw_name": clean_name,
  162. "group_key": normalize_group_key(clean_name),
  163. "qty": qty,
  164. "area": area,
  165. "amount": amount,
  166. "tags": label_tags(clean_name, ws.title),
  167. }
  168. )
  169. return records, excluded
  170. def build_sheet_stats(records: List[Dict]):
  171. stats: Dict[str, Dict] = {}
  172. for rec in records:
  173. bucket = stats.setdefault(
  174. rec["sheet"],
  175. {
  176. "sheet": rec["sheet"],
  177. "rows": 0,
  178. "qty": 0.0,
  179. "area": 0.0,
  180. "amount": 0.0,
  181. },
  182. )
  183. bucket["rows"] += 1
  184. bucket["qty"] += rec["qty"]
  185. bucket["area"] += rec["area"]
  186. bucket["amount"] += rec["amount"]
  187. return stats
  188. def build_group_stats(records: List[Dict]):
  189. stats: Dict[str, Dict] = {}
  190. for rec in records:
  191. key = rec["group_key"]
  192. bucket = stats.setdefault(
  193. key,
  194. {
  195. "group_key": key,
  196. "qty": 0.0,
  197. "area": 0.0,
  198. "amount": 0.0,
  199. "rows": 0,
  200. "raw_names": Counter(),
  201. "sheets": Counter(),
  202. "tags": Counter(),
  203. },
  204. )
  205. bucket["qty"] += rec["qty"]
  206. bucket["area"] += rec["area"]
  207. bucket["amount"] += rec["amount"]
  208. bucket["rows"] += 1
  209. bucket["raw_names"][rec["raw_name"]] += 1
  210. bucket["sheets"][rec["sheet"]] += 1
  211. bucket["tags"].update(rec["tags"])
  212. for bucket in stats.values():
  213. raw_counts = bucket["raw_names"]
  214. bucket["variant_count"] = len(raw_counts)
  215. bucket["variants"] = raw_counts.most_common()
  216. bucket["primary_name"] = bucket["group_key"]
  217. bucket["dominant_sheet"] = bucket["sheets"].most_common(1)[0][0] if bucket["sheets"] else ""
  218. return stats
  219. def build_label_stats(records: List[Dict], group_stats: Dict[str, Dict]):
  220. labels = ["夹丝", "透明", "烤漆", "艺术玻璃"]
  221. out: Dict[str, Dict] = {}
  222. for label in labels:
  223. out[label] = {
  224. "label": label,
  225. "rows": 0,
  226. "qty": 0.0,
  227. "area": 0.0,
  228. "amount": 0.0,
  229. "groups": Counter(),
  230. "group_metrics": defaultdict(lambda: {"qty": 0.0, "area": 0.0, "amount": 0.0}),
  231. "raw_names": Counter(),
  232. }
  233. for rec in records:
  234. group_key = rec["group_key"]
  235. for label in rec["tags"]:
  236. bucket = out[label]
  237. bucket["rows"] += 1
  238. bucket["qty"] += rec["qty"]
  239. bucket["area"] += rec["area"]
  240. bucket["amount"] += rec["amount"]
  241. bucket["groups"][group_key] += rec["amount"]
  242. bucket["group_metrics"][group_key]["qty"] += rec["qty"]
  243. bucket["group_metrics"][group_key]["area"] += rec["area"]
  244. bucket["group_metrics"][group_key]["amount"] += rec["amount"]
  245. bucket["raw_names"][rec["raw_name"]] += 1
  246. for label, bucket in out.items():
  247. ranked = bucket["groups"].most_common()
  248. bucket["top_groups"] = [
  249. {
  250. "name": key,
  251. "amount": amt,
  252. "qty": bucket["group_metrics"][key]["qty"],
  253. "area": bucket["group_metrics"][key]["area"],
  254. "variants": group_stats[key]["variant_count"] if key in group_stats else 0,
  255. }
  256. for key, amt in ranked[:6]
  257. ]
  258. return out
  259. def find_focus_group(group_stats: Dict[str, Dict], target: str) -> Dict:
  260. return group_stats.get(target, {
  261. "group_key": target,
  262. "qty": 0.0,
  263. "area": 0.0,
  264. "amount": 0.0,
  265. "rows": 0,
  266. "variant_count": 0,
  267. "variants": [],
  268. "raw_names": Counter(),
  269. "sheets": Counter(),
  270. "dominant_sheet": "",
  271. })
  272. def escape(text) -> str:
  273. return html.escape(str(text), quote=True)
  274. def make_color(index: int) -> str:
  275. return PALETTE[index % len(PALETTE)]
  276. def make_horizontal_bar_svg(items: List[Dict], width: int = 1040, row_h: int = 34) -> str:
  277. if not items:
  278. return '<div class="empty">暂无可展示数据</div>'
  279. height = 36 + len(items) * row_h + 16
  280. left = 300
  281. right = 150
  282. top = 18
  283. chart_w = width - left - right
  284. max_value = max(item["value"] for item in items) or 1.0
  285. tick_values = [0, max_value * 0.25, max_value * 0.5, max_value * 0.75, max_value]
  286. def x_for(value: float) -> float:
  287. return left + chart_w * value / max_value
  288. grid_lines = []
  289. for tick in tick_values[1:]:
  290. x = x_for(tick)
  291. grid_lines.append(
  292. f'<line x1="{x:.1f}" y1="{top}" x2="{x:.1f}" y2="{height - 24}" stroke="#e5e7eb" stroke-dasharray="3 4" />'
  293. )
  294. grid_lines.append(
  295. f'<text x="{x:.1f}" y="{height - 6}" text-anchor="middle" font-size="11" fill="#64748b">{escape(format_money(tick)) if max_value > 1000 else escape(format_qty(tick))}</text>'
  296. )
  297. rows = []
  298. for idx, item in enumerate(items):
  299. y = top + idx * row_h
  300. value = item["value"]
  301. label = escape(truncate_text(item["label"], 28))
  302. bar_w = chart_w * value / max_value
  303. color = item.get("color") or make_color(idx)
  304. rows.append(
  305. f"""
  306. <g>
  307. <text x="12" y="{y + 20}" font-size="13" fill="#0f172a">{label}</text>
  308. <rect x="{left}" y="{y + 6}" width="{chart_w}" height="18" rx="9" fill="#eef2ff" />
  309. <rect x="{left}" y="{y + 6}" width="{max(3, bar_w):.1f}" height="18" rx="9" fill="{color}" />
  310. <text x="{width - 12}" y="{y + 20}" text-anchor="end" font-size="13" fill="#0f172a">{escape(item['value_text'])}</text>
  311. </g>
  312. """
  313. )
  314. return f"""
  315. <svg class="chart-svg" viewBox="0 0 {width} {height}" role="img" aria-label="bar chart">
  316. <rect x="0" y="0" width="{width}" height="{height}" rx="8" fill="white" />
  317. {' '.join(grid_lines)}
  318. {' '.join(rows)}
  319. </svg>
  320. """
  321. def render_chip(text: str, tone: str = "neutral") -> str:
  322. return f'<span class="chip {tone}">{escape(text)}</span>'
  323. def render_variant_list(variants: List[Tuple[str, int]], limit: int = 4) -> str:
  324. if not variants:
  325. return '<span class="muted">无</span>'
  326. parts = []
  327. for name, count in variants[:limit]:
  328. parts.append(render_chip(f"{name} ×{count}", "soft"))
  329. if len(variants) > limit:
  330. parts.append(render_chip(f"+{len(variants) - limit} 个", "neutral"))
  331. return "".join(parts)
  332. def render_group_card(group: Dict, title: str) -> str:
  333. variants = group.get("variants", [])
  334. return f"""
  335. <div class="focus-card">
  336. <div class="focus-head">
  337. <div>
  338. <div class="focus-title">{escape(title)}</div>
  339. <div class="focus-sub">{escape(group['group_key'])}</div>
  340. </div>
  341. <div class="focus-badge">{group.get('variant_count', 0)} 个 SKU</div>
  342. </div>
  343. <div class="focus-metrics">
  344. <div><span>数量</span><strong>{format_qty(group['qty'])}</strong></div>
  345. <div><span>面积</span><strong>{format_area(group['area'])} ㎡</strong></div>
  346. <div><span>金额</span><strong>{format_money(group['amount'])}</strong></div>
  347. <div><span>来源</span><strong>{escape(group.get('dominant_sheet', ''))}</strong></div>
  348. </div>
  349. <div class="focus-variants">{render_variant_list(variants)}</div>
  350. </div>
  351. """
  352. def render_table(rows: List[Dict], columns: List[Tuple[str, str]], table_class: str = "") -> str:
  353. head = "".join(f"<th>{escape(label)}</th>" for label, _ in columns)
  354. body_rows = []
  355. for row in rows:
  356. cells = []
  357. for _, key in columns:
  358. value = row.get(key, "")
  359. if isinstance(value, float):
  360. if key in {"qty"}:
  361. value = format_qty(value)
  362. elif key in {"area"}:
  363. value = f"{format_area(value)} ㎡"
  364. elif key in {"amount"}:
  365. value = format_money(value)
  366. cells.append(f"<td>{escape(value)}</td>")
  367. body_rows.append("<tr>" + "".join(cells) + "</tr>")
  368. return f"""
  369. <div class="table-wrap {table_class}">
  370. <table>
  371. <thead><tr>{head}</tr></thead>
  372. <tbody>{''.join(body_rows)}</tbody>
  373. </table>
  374. </div>
  375. """
  376. def render_group_table(group_stats: Dict[str, Dict], limit: int = 15) -> str:
  377. rows = sorted(group_stats.values(), key=lambda x: x["amount"], reverse=True)[:limit]
  378. table_rows = []
  379. for idx, g in enumerate(rows, start=1):
  380. variant_preview = ";".join(name for name, _ in g["variants"][:3])
  381. if len(g["variants"]) > 3:
  382. variant_preview += f" 等 {len(g['variants'])} 个SKU"
  383. table_rows.append(
  384. {
  385. "rank": idx,
  386. "group": g["group_key"],
  387. "variants": variant_preview,
  388. "variant_count": g["variant_count"],
  389. "rows": g["rows"],
  390. "qty": g["qty"],
  391. "area": g["area"],
  392. "amount": g["amount"],
  393. "source": g["dominant_sheet"],
  394. }
  395. )
  396. columns = [
  397. ("排名", "rank"),
  398. ("产品级名称", "group"),
  399. ("原始SKU", "variants"),
  400. ("SKU数", "variant_count"),
  401. ("明细行", "rows"),
  402. ("数量", "qty"),
  403. ("面积", "area"),
  404. ("金额", "amount"),
  405. ("主来源", "source"),
  406. ]
  407. return render_table(table_rows, columns)
  408. def render_sheet_table(sheet_stats: Dict[str, Dict]) -> str:
  409. rows = sorted(sheet_stats.values(), key=lambda x: x["amount"], reverse=True)
  410. table_rows = []
  411. for idx, s in enumerate(rows, start=1):
  412. table_rows.append(
  413. {
  414. "rank": idx,
  415. "sheet": s["sheet"],
  416. "rows": s["rows"],
  417. "qty": s["qty"],
  418. "area": s["area"],
  419. "amount": s["amount"],
  420. }
  421. )
  422. columns = [
  423. ("排名", "rank"),
  424. ("来源表", "sheet"),
  425. ("有效行", "rows"),
  426. ("数量", "qty"),
  427. ("面积", "area"),
  428. ("金额", "amount"),
  429. ]
  430. return render_table(table_rows, columns)
  431. def render_label_cards(label_stats: Dict[str, Dict], total_amount: float) -> str:
  432. cards = []
  433. for idx, label in enumerate(["夹丝", "透明", "烤漆", "艺术玻璃"]):
  434. bucket = label_stats[label]
  435. items = bucket["top_groups"]
  436. bars = []
  437. top_amt = items[0]["amount"] if items else 0
  438. for item_idx, item in enumerate(items[:5]):
  439. bars.append(
  440. f"""
  441. <div class="mini-row">
  442. <span class="mini-name">{escape(truncate_text(item['name'], 16))}</span>
  443. <span class="mini-bar"><i style="width:{max(6, item['amount'] / (top_amt or 1) * 100):.1f}%; background:{make_color(item_idx + idx)}"></i></span>
  444. <span class="mini-value">{format_money(item['amount'])}</span>
  445. </div>
  446. """
  447. )
  448. cards.append(
  449. f"""
  450. <div class="label-card">
  451. <div class="label-top">
  452. <div>
  453. <div class="label-name">{escape(label)}</div>
  454. <div class="label-sub">{bucket['rows']} 行 · {format_qty(bucket['qty'])} 件 · {format_money(bucket['amount'])}</div>
  455. </div>
  456. <div class="label-badge">{percent(bucket['amount'], total_amount)}</div>
  457. </div>
  458. <div class="label-bars">{''.join(bars) if bars else '<div class="empty">暂无命中</div>'}</div>
  459. </div>
  460. """
  461. )
  462. return "\n".join(cards)
  463. def make_summary_insight(records: List[Dict], group_stats: Dict[str, Dict]) -> str:
  464. top_groups = sorted(group_stats.values(), key=lambda x: x["amount"], reverse=True)[:3]
  465. pieces = []
  466. for g in top_groups:
  467. pieces.append(f"{g['group_key']}({format_money(g['amount'])})")
  468. return ";".join(pieces)
  469. def generate_report(
  470. xlsx_path: Path,
  471. output_path: Path,
  472. records: List[Dict],
  473. excluded: List[Dict],
  474. sheet_stats: Dict[str, Dict],
  475. group_stats: Dict[str, Dict],
  476. label_stats: Dict[str, Dict],
  477. ):
  478. total_qty = sum(r["qty"] for r in records)
  479. total_area = sum(r["area"] for r in records)
  480. total_amount = sum(r["amount"] for r in records)
  481. total_rows = len(records)
  482. total_groups = len(group_stats)
  483. total_sheets = len(sheet_stats)
  484. excluded_rows = len(excluded)
  485. summary_insight = make_summary_insight(records, group_stats)
  486. focus_lm012 = find_focus_group(group_stats, "LM-012")
  487. focus_super_white = find_focus_group(group_stats, "超白钢化")
  488. product_chart_items = [
  489. {
  490. "label": g["group_key"],
  491. "value": g["amount"],
  492. "value_text": format_money(g["amount"]),
  493. "color": make_color(i),
  494. }
  495. for i, g in enumerate(sorted(group_stats.values(), key=lambda x: x["amount"], reverse=True)[:10])
  496. ]
  497. sheet_chart_items = [
  498. {
  499. "label": s["sheet"],
  500. "value": s["amount"],
  501. "value_text": format_money(s["amount"]),
  502. "color": make_color(i),
  503. }
  504. for i, s in enumerate(sorted(sheet_stats.values(), key=lambda x: x["amount"], reverse=True))
  505. ]
  506. label_chart_items = [
  507. {
  508. "label": label,
  509. "value": label_stats[label]["amount"],
  510. "value_text": format_money(label_stats[label]["amount"]),
  511. "color": make_color(i),
  512. }
  513. for i, label in enumerate(["夹丝", "透明", "烤漆", "艺术玻璃"])
  514. ]
  515. excluded_examples = excluded[:8]
  516. excluded_html = "".join(
  517. f"<li><b>{escape(item['sheet'])}</b> 第 {item['row']} 行:{escape(item['name'] or '空白')}({escape(item['reason'])})</li>"
  518. for item in excluded_examples
  519. )
  520. report_html = f"""<!DOCTYPE html>
  521. <html lang="zh-CN">
  522. <head>
  523. <meta charset="utf-8" />
  524. <meta name="viewport" content="width=device-width, initial-scale=1" />
  525. <title>玻璃明细数据报表</title>
  526. <style>
  527. :root {{
  528. --bg: #f5f7fb;
  529. --panel: #ffffff;
  530. --line: #dbe2ea;
  531. --text: #0f172a;
  532. --muted: #64748b;
  533. --soft: #eef2ff;
  534. --accent: #2563eb;
  535. --accent-2: #14b8a6;
  536. --accent-3: #f59e0b;
  537. --accent-4: #ef4444;
  538. --shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
  539. }}
  540. * {{ box-sizing: border-box; }}
  541. body {{
  542. margin: 0;
  543. background: var(--bg);
  544. color: var(--text);
  545. font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
  546. line-height: 1.5;
  547. }}
  548. .wrap {{
  549. max-width: 1480px;
  550. margin: 0 auto;
  551. padding: 24px;
  552. }}
  553. .hero {{
  554. background: linear-gradient(135deg, #0f172a 0%, #1e293b 52%, #0f766e 100%);
  555. color: white;
  556. border-radius: 8px;
  557. padding: 28px;
  558. box-shadow: var(--shadow);
  559. }}
  560. .hero-top {{
  561. display: grid;
  562. grid-template-columns: 1.5fr 1fr;
  563. gap: 18px;
  564. align-items: start;
  565. }}
  566. .title {{
  567. font-size: 32px;
  568. font-weight: 800;
  569. letter-spacing: 0;
  570. margin: 0 0 8px 0;
  571. }}
  572. .subtitle {{
  573. color: rgba(255,255,255,0.82);
  574. margin: 0 0 16px 0;
  575. max-width: 980px;
  576. font-size: 14px;
  577. }}
  578. .chips {{
  579. display: flex;
  580. flex-wrap: wrap;
  581. gap: 8px;
  582. }}
  583. .chip {{
  584. display: inline-flex;
  585. align-items: center;
  586. gap: 6px;
  587. border-radius: 999px;
  588. border: 1px solid transparent;
  589. padding: 6px 10px;
  590. font-size: 12px;
  591. font-weight: 600;
  592. white-space: nowrap;
  593. }}
  594. .chip.soft {{ background: rgba(255,255,255,0.12); color: white; border-color: rgba(255,255,255,0.16); }}
  595. .chip.neutral {{ background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.86); border-color: rgba(255,255,255,0.12); }}
  596. .hero-metrics {{
  597. display: grid;
  598. grid-template-columns: repeat(2, minmax(0, 1fr));
  599. gap: 10px;
  600. }}
  601. .hero-metric {{
  602. background: rgba(255,255,255,0.08);
  603. border: 1px solid rgba(255,255,255,0.12);
  604. border-radius: 8px;
  605. padding: 14px 14px 12px;
  606. }}
  607. .hero-metric span {{
  608. display: block;
  609. color: rgba(255,255,255,0.72);
  610. font-size: 12px;
  611. margin-bottom: 6px;
  612. }}
  613. .hero-metric strong {{
  614. font-size: 24px;
  615. line-height: 1.1;
  616. }}
  617. .section {{
  618. margin-top: 18px;
  619. background: var(--panel);
  620. border: 1px solid #e5e7eb;
  621. border-radius: 8px;
  622. box-shadow: var(--shadow);
  623. overflow: hidden;
  624. }}
  625. .section-head {{
  626. padding: 18px 20px 10px;
  627. border-bottom: 1px solid #edf2f7;
  628. }}
  629. .section-title {{
  630. font-size: 18px;
  631. font-weight: 800;
  632. margin: 0 0 4px 0;
  633. }}
  634. .section-desc {{
  635. margin: 0;
  636. color: var(--muted);
  637. font-size: 13px;
  638. }}
  639. .section-body {{
  640. padding: 18px 20px 20px;
  641. }}
  642. .kpis {{
  643. display: grid;
  644. grid-template-columns: repeat(6, minmax(0, 1fr));
  645. gap: 12px;
  646. }}
  647. .kpi {{
  648. background: #fff;
  649. border: 1px solid #e5e7eb;
  650. border-radius: 8px;
  651. padding: 14px;
  652. box-shadow: 0 2px 8px rgba(15,23,42,0.03);
  653. min-height: 92px;
  654. }}
  655. .kpi span {{
  656. color: var(--muted);
  657. font-size: 12px;
  658. display: block;
  659. margin-bottom: 8px;
  660. }}
  661. .kpi strong {{
  662. font-size: 22px;
  663. display: block;
  664. margin-bottom: 4px;
  665. }}
  666. .kpi em {{
  667. font-style: normal;
  668. color: var(--muted);
  669. font-size: 12px;
  670. }}
  671. .grid-2 {{
  672. display: grid;
  673. grid-template-columns: 1.35fr 0.95fr;
  674. gap: 16px;
  675. align-items: start;
  676. }}
  677. .panel {{
  678. border: 1px solid #e5e7eb;
  679. border-radius: 8px;
  680. background: #fff;
  681. padding: 16px;
  682. }}
  683. .panel-head {{
  684. display: flex;
  685. align-items: baseline;
  686. justify-content: space-between;
  687. gap: 10px;
  688. margin-bottom: 12px;
  689. }}
  690. .panel-head h3 {{
  691. margin: 0;
  692. font-size: 15px;
  693. }}
  694. .panel-head p {{
  695. margin: 0;
  696. color: var(--muted);
  697. font-size: 12px;
  698. }}
  699. .focus-grid {{
  700. display: grid;
  701. grid-template-columns: repeat(2, minmax(0, 1fr));
  702. gap: 14px;
  703. }}
  704. .focus-card {{
  705. border: 1px solid #e5e7eb;
  706. border-radius: 8px;
  707. background: linear-gradient(180deg, #ffffff, #f8fafc);
  708. padding: 14px;
  709. }}
  710. .focus-head {{
  711. display: flex;
  712. justify-content: space-between;
  713. gap: 10px;
  714. align-items: start;
  715. margin-bottom: 12px;
  716. }}
  717. .focus-title {{
  718. font-size: 20px;
  719. font-weight: 800;
  720. margin-bottom: 4px;
  721. }}
  722. .focus-sub {{
  723. color: var(--muted);
  724. font-size: 12px;
  725. word-break: break-all;
  726. }}
  727. .focus-badge {{
  728. background: #ecfeff;
  729. color: #0f766e;
  730. border: 1px solid #99f6e4;
  731. border-radius: 999px;
  732. padding: 4px 10px;
  733. font-size: 12px;
  734. font-weight: 700;
  735. white-space: nowrap;
  736. }}
  737. .focus-metrics {{
  738. display: grid;
  739. grid-template-columns: repeat(2, minmax(0, 1fr));
  740. gap: 10px;
  741. margin-bottom: 12px;
  742. }}
  743. .focus-metrics div {{
  744. background: white;
  745. border: 1px solid #eef2f7;
  746. border-radius: 8px;
  747. padding: 10px;
  748. }}
  749. .focus-metrics span {{
  750. display: block;
  751. color: var(--muted);
  752. font-size: 12px;
  753. margin-bottom: 4px;
  754. }}
  755. .focus-metrics strong {{
  756. font-size: 16px;
  757. }}
  758. .focus-variants {{
  759. display: flex;
  760. flex-wrap: wrap;
  761. gap: 8px;
  762. }}
  763. .soft {{
  764. background: #f1f5f9;
  765. color: #0f172a;
  766. border-color: #e2e8f0;
  767. }}
  768. .label-grid {{
  769. display: grid;
  770. grid-template-columns: repeat(4, minmax(0, 1fr));
  771. gap: 14px;
  772. }}
  773. .label-card {{
  774. border: 1px solid #e5e7eb;
  775. border-radius: 8px;
  776. background: #fff;
  777. padding: 14px;
  778. min-height: 240px;
  779. }}
  780. .label-top {{
  781. display: flex;
  782. justify-content: space-between;
  783. gap: 12px;
  784. align-items: start;
  785. margin-bottom: 12px;
  786. }}
  787. .label-name {{
  788. font-size: 18px;
  789. font-weight: 800;
  790. margin-bottom: 4px;
  791. }}
  792. .label-sub {{
  793. color: var(--muted);
  794. font-size: 12px;
  795. }}
  796. .label-badge {{
  797. background: #eff6ff;
  798. border: 1px solid #bfdbfe;
  799. color: #1d4ed8;
  800. border-radius: 999px;
  801. padding: 4px 10px;
  802. font-size: 12px;
  803. font-weight: 700;
  804. white-space: nowrap;
  805. }}
  806. .mini-row {{
  807. display: grid;
  808. grid-template-columns: 92px 1fr 98px;
  809. gap: 10px;
  810. align-items: center;
  811. margin-bottom: 10px;
  812. }}
  813. .mini-name {{
  814. font-size: 12px;
  815. color: #334155;
  816. overflow: hidden;
  817. text-overflow: ellipsis;
  818. white-space: nowrap;
  819. }}
  820. .mini-bar {{
  821. display: block;
  822. height: 8px;
  823. background: #eef2f7;
  824. border-radius: 999px;
  825. overflow: hidden;
  826. }}
  827. .mini-bar i {{
  828. display: block;
  829. height: 100%;
  830. border-radius: 999px;
  831. }}
  832. .mini-value {{
  833. font-size: 12px;
  834. text-align: right;
  835. color: #0f172a;
  836. font-weight: 600;
  837. }}
  838. .table-wrap {{
  839. overflow-x: auto;
  840. }}
  841. table {{
  842. width: 100%;
  843. border-collapse: collapse;
  844. min-width: 1080px;
  845. }}
  846. th, td {{
  847. border-bottom: 1px solid #edf2f7;
  848. padding: 11px 10px;
  849. text-align: left;
  850. vertical-align: top;
  851. font-size: 13px;
  852. }}
  853. th {{
  854. position: sticky;
  855. top: 0;
  856. background: #f8fafc;
  857. color: #0f172a;
  858. font-weight: 800;
  859. z-index: 1;
  860. }}
  861. tbody tr:nth-child(even) td {{
  862. background: #fbfdff;
  863. }}
  864. .chart-svg {{
  865. width: 100%;
  866. height: auto;
  867. display: block;
  868. }}
  869. .insight {{
  870. display: grid;
  871. grid-template-columns: 1.2fr 0.8fr;
  872. gap: 16px;
  873. align-items: start;
  874. }}
  875. .note {{
  876. background: #fffbeb;
  877. border: 1px solid #fde68a;
  878. color: #92400e;
  879. border-radius: 8px;
  880. padding: 14px 16px;
  881. font-size: 13px;
  882. }}
  883. .note ul {{
  884. margin: 8px 0 0 18px;
  885. padding: 0;
  886. }}
  887. .note li {{
  888. margin-bottom: 6px;
  889. }}
  890. .empty {{
  891. color: var(--muted);
  892. font-size: 13px;
  893. padding: 12px 0;
  894. }}
  895. .muted {{
  896. color: var(--muted);
  897. }}
  898. .footer {{
  899. margin: 18px 0 8px;
  900. color: var(--muted);
  901. font-size: 12px;
  902. text-align: center;
  903. }}
  904. @media (max-width: 1200px) {{
  905. .hero-top, .grid-2, .insight {{
  906. grid-template-columns: 1fr;
  907. }}
  908. .kpis, .label-grid, .focus-grid {{
  909. grid-template-columns: repeat(2, minmax(0, 1fr));
  910. }}
  911. }}
  912. @media (max-width: 760px) {{
  913. .wrap {{ padding: 14px; }}
  914. .hero {{ padding: 20px; }}
  915. .title {{ font-size: 24px; }}
  916. .kpis, .label-grid, .focus-grid, .hero-metrics {{
  917. grid-template-columns: 1fr;
  918. }}
  919. table {{ min-width: 920px; }}
  920. .mini-row {{
  921. grid-template-columns: 72px 1fr 86px;
  922. }}
  923. }}
  924. </style>
  925. </head>
  926. <body>
  927. <div class="wrap">
  928. <section class="hero">
  929. <div class="hero-top">
  930. <div>
  931. <h1 class="title">玻璃明细数据报表</h1>
  932. <p class="subtitle">
  933. 以“料单编号聚合 + 厚度 SKU 归并 + 标签并行统计”为主线,整合来源表中的有效明细。
  934. 已优先合并以 <b>LM-</b> 开头的同编号产品,并把 <b>超白钢化</b> 这类厚度 SKU 统一到产品级别。
  935. </p>
  936. <div class="chips">
  937. {render_chip(f"文件:{xlsx_path.name}", "soft")}
  938. {render_chip(f"有效明细 {total_rows} 行", "soft")}
  939. {render_chip(f"产品级 {total_groups} 组", "soft")}
  940. {render_chip(f"已剔除 {excluded_rows} 条汇总/空白行", "soft")}
  941. </div>
  942. </div>
  943. <div class="hero-metrics">
  944. <div class="hero-metric"><span>总数量</span><strong>{format_qty(total_qty)}</strong></div>
  945. <div class="hero-metric"><span>总面积</span><strong>{format_area(total_area)} ㎡</strong></div>
  946. <div class="hero-metric"><span>总金额</span><strong>{format_money(total_amount)}</strong></div>
  947. <div class="hero-metric"><span>来源表</span><strong>{total_sheets}</strong></div>
  948. </div>
  949. </div>
  950. </section>
  951. <section class="section">
  952. <div class="section-head">
  953. <h2 class="section-title">统计概览</h2>
  954. <p class="section-desc">先看整体规模,再往下看产品级聚合、标签维度和来源表分布。</p>
  955. </div>
  956. <div class="section-body">
  957. <div class="kpis">
  958. <div class="kpi"><span>有效记录</span><strong>{total_rows}</strong><em>去掉总计/空白行后</em></div>
  959. <div class="kpi"><span>产品级分组</span><strong>{total_groups}</strong><em>同编号、同家族归并</em></div>
  960. <div class="kpi"><span>原始SKU数</span><strong>{sum(g['variant_count'] for g in group_stats.values())}</strong><em>去重后的原始名称数</em></div>
  961. <div class="kpi"><span>夹丝命中</span><strong>{label_stats['夹丝']['rows']}</strong><em>可与其他标签重叠</em></div>
  962. <div class="kpi"><span>透明命中</span><strong>{label_stats['透明']['rows']}</strong><em>可与其他标签重叠</em></div>
  963. <div class="kpi"><span>烤漆/艺术</span><strong>{label_stats['烤漆']['rows']} / {label_stats['艺术玻璃']['rows']}</strong><em>双维并行统计</em></div>
  964. </div>
  965. </div>
  966. </section>
  967. <section class="section">
  968. <div class="section-head">
  969. <h2 class="section-title">规则聚合重点</h2>
  970. <p class="section-desc">以下两组是这次报表里最关键的归并逻辑,和你的统计规则一一对应。</p>
  971. </div>
  972. <div class="section-body">
  973. <div class="focus-grid">
  974. {render_group_card(focus_lm012, "LM 编号聚合")}
  975. {render_group_card(focus_super_white, "超白钢化家族")}
  976. </div>
  977. </div>
  978. </section>
  979. <section class="section">
  980. <div class="section-head">
  981. <h2 class="section-title">产品级别 Top 10</h2>
  982. <p class="section-desc">按金额排序后的产品级统计,已把同编号和同家族 SKU 聚合到一条产品线。</p>
  983. </div>
  984. <div class="section-body">
  985. <div class="grid-2">
  986. <div class="panel">
  987. <div class="panel-head">
  988. <div>
  989. <h3>产品金额排行</h3>
  990. <p>看哪个产品级组合贡献了最多销售额。</p>
  991. </div>
  992. </div>
  993. {make_horizontal_bar_svg(product_chart_items)}
  994. </div>
  995. <div class="panel">
  996. <div class="panel-head">
  997. <div>
  998. <h3>来源表金额排行</h3>
  999. <p>不同 sheet 的金额贡献和有效行数分布。</p>
  1000. </div>
  1001. </div>
  1002. {make_horizontal_bar_svg(sheet_chart_items)}
  1003. </div>
  1004. </div>
  1005. </div>
  1006. </section>
  1007. <section class="section">
  1008. <div class="section-head">
  1009. <h2 class="section-title">标签维度</h2>
  1010. <p class="section-desc">四个维度按名称关键词和专类 sheet 共同归类,标签之间允许重叠,因此合计不会等于总量。</p>
  1011. </div>
  1012. <div class="section-body">
  1013. <div class="panel" style="margin-bottom:16px;">
  1014. <div class="panel-head">
  1015. <div>
  1016. <h3>四大标签金额对比</h3>
  1017. <p>从多个来源里单独看夹丝、透明、烤漆、艺术玻璃。</p>
  1018. </div>
  1019. </div>
  1020. {make_horizontal_bar_svg(label_chart_items)}
  1021. </div>
  1022. <div class="label-grid">
  1023. {render_label_cards(label_stats, total_amount)}
  1024. </div>
  1025. </div>
  1026. </section>
  1027. <section class="section">
  1028. <div class="section-head">
  1029. <h2 class="section-title">产品级明细表</h2>
  1030. <p class="section-desc">按产品级统计前 15 条,便于回看每个聚合组对应了哪些原始 SKU。</p>
  1031. </div>
  1032. <div class="section-body">
  1033. {render_group_table(group_stats, 15)}
  1034. </div>
  1035. </section>
  1036. <section class="section">
  1037. <div class="section-head">
  1038. <h2 class="section-title">来源表明细</h2>
  1039. <p class="section-desc">用于看各 sheet 的有效行、数量、面积和金额总览。</p>
  1040. </div>
  1041. <div class="section-body">
  1042. {render_sheet_table(sheet_stats)}
  1043. </div>
  1044. </section>
  1045. <section class="section">
  1046. <div class="section-head">
  1047. <h2 class="section-title">数据质量说明</h2>
  1048. <p class="section-desc">已自动剔除每个 sheet 的汇总行、空白行和 0 值行,避免把总计重复计入图表。</p>
  1049. </div>
  1050. <div class="section-body">
  1051. <div class="insight">
  1052. <div class="note">
  1053. <b>已排除的典型行</b>
  1054. <ul>
  1055. {excluded_html}
  1056. </ul>
  1057. <div style="margin-top:10px;">
  1058. 这类行主要是 <b>总计/合计</b>、空白行或 0 值占位行。它们不会进入图表和总表。
  1059. </div>
  1060. </div>
  1061. <div class="panel">
  1062. <div class="panel-head">
  1063. <div>
  1064. <h3>自动摘要</h3>
  1065. <p>顶部最强的几条产品级记录。</p>
  1066. </div>
  1067. </div>
  1068. <div style="font-size:14px;color:#0f172a;">
  1069. {escape(summary_insight)}
  1070. </div>
  1071. <div style="margin-top:14px;color:var(--muted);font-size:13px;">
  1072. 这份报表的重点,是把编码型料单和厚度型 SKU 先做产品级归并,再对四个标签分别统计。
  1073. 这样既能看总盘,也能回溯每个 SKU 的原始来源。
  1074. </div>
  1075. </div>
  1076. </div>
  1077. </div>
  1078. </section>
  1079. <div class="footer">
  1080. 生成时间:{datetime.now().strftime("%Y-%m-%d %H:%M:%S")} · 数据来源:{escape(str(xlsx_path))}
  1081. </div>
  1082. </div>
  1083. </body>
  1084. </html>
  1085. """
  1086. output_path.write_text(report_html, encoding="utf-8")
  1087. def main():
  1088. parser = argparse.ArgumentParser(description="Build glass detail HTML report")
  1089. parser.add_argument(
  1090. "--input",
  1091. default=r"D:\edge下载文件\玻璃明细.xlsx",
  1092. help="Excel file path",
  1093. )
  1094. parser.add_argument(
  1095. "--output",
  1096. default=None,
  1097. help="Output HTML path",
  1098. )
  1099. args = parser.parse_args()
  1100. xlsx_path = Path(args.input)
  1101. if not xlsx_path.exists():
  1102. raise FileNotFoundError(xlsx_path)
  1103. out_path = Path(args.output) if args.output else Path("outputs") / "glass_detail_report_20260525" / "glass_detail_report.html"
  1104. out_path.parent.mkdir(parents=True, exist_ok=True)
  1105. records, excluded = load_records(xlsx_path)
  1106. sheet_stats = build_sheet_stats(records)
  1107. group_stats = build_group_stats(records)
  1108. label_stats = build_label_stats(records, group_stats)
  1109. generate_report(
  1110. xlsx_path=xlsx_path,
  1111. output_path=out_path,
  1112. records=records,
  1113. excluded=excluded,
  1114. sheet_stats=sheet_stats,
  1115. group_stats=group_stats,
  1116. label_stats=label_stats,
  1117. )
  1118. print(
  1119. {
  1120. "input": str(xlsx_path),
  1121. "output": str(out_path),
  1122. "records": len(records),
  1123. "excluded": len(excluded),
  1124. "groups": len(group_stats),
  1125. "sheets": len(sheet_stats),
  1126. }
  1127. )
  1128. if __name__ == "__main__":
  1129. main()