Initial commit
This commit is contained in:
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -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/
|
||||
124
README.md
Normal file
124
README.md
Normal file
@@ -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 方案可直接复用)。
|
||||
- [ ] 发货信息单(地址 / 收件人 / 总件数 / 物料编码)—— 与装箱单拆分,另出报表。
|
||||
17
config/settings.example.yaml
Normal file
17
config/settings.example.yaml
Normal file
@@ -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: "<REPLACE_WITH_REAL_PASSWORD>"
|
||||
driver: "{ODBC Driver 18 for SQL Server}"
|
||||
trust_server_certificate: yes
|
||||
|
||||
# 报表配置
|
||||
report:
|
||||
# 调试开关:true 时给所有文本元素加边框,便于核对每个元素的实际占位;正式出图设 false。
|
||||
debug_border: false
|
||||
1
core/__init__.py
Normal file
1
core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""core 包:配置、数据库访问、字体。"""
|
||||
65
core/db.py
Normal file
65
core/db.py
Normal file
@@ -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()]
|
||||
46
core/fonts.py
Normal file
46
core/fonts.py
Normal file
@@ -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
|
||||
111
core/settings.py
Normal file
111
core/settings.py
Normal file
@@ -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()
|
||||
1
reports/packing_list/__init__.py
Normal file
1
reports/packing_list/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""装箱单报表(packing_list)。"""
|
||||
274
reports/packing_list/_build_template.py
Normal file
274
reports/packing_list/_build_template.py
Normal file
@@ -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),
|
||||
}
|
||||
35
reports/packing_list/config.yaml
Normal file
35
reports/packing_list/config.yaml
Normal file
@@ -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 同步调大
|
||||
37
reports/packing_list/query.sql
Normal file
37
reports/packing_list/query.sql
Normal file
@@ -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;
|
||||
116
reports/packing_list/transform.py
Normal file
116
reports/packing_list/transform.py
Normal file
@@ -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
|
||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@@ -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
|
||||
128
run.py
Normal file
128
run.py
Normal file
@@ -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/<name>/ 目录下,约定包含:
|
||||
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/<paichan_no>_box<box_no>.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())
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""tests 包。"""
|
||||
155
tests/test_transform.py
Normal file
155
tests/test_transform.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user