From 4dc563cdd9bce17fe1fa0679d6a440a65112508a Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Thu, 30 Jul 2026 14:17:18 +0800 Subject: [PATCH] Initial commit --- .gitignore | 25 +++ README.md | 124 +++++++++++ config/settings.example.yaml | 17 ++ core/__init__.py | 1 + core/db.py | 65 ++++++ core/fonts.py | 46 ++++ core/settings.py | 111 ++++++++++ reports/packing_list/__init__.py | 1 + reports/packing_list/_build_template.py | 274 ++++++++++++++++++++++++ reports/packing_list/config.yaml | 35 +++ reports/packing_list/query.sql | 37 ++++ reports/packing_list/transform.py | 116 ++++++++++ requirements.txt | 8 + run.py | 128 +++++++++++ tests/__init__.py | 1 + tests/test_transform.py | 155 ++++++++++++++ 16 files changed, 1144 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/settings.example.yaml create mode 100644 core/__init__.py create mode 100644 core/db.py create mode 100644 core/fonts.py create mode 100644 core/settings.py create mode 100644 reports/packing_list/__init__.py create mode 100644 reports/packing_list/_build_template.py create mode 100644 reports/packing_list/config.yaml create mode 100644 reports/packing_list/query.sql create mode 100644 reports/packing_list/transform.py create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 tests/__init__.py create mode 100644 tests/test_transform.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..adc11d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ + +# virtualenv +.venv/ +venv/ + +# secrets & local config +config/settings.yaml + +# generated output +out/ + +# verification artifacts +*.preview.png +_smoke_* + +# AI Agents +.claude/ +.sisyphus/ +.agents/ +.zcode/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..73acbd1 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# WareShipManifest · 装箱单报表系统 + +基于 **ReportBro**(`reportbro-lib`,纯 Python)生成装箱单 PDF。报表定义(SQL / 模板 / 数据装配)均为纯文本,可被 Git 管理;后期对接真实打印机时,PDF 方案可直接复用。 + +> 业务背景:把 CargoTrace 成品库分拣系统的装箱数据,打印成装箱单随货流转。 +> 一个箱子一份 PDF,展示该箱装了哪些内容物(总排号)及其产品信息。 + +## 快速开始 + +```bash +# 1. 建虚拟环境并装依赖 +python -m venv .venv +.venv/Scripts/python.exe -m pip install -r requirements.txt # Windows / Git Bash + +# 2. 配置数据库(复制模板并填凭据,或直接复用 services/fastapi 的 settings.yaml) +cp config/settings.example.yaml config/settings.yaml +# 编辑 config/settings.yaml 填入真实 host/数据库/账号密码 + +# 3. 生成装箱单 +.venv/Scripts/python.exe run.py --report packing_list \ + --param paichan_no=R04398 --param box_no=1 \ + --output out/R04398_box1.pdf +# → out/R04398_box1.pdf +``` + +## 用法 + +```bash +python run.py --report packing_list \ + --param paichan_no=<排产号> \ + --param box_no=<箱号> \ + --output <输出路径.pdf> # 可选,默认 out/<排产号>_box<箱号>.pdf +``` + +- 缺少参数 → 提示缺少哪个参数。 +- 排产号 + 箱号无装箱明细 → 提示「找不到该箱」。 +- 均以非零退出码退出,便于脚本集成。 + +## 报表内容 + +| 区域 | 内容 | +|---|---| +| 标题 | 装箱单 / PACKING LIST + 分隔线 | +| 信息条 | 排产号、箱号、装箱日期、订单号(2×2 网格,标签灰 + 值黑) | +| 列头 | 序号 · 总排号 · 产品型号 · 量程 · 位号 · 数量 · 工令号(小号灰) | +| 明细 | 逐行文本,行间细灰线;无网格边框 | +| 合计 | 合计 N 件(右对齐于数量列下) | +| 页脚 | 生成时间 | + +- **无边框、单色、字体驱动**:靠字号 / 字重 / 留白 / 细灰分隔线建立层次,不用表格网格。 +- **位号**:合同表字段,多数订单为空,有则显示、无则留白。 +- **产品型号**为超长编码串,按列宽换行,行高自适应。 + +## 数据来源 + +| 表 | 作用 | +|---|---| +| `CargoTrace.finished_goods_box` | 箱头(排产号 + 箱号 + 装箱时间) | +| `CargoTrace.finished_goods_box_item` | 箱内明细(总排号 + 装入数量) | +| `productionContractData.26年压力表合同数据` / `26年温度计合同数据` | 产品信息(型号/量程/位号/工令号/订单号) | + +同一总排号只命中压力表或温度计其中一张表,用双 `LEFT JOIN + COALESCE` 取产品字段, +避免前缀误匹配(`26BW` 不会被 `26B%` 命中)。 + +## 目录结构 + +``` +WareShipManifest/ +├── config/ +│ ├── settings.example.yaml # 配置模板(提交) +│ └── settings.yaml # 真实凭据(gitignore) +├── core/ +│ ├── settings.py # 读 yaml → mssql+pyodbc 连接 URL +│ ├── db.py # run_query(sql, params) 参数化绑定 +│ └── fonts.py # 中文字体注册(simhei via additional_fonts) +├── reports/packing_list/ +│ ├── query.sql # 参数化查询(:paichan_no / :box_no) +│ ├── transform.py # build_context(rows) → 预展开标量参数 + 行高估算(纯逻辑) +│ ├── _build_template.py # 样式 + 运行时按数据动态布局的文档元素(无边框列表式) +│ └── config.yaml # 报表元信息(参数/字段说明) +├── tests/test_transform.py # 纯逻辑单测 +├── run.py # CLI 入口 +├── requirements.txt +└── README.md +``` + +## 开发 + +### 改报表版式(列宽 / 字号 / 边距 / 配色 / 行距) + +版式全部在 `reports/packing_list/_build_template.py` 中以代码定义(常量 + 样式工厂)。 +文档元素在运行时按数据动态生成(明细行高度自适应),无需预生成模板文件—— +直接改代码后重跑 `run.py` 即生效。 + +> 不使用 ReportBro 可视化设计器,也不用其表格元素(行高不自适应、行不自动堆叠); +> 改用纯文本元素 + 细横线手工排版,坐标完全可控,靠字号/字重/留白建立层次。 + +### 改查询 / 数据装配 + +- SQL:`reports/packing_list/query.sql`(参数化,禁字符串拼接)。 +- 数据装配:`reports/packing_list/transform.py` 的 `build_context`(纯逻辑,含单测)。 + +### 跑测试 + +```bash +.venv/Scripts/python.exe -m pytest -q +``` + +### 中文字体 + +核心字体(helvetica 等)无法编码中文,故通过 `core/fonts.py` 注册 +`C:/Windows/Fonts/simhei.ttf`(黑体),模板样式 `font="simhei"`。 +跨机器部署若缺该字体,可用环境变量 `REPORT_CJK_FONT` 指定其它支持中文的 ttf。 + +## 技术说明 + +- **reportbro-lib**:纯 Python(`pip install reportbro-lib`),无需 Docker / 浏览器 / 设计器常驻服务。 +- 数据处理与展示分离:排序在 SQL、合计与日期在 transform、模板只渲染。 +- 生成的 PDF 可用 `pymupdf`(`fitz`)渲染 PNG 做肉眼核对(验证用,非运行时依赖)。 + +## 路线图 + +- [ ] 后期对接真实打印机(PDF 方案可直接复用)。 +- [ ] 发货信息单(地址 / 收件人 / 总件数 / 物料编码)—— 与装箱单拆分,另出报表。 diff --git a/config/settings.example.yaml b/config/settings.example.yaml new file mode 100644 index 0000000..83502f9 --- /dev/null +++ b/config/settings.example.yaml @@ -0,0 +1,17 @@ +# 复制本文件为 config/settings.yaml 并填入真实凭据(settings.yaml 已被 .gitignore 忽略,不会提交)。 +# 配置与 services/fastapi(CargoTrace 后端)保持一致。 +database: + active: sql_server + sql_server: + host: 192.168.110.114 + port: 1433 + database: CompanyDB + username: peng + password: "" + driver: "{ODBC Driver 18 for SQL Server}" + trust_server_certificate: yes + +# 报表配置 +report: + # 调试开关:true 时给所有文本元素加边框,便于核对每个元素的实际占位;正式出图设 false。 + debug_border: false diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..acf6651 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ +"""core 包:配置、数据库访问、字体。""" diff --git a/core/db.py b/core/db.py new file mode 100644 index 0000000..3d1b391 --- /dev/null +++ b/core/db.py @@ -0,0 +1,65 @@ +"""数据库访问层。 + +提供 run_query:读取 .sql 文件、剥离注释、参数化绑定执行、返回 list[dict]。 +所有报表查询必须走参数绑定,禁止字符串拼接 SQL(防注入)。 +""" +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +from sqlalchemy import create_engine, text +from sqlalchemy.engine import Engine + +from core.settings import settings + +_engine: Engine | None = None + + +def get_engine() -> Engine: + """惰性创建并缓存 SQLAlchemy engine。""" + global _engine + if _engine is None: + _engine = create_engine( + settings.database_url, + pool_pre_ping=True, + pool_recycle=1800, + ) + return _engine + + +def load_sql(sql_path: str | Path) -> str: + """读取 .sql 文件并剥离行级 `--` 注释,返回纯 SQL 文本。 + + 支持 SQL 文件里自由书写中文注释(便于阅读与 AI 理解)。 + 仅剥离以 `--` 开头的整行注释(行首可有空白),不处理行内注释, + 避免误删含 `--` 的字符串字面量。 + """ + path = Path(sql_path) + if not path.is_absolute(): + from core.settings import PROJECT_ROOT + + path = PROJECT_ROOT / path + raw = path.read_text(encoding="utf-8") + lines = [] + for line in raw.splitlines(): + # 去掉行首空白后判断是否为注释行 + if re.match(r"\s*--", line): + continue + lines.append(line) + return "\n".join(lines).strip() + + +def run_query(sql_path: str | Path, params: dict[str, Any]) -> list[dict[str, Any]]: + """执行参数化查询,返回 list[dict](每行一个 dict,键为列别名)。 + + :param sql_path: .sql 文件路径(相对项目根或绝对路径)。 + :param params: 绑定参数,键名对应 SQL 中的 :name 占位符。 + """ + sql_text = load_sql(sql_path) + engine = get_engine() + with engine.connect() as conn: + result = conn.execute(text(sql_text), params) + cols = list(result.keys()) + return [dict(zip(cols, row)) for row in result.fetchall()] diff --git a/core/fonts.py b/core/fonts.py new file mode 100644 index 0000000..1c866a7 --- /dev/null +++ b/core/fonts.py @@ -0,0 +1,46 @@ +"""中文字体配置。 + +reportbro-lib 的核心字体(helvetica 等)无法编码 CJK,需通过 additional_fonts +注册支持中文的 TrueType 字体。本机使用 Windows 自带的 simhei.ttf(黑体)。 + +模板里所有样式的 font 字段必须设为这里的 value(如 "simhei")。 +若将来跨机器部署,将字体文件放入 assets/ 并改为相对路径。 +""" +import os + +# 模板中引用的字体名(小写,reportbro 内部按小写存储) +CJK_FONT_NAME = "simhei" + +# 默认字体文件路径(Windows 系统字体目录) +_DEFAULT_FONT_PATH = r"C:/Windows/Fonts/simhei.ttf" +_BAHNSCHRIFT_PATH = r"C:/Windows/Fonts/bahnschrift.ttf" + + +def additional_fonts() -> list[dict]: + """返回 reportbro Report(additional_fonts=...) 所需的字体清单。 + + 注册两类字体: + - simhei:中文(CJK)主字体 + - bahnschrift:西文等宽风格字体,用于装箱单信息条(排产号/订单号等编号) + 若环境变量 REPORT_CJK_FONT 指定了备用 ttf 路径,则改用该路径。 + simhei 无独立 bold/italic 文件,四种字形全部映射到同一个 ttf。 + """ + font_path = os.environ.get("REPORT_CJK_FONT", _DEFAULT_FONT_PATH) + if not os.path.exists(font_path): + raise FileNotFoundError( + f"中文字体文件不存在: {font_path}\n" + f"请安装 simhei.ttf,或通过环境变量 REPORT_CJK_FONT 指定其它支持中文的 ttf 路径。" + ) + fonts = [ + { + "value": CJK_FONT_NAME, + "filename": font_path, + # bold_filename / italic_filename / bold_italic_filename 省略, + # reportbro 会自动把所有字形映射到 filename(见 FPDFRB 初始化)。 + } + ] + # Bahnschrift(西文编号字体,可选;缺失不致命) + bahn_path = os.environ.get("REPORT_LATIN_FONT", _BAHNSCHRIFT_PATH) + if os.path.exists(bahn_path): + fonts.append({"value": "bahnschrift", "filename": bahn_path}) + return fonts diff --git a/core/settings.py b/core/settings.py new file mode 100644 index 0000000..1ec7bf5 --- /dev/null +++ b/core/settings.py @@ -0,0 +1,111 @@ +"""数据库配置加载。 + +与 services/fastapi(CargoTrace 后端)的 config/settings.py 保持同构, +复用同一份 config/settings.yaml 凭据。 +""" +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel +from pydantic_yaml import parse_yaml_raw_as +from sqlalchemy.engine import URL + + +class SqlServerConfig(BaseModel): + """SQL Server 连接配置""" + + host: str + port: int = 1433 + database: str + username: str + password: str + driver: str = "{ODBC Driver 18 for SQL Server}" + trust_server_certificate: str = "yes" + + +class PostgreSqlConfig(BaseModel): + """PostgreSQL 连接配置""" + + host: str + port: int = 5432 + database: str + username: str + password: str + + +class DatabaseConfig(BaseModel): + """数据库配置""" + + active: Literal["sql_server", "postgresql"] = "sql_server" + sql_server: SqlServerConfig + postgresql: PostgreSqlConfig | None = None + + +class ReportConfig(BaseModel): + """报表配置""" + + # 调试开关:True 时给所有文本元素加边框,便于核对每个元素的实际占位。 + debug_border: bool = False + + +class Settings(BaseModel): + """应用配置""" + + database: DatabaseConfig + report: ReportConfig = ReportConfig() + + @property + def database_url(self) -> URL: + """构建数据库连接 URL""" + if self.database.active == "postgresql": + return self._postgresql_url() + return self._sql_server_url() + + def _sql_server_url(self) -> URL: + conf = self.database.sql_server + return URL.create( + "mssql+pyodbc", + username=conf.username, + password=conf.password, + host=conf.host, + port=conf.port, + database=conf.database, + query={ + "driver": conf.driver.strip("{}"), + "TrustServerCertificate": conf.trust_server_certificate, + }, + ) + + def _postgresql_url(self) -> URL: + conf = self.database.postgresql + if conf is None: + raise ValueError("已选择 postgresql,但未配置 database.postgresql") + return URL.create( + "postgresql+psycopg", + username=conf.username, + password=conf.password, + host=conf.host, + port=conf.port, + database=conf.database, + ) + + +# 项目根目录:core/settings.py 的 parent.parent +PROJECT_ROOT = Path(__file__).resolve().parent.parent + + +def load_settings(config_path: str = "config/settings.yaml") -> Settings: + """加载 YAML 配置文件(相对路径基于项目根目录解析)。""" + path = Path(config_path) + if not path.is_absolute(): + path = PROJECT_ROOT / config_path + if not path.exists(): + raise FileNotFoundError( + f"配置文件不存在: {config_path}\n" + f"请复制 config/settings.example.yaml 为 config/settings.yaml 并填入凭据。" + ) + with open(path, "r", encoding="utf-8") as f: + return parse_yaml_raw_as(Settings, f) + + +settings = load_settings() diff --git a/reports/packing_list/__init__.py b/reports/packing_list/__init__.py new file mode 100644 index 0000000..c15a17c --- /dev/null +++ b/reports/packing_list/__init__.py @@ -0,0 +1 @@ +"""装箱单报表(packing_list)。""" diff --git a/reports/packing_list/_build_template.py b/reports/packing_list/_build_template.py new file mode 100644 index 0000000..be52297 --- /dev/null +++ b/reports/packing_list/_build_template.py @@ -0,0 +1,274 @@ +"""装箱单模板:样式定义 + 文档元素布局。 + +设计理念:**无边框、单色、字体驱动**的列表式排版。 +弃用 ReportBro 表格元素(行高不自适应、行不自动堆叠),改用纯文本元素 ++ 细横线手工排版。每行明细的高度由 transform 按字段长度估算, +本模块据此计算各元素的精确 y 坐标 —— 因此 docElements 在运行时按数据动态生成。 + +坐标系:mm。A4 纵向 210 x 297。 +""" +from __future__ import annotations + +from typing import Any + +# ---- 单位说明 ---- +# ReportBro 的页面尺寸由 pageFormat=A4 自动换算为 pt(210mm→595pt), +# 但 docElements 的 x/y/width/height 与 documentProperties 的边距都按 **pt** 直接使用 +# (边距不换算)。因此本文件所有「设计尺寸」以 mm 书写便于阅读,输出前用 PT 换算为 pt。 +PT = 2.834645669 # 1mm = 2.834645669pt(72/25.4) + + +def mm(v: float) -> int: + """mm → pt(取整)。所有坐标/尺寸输出前必须经过此换算。""" + return round(v * PT) + + +# ---- 页面与边距(mm,设计值)---- +# A5 横向:210mm(宽) × 148mm(高)。ReportBro 用 orientation=landscape + pageFormat=A5。 +PAGE_W = 210 # 横向时的宽度 +PAGE_H = 148 # 横向时的高度 +MARGIN_L = 12 +MARGIN_R = 12 +CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R # 186mm + +CJK = "simhei" +INK = "#1a1a1a" # 主文字(近黑) +MUTE = "#8a8a8a" # 辅助文字/列头(中灰) +FAINT = "#b5b5b5" # 行间细线(浅灰) +RULE = "#1a1a1a" # 区段加重线(与主文字同色) + +# ---- 列定义:序号/产品名称/产品型号/量程/数量/位号/备注 ---- +# (字段后缀, 表头, x偏移mm, 宽度mm, 对齐) +# 宽度合计 = 186mm。产品名称与型号给宽列,其余窄列。 +COLS = [ + ("seq", "序号", 0, 12, "center"), + ("product_name", "产品名称", 12, 30, "left"), + ("model", "产品型号", 42, 44, "left"), + ("range_", "量程", 86, 20, "left"), + ("qty", "数量", 106, 14, "right"), + ("weihao", "位号", 120, 26, "left"), + ("remark", "备注", 146, 40, "left"), +] + + +# ---------- 样式 ---------- +def _debug_border() -> bool: + """从配置文件读取 debug_border 开关(config/settings.yaml → report.debug_border)。""" + try: + from core.settings import settings + return settings.report.debug_border + except Exception: + return False + + +def _text_style(id_, *, size=10, bold=False, color=INK, halign="left", + valign="middle", name=None, pad_l=0, pad_r=0, font=CJK, + debug_border=None): + """文本样式。padding 默认 0 让坐标精确可控;可经 pad_l/pad_r 加左右内边距(mm)。 + + debug_border 由配置文件 report.debug_border 决定(True 时给元素加边框便于核对占位)。 + """ + db = _debug_border() if debug_border is None else debug_border + return { + "id": id_, "type": "text", "name": name or f"s{id_}", + "font": font, "fontSize": size, "bold": bold, "italic": False, + "underline": False, "strikethrough": False, + "horizontalAlignment": halign, "verticalAlignment": valign, + "textColor": color, "backgroundColor": "", + "lineSpacing": 1.25, + "paddingLeft": pad_l, "paddingTop": 0, "paddingRight": pad_r, "paddingBottom": 0, + "borderColor": FAINT, + "borderWidth": 0.3 if db else 0, + "borderRadius": 0, + "borderAll": db, + "borderLeft": db, "borderTop": db, + "borderRight": db, "borderBottom": db, + } + + +def styles() -> list[dict]: + return [ + _text_style(101, size=18, bold=True, halign="center", name="title"), + _text_style(102, size=8, color=MUTE, halign="center", name="subtitle"), + _text_style(103, size=8, color=INK, name="info_lbl"), + # 信息条值:Bahnschrift 字体。排产号/订单号放大1.5倍(28.5),箱号/装箱日期保持19。 + _text_style(104, size=19, bold=True, color=INK, name="info_val", font="bahnschrift"), + _text_style(112, size=28.5, bold=True, color=INK, name="info_val_big", font="bahnschrift"), + _text_style(105, size=9.5, bold=True, color=INK, halign="center", name="colhdr"), + _text_style(106, size=8.5, color=INK, name="cell_l"), + _text_style(107, size=8.5, color=INK, halign="center", name="cell_c"), + _text_style(108, size=8.5, bold=True, color=INK, halign="right", name="cell_r"), + _text_style(109, size=11.5, bold=True, color=INK, halign="center", name="total"), + # 合计数量:居中 + 加大两号(13.5) + 加粗 + _text_style(113, size=13.5, bold=True, color=INK, halign="center", name="total_qty"), + # 产品名称/型号:居中 + 左右各 5% 边距(按列宽算,padding 单位 mm) + # product_name 列宽 30mm → 5%≈1.5mm;model 列宽 44mm → 5%≈2.2mm + _text_style(110, size=8.5, color=INK, halign="center", name="cell_name", + pad_l=1.5, pad_r=1.5), + _text_style(111, size=8.5, color=INK, halign="center", name="cell_model", + pad_l=2.2, pad_r=2.2), + {"id": 201, "type": "line", "name": "rule_faint", + "color": FAINT, "borderWidth": 0.3}, + {"id": 202, "type": "line", "name": "rule_strong", + "color": RULE, "borderWidth": 2.5}, + ] + + +def document_properties() -> dict: + # 边距按 pt 给出(ReportBro 不换算 margin;pageFormat=A5 自动换算页面尺寸为 pt)。 + return { + "pageFormat": "A5", "orientation": "landscape", + "marginLeft": mm(MARGIN_L), "marginRight": mm(MARGIN_R), + "marginTop": mm(10), "marginBottom": mm(10), + "headerDisplay": "never", "headerSize": 0, + "footerDisplay": "never", "footerSize": 0, + "patternLocale": "zh", "patternCurrencySymbol": "", + "patternNumberGroupSymbol": "", + } + + +def parameters() -> list[dict]: + """模板参数。明细行预展开为 r0_*..r7_* 标量。""" + params = [ + {"id": 1, "name": "paichan_no", "type": "string", "nullable": False}, + {"id": 2, "name": "box_no", "type": "number", "nullable": False}, + {"id": 3, "name": "pack_date", "type": "string", "nullable": False}, + {"id": 4, "name": "order_no", "type": "string", "nullable": False}, + {"id": 5, "name": "total_qty", "type": "number", "nullable": False}, + {"id": 6, "name": "now", "type": "string", "nullable": False}, + ] + pid = 100 + for i in range(8): + for field, _l, _x, _w, _a in COLS: + pid += 1 + # 行字段统一用 string:空行为 ""、有数据行由 transform 转成字符串, + # 避免 number 参数收到空串报错。 + params.append({"id": pid, "name": f"r{i}_{field}", "type": "string", "nullable": True}) + # printIf 标记本行是否渲染 + params.append({"id": pid + 1, "name": f"r{i}_show", "type": "string", "nullable": True}) + return params + + +# ---------- 元素工厂 ---------- +def _text(id_, x, y, w, h, content, *, style_id, print_if=""): + return { + "id": id_, "elementType": "text", "containerId": "0_content", + "x": x, "y": y, "width": w, "height": h, + "content": content, "styleId": style_id, "eval": False, + "printIf": print_if, "removeEmptyElement": False, "alwaysPrintOnSamePage": False, + "link": "", "pattern": "", "cs_condition": "", + "richText": False, "richTextHtml": "", + "spreadsheet_hide": True, "spreadsheet_column": 0, + "spreadsheet_colspan": 1, "spreadsheet_addEmptyRow": False, + } + + +def _line(id_, x, y, w, *, style_id, weight=0): + """线条元素。weight=线粗(mm),映射到元素 height(reportbro 线条粗细由 height 决定, + 样式的 borderWidth 对线条无效)。0 = 细线(fpdf 默认最细)。""" + return { + "id": id_, "elementType": "line", "containerId": "0_content", + "x": x, "y": y, "width": w, "height": mm(weight), + "styleId": style_id, "printIf": "", "removeEmptyElement": False, + "spreadsheet_hide": True, "spreadsheet_column": 0, + "spreadsheet_addEmptyRow": False, + } + + +def build_doc_elements(context: dict[str, Any]) -> list[dict]: + """按数据动态生成所有文档元素(标题/信息条/列头/明细行/合计/页脚)。 + + 所有坐标/尺寸以 mm 设计书写,输出时统一经 mm() 换算为 pt + (ReportBro 元素坐标按 pt 使用,详见文件头单位说明)。 + 明细行高度取自 context['row_heights'](单位 mm),逐行累加 y 坐标。 + + 注意:元素 x/y 是相对内容容器的坐标(容器原点=左边距),故 x 从 0 起, + 不要再加左边距(左边距由 reportbro 在渲染时统一偏移)。 + """ + L = 0 # 元素相对内容容器左边,从 0 开始 + CW = mm(CONTENT_W) + els: list[dict] = [] + nid = [2000] + + def nid_(): + nid[0] += 1 + return nid[0] + + # 详情数据行各字段样式:默认居中(107);产品名称(110)/产品型号(111)居中+左右5%边距。 + # 表头仍用 colhdr 样式 105,不受影响。 + col_style = {"left": 107, "center": 107, "right": 107} + field_style = {"product_name": 110, "model": 111} + + # ===== 标题(紧贴内容区顶部,容器原点已在 marginTop 处)===== + y = mm(1) + els.append(_text(nid_(), L, y, CW, mm(10), "装箱单", style_id=101)) + els.append(_line(nid_(), L, y + mm(12), CW, style_id=202)) + + # ===== 信息条(2×2 网格,标签灰 + 值用 Bahnschrift)===== + # 行1:排产号 | 箱号;行2:订单号 | 装箱日期(订单号在排产号正下方) + # 排产号、订单号用大号(112, 28.5pt);箱号、装箱日期用普通号(104, 19pt)。 + y = y + mm(15) + info = [("排产号", "${paichan_no}", 112), ("箱号", "${box_no}", 104), + ("订单号", "${order_no}", 112), ("装箱日期", "${pack_date}", 104)] + col_w = CW / 2 + lbl_w = mm(28) + rh = mm(13) # 行高放高,容纳放大后的值字号 + for lbl, val, val_style in info: + row = info.index((lbl, val, val_style)) // 2 + col = info.index((lbl, val, val_style)) % 2 + x = L + col * col_w + yy = y + row * (rh + mm(2)) + els.append(_text(nid_(), x, yy, lbl_w, rh, lbl, style_id=103)) + els.append(_text(nid_(), x + lbl_w, yy, col_w - lbl_w, rh, val, style_id=val_style)) + y = y + 2 * (rh + mm(2)) + mm(2) + els.append(_line(nid_(), L, y, CW, style_id=201)) + + # ===== 列头 ===== + y = y + mm(4) + hdr_h = mm(6) + for _f, label, xoff, w, _a in COLS: + els.append(_text(nid_(), L + mm(xoff), y, mm(w), hdr_h, label, style_id=105)) + y = y + hdr_h + mm(2) + els.append(_line(nid_(), L, y, CW, style_id=202, weight=0.45)) # 表头下横线加粗 + + # ===== 明细行(逐行定位,高度自适应)===== + y = y + mm(2) + row_heights: list[float] = context.get("row_heights", []) + for i in range(8): + h = mm(row_heights[i]) if i < len(row_heights) else 0 + show = f"${{r{i}_show}}" # printIf: r{i}_show 为 "1" 才渲染 + for field, _label, xoff, w, align in COLS: + els.append(_text( + nid_(), L + mm(xoff), y, mm(w), h, + "${r%d_%s}" % (i, field), + style_id=field_style.get(field, col_style[align]), print_if=show, + )) + y += h + # 行间极细线(仅在有数据的行之后) + if i < 7 and row_heights[i] > 0: + els.append(_line(nid_(), L, y + mm(1), CW, style_id=201)) + y += mm(2) + + # ===== 合计 ===== + # 标签框宽度对齐量程列(x=86-106,宽20);数量框与数量列同宽(x=106-120)、居中。 + els.append(_line(nid_(), L, y + mm(2), CW, style_id=202, weight=0.45)) + y = y + mm(4) + range_x = COLS[3][2] # 量程列起点 86 + range_w = COLS[3][3] # 量程列宽 20 + qty_x = COLS[4][2] # 数量列起点 106 + qty_w = COLS[4][3] # 数量列宽 14 + els.append(_text(nid_(), L + mm(range_x), y, mm(range_w), mm(8), "合计", style_id=109)) + els.append(_text(nid_(), L + mm(qty_x), y, mm(qty_w), mm(8), "${total_qty}", style_id=113)) + + return els + + +def build_report_definition(context: dict[str, Any]) -> dict: + """组装完整 report_definition(运行时调用,按数据动态布局)。""" + return { + "version": 6, + "documentProperties": document_properties(), + "parameters": parameters(), + "styles": styles(), + "docElements": build_doc_elements(context), + } diff --git a/reports/packing_list/config.yaml b/reports/packing_list/config.yaml new file mode 100644 index 0000000..cfee86d --- /dev/null +++ b/reports/packing_list/config.yaml @@ -0,0 +1,35 @@ +name: packing_list +title: 装箱单 +description: 按排产号 + 箱号 生成单箱装箱单 PDF(一箱一份) +renderer: reportbro-lib +params: + paichan_no: + description: 排产号(如 R04398、R07425) + required: true + box_no: + description: 箱号(整数,在排产号下从 1 开始,如 1) + required: true +files: + query.sql: 参数化查询(:paichan_no / :box_no),双表 LEFT JOIN 取产品信息 + transform.py: build_context(rows) → 预展开的标量参数 + 行高估算(纯逻辑) + _build_template.py: 样式定义 + 运行时按数据动态布局的文档元素生成(无边框列表式) +fields: + seq: 序号(行号,1 起) + product_name: 产品名称(取自合同表 [客户名称] 字段——历史遗留命名,实际存的是产品名称,不改字段名) + model: 产品型号(双表 LEFT JOIN 取 COALESCE;超长编码会换行) + range_: 量程 + qty: 装入该箱的数量 + weihao: 位号(合同表字段,多数订单为空;有则显示、无则留白) + remark: 备注(合同表 [备注] 字段) +notes: + - 单位:一个箱子一份 PDF + - 纸张:A5 横向(210×148mm)。后期内容多时可切 A4 纵向,本期统一 A5 横向 + - 信息条:2×2 网格,行1 排产号/箱号,行2 订单号/装箱日期(订单号在排产号正下方) + - 排版:无边框、单色、字体驱动的列表式(弃用 ReportBro 表格元素,因其行高不自适应、行不自动堆叠) + - 文档元素按数据动态生成:明细行高度由 transform 按字段长度估算,模板据此计算 y 坐标 + - 明细行预展开为标量参数 r0_*..r7_*(ReportBro 表达式不支持 array 索引引用,故在 Python 端展平) + - 产品信息按总排号双表 LEFT JOIN(压力表/温度计合同表)取 COALESCE,避免前缀误匹配(26BW 不会被 26B% 命中) + - 合计件数 = 该箱所有 qty 之和 + - 查询空结果 → build_context 抛 EmptyBoxError,CLI 给出清晰提示 + - 中文通过 additional_fonts 注册 simhei.ttf 渲染(见 core/fonts.py) + - 预留最多 8 行明细;超过可在 _build_template.MAX_ROWS 与 transform.MAX_ROWS 同步调大 diff --git a/reports/packing_list/query.sql b/reports/packing_list/query.sql new file mode 100644 index 0000000..95d8e2e --- /dev/null +++ b/reports/packing_list/query.sql @@ -0,0 +1,37 @@ +-- 装箱单:按 排产号 + 箱号 取箱内明细,并双表 LEFT JOIN 取产品信息。 +-- 参数:paichan_no (str, 排产号) / box_no (int, 箱号) +-- 注:load_sql 会剥离整行 -- 注释,故注释里可自由书写中文。 +-- +-- 数据来源: +-- CargoTrace.finished_goods_box 箱头(排产号 + 箱号 + 装箱时间) +-- CargoTrace.finished_goods_box_item 箱内明细(总排号 + 装入数量) +-- productionContractData.26年压力表合同数据 / 26年温度计合同数据 产品信息 +-- +-- 产品信息双表取值:同一总排号只命中压力表或温度计其中一张表。 +-- 用双 LEFT JOIN + COALESCE 取产品字段,避免前缀路由出错(26BW 不会被 26B% 误匹配)。 +-- +-- 字段说明(注意历史遗留命名): +-- product_name:取自合同表 [客户名称] 字段,但实际存的是产品名称(历史笔误,系统已用,不改字段名) +-- weihao:位号,多数订单为空,有则显示、无则留白 +-- remark:备注 +SELECT b.paichan_no, + b.box_no, + b.created_at, + i.zongpai_no, + i.quantity AS box_qty, + COALESCE(p.[客户名称], t.[客户名称]) AS product_name, + COALESCE(p.[产品型号], t.[产品型号]) AS model, + COALESCE(p.[量程], t.[量程]) AS range_, + COALESCE(p.[位号], t.[位号]) AS weihao, + COALESCE(p.[备注], t.[备注]) AS remark, + COALESCE(p.[订单号], t.[订单号]) AS order_no +FROM CargoTrace.finished_goods_box b +JOIN CargoTrace.finished_goods_box_item i + ON i.box_id = b.id +LEFT JOIN [productionContractData].[26年压力表合同数据] p + ON p.[总排号] = i.zongpai_no +LEFT JOIN [productionContractData].[26年温度计合同数据] t + ON t.[总排号] = i.zongpai_no +WHERE b.paichan_no = :paichan_no + AND b.box_no = :box_no +ORDER BY i.id; diff --git a/reports/packing_list/transform.py b/reports/packing_list/transform.py new file mode 100644 index 0000000..87e2993 --- /dev/null +++ b/reports/packing_list/transform.py @@ -0,0 +1,116 @@ +"""装箱单数据装配(纯逻辑,可单测)。 + +把 run_query 返回的行(list[dict])转换成 ReportBro 模板所需的数据结构。 + +排版策略:弃用 ReportBro 表格元素(其行高不自适应、行不自动堆叠), +改为「Python 端逐行计算高度 + 预展开为标量参数」,模板用纯文本元素 +在精确坐标渲染。本模块负责: + - 计算 box 头信息、合计件数 + - 估算每行明细的高度(依据型号/位号等长字段的字符数),用于模板定位 +模板与展示逻辑严格分离:合计/日期/行高在此算好,模板只渲染。 +""" +from __future__ import annotations + +from datetime import date, datetime +from typing import Any + +MAX_ROWS = 8 # 模板预留的最大明细行数 + + +class EmptyBoxError(ValueError): + """查询结果为空 —— 该排产号 + 箱号不存在或无明细。""" + + +def _to_date(value: Any) -> str: + """把 created_at(datetime/iso str)格式化为 YYYY-MM-DD;失败则原样返回。""" + if value is None: + return "" + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d") + if isinstance(value, date): + return value.strftime("%Y-%m-%d") + if isinstance(value, str): + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).strftime("%Y-%m-%d") + except ValueError: + return value[:10] + return str(value) + + +def _clean(value: Any) -> str: + """None → 空串;其余去除首尾空白。位号/型号等空值统一留白。""" + if value is None: + return "" + return str(value).strip() + + +def _estimate_row_height(row: dict[str, Any]) -> float: + """估算单行明细的高度(mm)。 + + 长字段(产品名称/型号/位号/备注)会换行,行高取决于换行后最多的行数。 + 按各列宽度与字符宽估算行数,取最大值,每文本行约 4.2mm。 + 列宽与模板 COLS 保持一致(A5 横向)。 + """ + col_caps = { # field: (宽度mm, 约每行字符数) + "product_name": (30, 18), + "model": (40, 24), + "weihao": (24, 16), + "remark": (24, 16), + "range_": (18, 12), + } + max_lines = 1 + for field, (_w, cap) in col_caps.items(): + text = _clean(row.get(field)) + if not text: + continue + # 按容量向上取整;长串(含分隔符)按容量分段 + lines = max(1, -(-len(text) // cap)) # ceil division + max_lines = max(max_lines, lines) + # 基础行高 + 每文本行高度 + return 3.0 + max_lines * 4.2 + + +def build_context(rows: list[dict[str, Any]]) -> dict[str, Any]: + """把查询行装配成 ReportBro 模板数据。 + + :param rows: query.sql 的结果。 + :return: dict,含箱头标量参数 + 预展开的明细行标量参数(r0_*..r7_*)+ + 每行 y 坐标(row_y0..)+ 合计。 + :raises EmptyBoxError: rows 为空。 + """ + if not rows: + raise EmptyBoxError("找不到该箱:排产号与箱号无装箱明细,请核对参数。") + + first = rows[0] + total_qty = sum(int(r.get("box_qty") or 0) for r in rows) + + ctx: dict[str, Any] = { + "paichan_no": _clean(first.get("paichan_no")), + "box_no": int(first.get("box_no") or 0), + "pack_date": _to_date(first.get("created_at")), + "order_no": _clean(first.get("order_no")), + "total_qty": total_qty, + } + + # 预展开明细行:r{i}_{field},并在模板列出的行范围内填值(超出则留空)。 + # 每行的 y 偏移由模板侧按预估行高累加;此处也输出每行高度供模板使用。 + row_heights = [] + for i in range(MAX_ROWS): + if i < len(rows): + r = rows[i] + row_heights.append(_estimate_row_height(r)) + else: + row_heights.append(0.0) + src = rows[i] if i < len(rows) else {} + ctx[f"r{i}_seq"] = str(i + 1) if i < len(rows) else "" + ctx[f"r{i}_product_name"] = _clean(src.get("product_name")) + ctx[f"r{i}_model"] = _clean(src.get("model")) + ctx[f"r{i}_range_"] = _clean(src.get("range_")) + ctx[f"r{i}_qty"] = str(int(src.get("box_qty") or 0)) if i < len(rows) else "" + ctx[f"r{i}_weihao"] = _clean(src.get("weihao")) + ctx[f"r{i}_remark"] = _clean(src.get("remark")) + # 该行是否有数据(模板用 printIf 控制空行不渲染) + ctx[f"r{i}_show"] = "1" if i < len(rows) else "" + + ctx["row_heights"] = row_heights + return ctx diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..230ec83 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +reportbro-lib>=3.12 +sqlalchemy>=2.0 +pyodbc>=5.2 +pydantic>=2.0 +pydantic-yaml>=0.12 +# dev / verification only (not required at runtime) +pytest>=8.0 +pymupdf>=1.24 diff --git a/run.py b/run.py new file mode 100644 index 0000000..33daa42 --- /dev/null +++ b/run.py @@ -0,0 +1,128 @@ +"""装箱单报表生成 CLI。 + +用法: + python run.py --report packing_list \ + --param paichan_no=R04398 --param box_no=1 \ + --output out/R04398_box1.pdf + +每个报表位于 reports// 目录下,约定包含: + query.sql 参数化查询 + transform.py build_context(rows) -> dict(模板数据) + template.report ReportBro 模板 JSON +报表模块需导出 build_context。本入口负责:解析参数 → 查询 → 装配 → 渲染 PDF。 +""" +from __future__ import annotations + +import argparse +import importlib +import json +import sys +from datetime import datetime +from pathlib import Path + +# 确保项目根目录在 sys.path(便于 python run.py 直接运行) +PROJECT_ROOT = Path(__file__).resolve().parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from core.db import run_query # noqa: E402 +from core.fonts import additional_fonts # noqa: E402 + + +def parse_params(param_strs: list[str]) -> dict: + """把 ['paichan_no=R04398', 'box_no=1'] 解析为 {'paichan_no':'R04398','box_no':1}。 + + 数值型字符串自动转 int,便于 SQL 参数绑定。 + """ + params = {} + for item in param_strs or []: + if "=" not in item: + raise ValueError(f"参数格式错误(应为 key=value):{item}") + key, value = item.split("=", 1) + key, value = key.strip(), value.strip() + # 尝试转 int(箱号等);失败保持字符串 + try: + params[key] = int(value) + except ValueError: + params[key] = value + return params + + +def generate(report_name: str, params: dict, output: str) -> Path: + """生成指定报表的 PDF。""" + from reportbro import Report # 延迟导入,CLI 报错时信息更清晰 + + report_dir = PROJECT_ROOT / "reports" / report_name + if not report_dir.is_dir(): + raise FileNotFoundError(f"报表不存在:{report_dir}(检查 --report 名称)") + + # 必需参数校验 + required = {"paichan_no", "box_no"} + missing = required - params.keys() + if missing: + raise ValueError(f"缺少必需参数:{', '.join(sorted(missing))}(用 --param key=value 提供)") + + # 1. 查询 + rows = run_query(report_dir / "query.sql", params) + + # 2. 装配模板数据 + transform = importlib.import_module(f"reports.{report_name}.transform") + context = transform.build_context(rows) + context["now"] = datetime.now().strftime("%Y-%m-%d %H:%M") + + # 3. 渲染:模板元素按数据动态布局(明细行高度自适应) + layout = importlib.import_module(f"reports.{report_name}._build_template") + report_def = layout.build_report_definition(context) + report = Report( + report_definition=report_def, + data=context, + additional_fonts=additional_fonts(), + ) + if report.errors: + raise RuntimeError( + f"模板渲染错误({report_name}):\n" + + json.dumps(report.errors, ensure_ascii=False, indent=2, default=str) + ) + + out_path = Path(output) + if not out_path.is_absolute(): + out_path = PROJECT_ROOT / out_path + out_path.parent.mkdir(parents=True, exist_ok=True) + report.generate_pdf(str(out_path)) + return out_path + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="生成装箱单 PDF") + parser.add_argument("--report", default="packing_list", help="报表名(默认 packing_list)") + parser.add_argument( + "--param", action="append", default=[], + help="报表参数,格式 key=value(可多次指定)", + ) + parser.add_argument("--output", "-o", help="输出 PDF 路径(默认 out/_box.pdf)") + args = parser.parse_args(argv) + + try: + params = parse_params(args.param) + output = args.output + if not output: + pc = params.get("paichan_no", "report") + bn = params.get("box_no", 0) + output = f"out/{pc}_box{bn}.pdf" + + out_path = generate(args.report, params, output) + print(f"✅ 已生成:{out_path}") + print(f" 报表:{args.report} 参数:{params}") + return 0 + except FileNotFoundError as e: + print(f"❌ {e}", file=sys.stderr) + except ValueError as e: + print(f"❌ {e}", file=sys.stderr) + except Exception as e: # noqa: BLE001 + # 找不到箱等业务错误在此给出清晰提示 + print(f"❌ 生成失败:{e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8b62851 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""tests 包。""" diff --git a/tests/test_transform.py b/tests/test_transform.py new file mode 100644 index 0000000..be10075 --- /dev/null +++ b/tests/test_transform.py @@ -0,0 +1,155 @@ +"""装箱单 build_context 纯逻辑测试(不连数据库)。 + +字段集(v3):序号/产品名称/产品型号/量程/数量/位号/备注。 +产品名称取自合同表 [客户名称](历史遗留命名,实为产品名称)。 +""" +from datetime import datetime + +import pytest + +from reports.packing_list.transform import ( + EmptyBoxError, + build_context, +) + +# —— 真实样本:R04398/箱1,3 项,含位号,合计 4+4+5=13 —— +ROWS_R04398 = [ + { + "paichan_no": "R04398", "box_no": 1, + "created_at": datetime(2026, 5, 26, 8, 58, 44, 473000), + "zongpai_no": "26B14077", "box_qty": 4, + "product_name": "压力表(不锈钢压力表)", + "model": "YTH-100.A0.531.M201.M08|BP-088.2312.M08.0A3|WHP.70X20X1.3", + "range_": "0-1.6MPa", "weihao": "PG-01105,PG-01106,PG-01107,PG-01108", + "remark": "", "order_no": "26YB0410175", + }, + { + "paichan_no": "R04398", "box_no": 1, + "created_at": datetime(2026, 5, 26, 8, 58, 44, 473000), + "zongpai_no": "26B14078", "box_qty": 4, + "product_name": "耐震压力表(不锈钢耐震压力表)", + "model": "YTHN-100.A0.531.M201.M05.Y3|BP-088.2312.M05.0A3|WHP.70X20X1.3", + "range_": "0-0.4MPa", "weihao": "PG-00401A,PG-00401B,PG-00405A,PG-00405B", + "remark": "", "order_no": "26YB0410175", + }, + { + "paichan_no": "R04398", "box_no": 1, + "created_at": datetime(2026, 5, 26, 8, 58, 44, 473000), + "zongpai_no": "26B14079", "box_qty": 5, + "product_name": "耐震压力表(不锈钢耐震压力表)", + "model": "YTHN-100.A0.531.M201.M07.Y3|BP-088.2312.M07.0A3|WHP.70X20X1.3", + "range_": "0-1MPa", "weihao": "PG-00801A,PG-01201,PG-01202,PG-01203A,PG-01203B", + "remark": "", "order_no": "26YB0410175", + }, +] + + +def test_box_header_fields(): + ctx = build_context(ROWS_R04398) + assert ctx["paichan_no"] == "R04398" + assert ctx["box_no"] == 1 + assert ctx["pack_date"] == "2026-05-26" + assert ctx["order_no"] == "26YB0410175" + + +def test_total_qty_is_sum(): + ctx = build_context(ROWS_R04398) + assert ctx["total_qty"] == 13 # 4 + 4 + 5 + + +def test_rows_flattened_with_sequence(): + """明细行预展开为 r0_/r1_/r2_,序号从 1 起。""" + ctx = build_context(ROWS_R04398) + assert ctx["r0_seq"] == "1" + assert ctx["r1_seq"] == "2" + assert ctx["r2_seq"] == "3" + assert ctx["r0_qty"] == "4" + assert ctx["r2_qty"] == "5" + + +def test_product_name_from_kehu_mingcheng(): + """产品名称取自 product_name(即合同表 [客户名称])。""" + ctx = build_context(ROWS_R04398) + assert ctx["r0_product_name"] == "压力表(不锈钢压力表)" + assert ctx["r1_product_name"] == "耐震压力表(不锈钢耐震压力表)" + + +def test_row_show_flags(): + ctx = build_context(ROWS_R04398) + assert ctx["r0_show"] == "1" + assert ctx["r2_show"] == "1" + assert ctx["r3_show"] == "" # 第 4 行无数据 + + +def test_weihao_populated(): + ctx = build_context(ROWS_R04398) + assert ctx["r0_weihao"] == "PG-01105,PG-01106,PG-01107,PG-01108" + + +def test_remark_blank_when_empty(): + """备注为空时留白。""" + ctx = build_context(ROWS_R04398) + assert ctx["r0_remark"] == "" + + +def test_weihao_and_remark_blank(): + rows = [ + { + "paichan_no": "R07425", "box_no": 1, + "created_at": datetime(2026, 5, 26, 8, 58, 44), + "zongpai_no": "26B24529", "box_qty": 1, + "product_name": "耐震压力表", + "model": "YTHN-100.A0.531.M201.M09.Y3|BP-088.2312.M09.0p3", + "range_": "0-2.5MPa", "weihao": "", "remark": "", + "order_no": "26YB0210489", + } + ] + ctx = build_context(rows) + assert ctx["r0_weihao"] == "" + assert ctx["r0_remark"] == "" + assert ctx["total_qty"] == 1 + + +def test_none_values_cleaned_to_blank(): + rows = [ + { + "paichan_no": "X001", "box_no": 2, + "created_at": None, + "zongpai_no": "26B1", "box_qty": None, + "product_name": None, "model": None, "range_": None, + "weihao": None, "remark": None, "order_no": None, + } + ] + ctx = build_context(rows) + assert ctx["pack_date"] == "" + assert ctx["order_no"] == "" + assert ctx["r0_product_name"] == "" + assert ctx["r0_model"] == "" + assert ctx["r0_qty"] == "0" + assert ctx["total_qty"] == 0 + + +def test_iso_string_date(): + rows = [ + { + "paichan_no": "X002", "box_no": 1, + "created_at": "2026-07-30T10:00:00Z", + "zongpai_no": "26B2", "box_qty": 7, + "product_name": "P", "model": "M", "range_": "R", + "weihao": "W", "remark": "RM", "order_no": "O", + } + ] + ctx = build_context(rows) + assert ctx["pack_date"] == "2026-07-30" + + +def test_empty_rows_raise(): + with pytest.raises(EmptyBoxError): + build_context([]) + + +def test_row_heights_estimated(): + ctx = build_context(ROWS_R04398) + assert len(ctx["row_heights"]) == 8 + assert all(h > 0 for h in ctx["row_heights"][:3]) + assert ctx["row_heights"][3] == 0.0