Initial commit

This commit is contained in:
Misaka_Company
2026-07-30 14:17:18 +08:00
commit 4dc563cdd9
16 changed files with 1144 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""装箱单报表packing_list"""

View 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 自动换算为 pt210mm→595pt
# 但 docElements 的 x/y/width/height 与 documentProperties 的边距都按 **pt** 直接使用
# (边距不换算)。因此本文件所有「设计尺寸」以 mm 书写便于阅读,输出前用 PT 换算为 pt。
PT = 2.834645669 # 1mm = 2.834645669pt72/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.5mmmodel 列宽 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 不换算 marginpageFormat=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),映射到元素 heightreportbro 线条粗细由 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),
}

View 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 抛 EmptyBoxErrorCLI 给出清晰提示
- 中文通过 additional_fonts 注册 simhei.ttf 渲染(见 core/fonts.py
- 预留最多 8 行明细;超过可在 _build_template.MAX_ROWS 与 transform.MAX_ROWS 同步调大

View 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;

View 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_atdatetime/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