马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?站点注册
×
PBIDEA:用 uo_pbidea_report 生成三大财务报表(资产负债表 / 利润表 / 现金流量表 实机跑通全解)
阅读说明
1. 适用版本:PowerBuilder 10 与 12.5 均可(本文示例已在 PB12.5 的 PBVM 上真机跑通);PBIDEA 版本源为 D:\PBAI\UPDATE\10 与 D:\PBAI\UPDATE\125
2. 支持数据库:本文示例不依赖数据库——报表数据在代码中给定,并由程序自己复算勾稽,便于对照。真实项目里把数据来源换成 uo_database/uo_json 即可
3. 操作系统与环境要求:Windows 7+;需要 PbIdea.dll(含报表导出,见第三节的版本核对)与 websuite.pbl 同源;页面单位用 mm,A4 = 210×297
4. 难度系数:★★★★☆(四星:需要一点坐标推演,但不需要图形学基础)
5. 文中所有 API 均逐条对照片本 uo_pbidea_report.sru / uo_pbidea_report_page.sru 原型,未凭印象编写;所有「实测」结论都附了可复现的运行输出
一、这次学的是什么
手头有两份新材料:
- PbIdea报表开发文档.md:PBIDEA 报表组件的开发文档,讲清了分层架构、元素类型、样式与资源机制、表达式引擎、模板填充流程,以及 14 条注意事项。
- D:\PBAI\UPDATE\10\export_20260917:最新一版导出源码。其中 websuite.pbl 下有 uo_pbidea_report.sru(169 行)、uo_pbidea_report_page.sru(96 行);pbjson.pbl 下有官方演示 uo_tabpage_report_demo.sru(5050 行),内含过程式、对象式、体温单三个完整范例。
学完之后的结论很直接:这是 PB 生态里少见的「用代码画报表」的方案,而且它对财务表格这种「格子多、要勾稽、要能导 PDF」的场景特别合适。 所以我用它把三大财务报表(资产负债表、利润表、现金流量表)完整画了出来,并让程序自己算平勾稽关系。
本文就是这次学习的结果说明与汇报,附多场景实例与实测踩坑清单。
二、组件全景
2.1 分层结构
- PowerBuilder 应用层
- │ create uo_pbidea_report ← 报表文档对象(一份报表 = 一个 job)
- │ job.CreatePageObj() ─────────────► uo_pbidea_report_page ← 单页对象(对象式写法)
- ▼
- PbIdea.dll(system library 别名映射)
- │ reportCreate / reportAddText / reportSave / reportCreatePageObj ...
- ▼
- pbidea::ReportGenerator(C++ 核心)
- │ - 持有 styles / resources / pages
- │ - XML ⇄ 内存对象 双向序列化
- │ - 表达式求值(pbidea::evaluate)
- │ - 模板填充(ParseReportTemplate)
- ▼
- 渲染输出:cairo 绘制 → Print / Preview / ExportPDF / ExportImage
复制代码
2.2 两个对象,两种写法
| 对象 | 角色 | 要点 | | uo_pbidea_report | 报表文档(job) | 一份报表的总入口。创建/打开/关闭、定义样式与资源、管理页面、添加元素、打印/预览/导出、模板填充。一个实例对应底层一个 ReportGenerator | | uo_pbidea_report_page | 单页对象 | 由 job.CreatePageObj() 创建,封装一个 PageElement。在它上面加元素不必再传 pageId |
两种写法完全等价,二选一即可:
- // 过程式:每个调用都传 pageId
- ulong pageId = job.CreatePage(210, 297)
- job.addtext(pageId, 10, 8, "资产负债表", "bs_title")
- // 对象式:页对象自带 pageId
- uo_pbidea_report_page page = job.CreatePageObj(210, 297)
- page.addtext(10, 8, "资产负债表", "bs_title")
- destroy page // 用完即销毁
复制代码
过程式的 pageId 是个 ulong,但它的本质是底层 PageElement* 裸指针——实测 GetPageId(2) 返回 8282880 / 101454104(两次运行不同),印证了它是指针而不是稳定编号。所以:它只在当前 job 生命周期内有效,不能持久化,Open 之后必须用 GetPageId(pageNo) 重新取。
三、★开工前必做的一件事:先确认 PbIdea.dll 的版本
这一节放在最前面,因为它是我这次踩到的最贵的一个坑。
报表这套 API 是后来加进 PbIdea.dll 的。同一个仓库里可能同时躺着好几个版本的 PbIdea.dll,它们的文件名完全一样,但导出表差别巨大。我直接读了 PE 导出表来对比:
| PbIdea.dll 位置 | 文件大小 | 总导出 | report* | reportpage* | units_* | | UPDATE\10\PbIdea.dll | 17,978,368 | 2545 | 40 | 12 | 7 | | UPDATE\125\PbIdea.dll | 17,978,368 | 2545 | 40 | 12 | 7 | | UPDATE\125\runtest\PbIdea.dll | 15,122,944 | 2201 | 0 | 0 | 0 | | pblit_test\PbIdea.dll(旧) | 17,892,864 | 2520 | 25 | 0 | 0 |
关键差异:
- runtest 里那份根本没有报表模块(0 个 report*),拿它跑报表会直接找不到函数。
- pblit_test 里那份只有 25 个 report*,缺 reportCreatePageObj、reportSetName、reportGetJsonDataTemplate,以及全部 12 个 reportpage* 系列导出。也就是说:对象式写法、元素命名、模板骨架、页对象析构(reportpageDestroy)全都会失败。
- UPDATE\10 与 UPDATE\125 下的两份是同版本(40 个 report* + 12 个 reportpage* + 7 个 units_mm2* 换算),这才是本次示例使用的版本。
一个顺手可用的自查命令(PowerShell):
- # 列出 DLL 里所有 report* 导出,数量对不上就说明版本过旧
- dumpbin /exports PbIdea.dll | Select-String "report"
复制代码
还有一个更容易上当的地方:websuite.pbl 本身也有版本之分。同样叫 websuite.pbl:
| 文件 | 大小 | 在 PB12.5 下 pbl import 引用它 | | UPDATE\10\websuite.pbl | 4,611,584 | 报 C0101: Referenced object uo_pbidea_report is out of date, must be converted.(这是 PB10 格式的库) | | UPDATE\125\websuite.pbl | 4,504,064 | 正常,0 错误 |
所以完整的前提是:PbIdea.dll 用新版,websuite.pbl 用与你的 PB 大版本匹配的那一份。两者不配套,报错信息会把你引到完全错误的方向上去(我一开始以为是自己的代码写错了)。
四、API 全貌:29 个方法名 / 77 个重载
uo_pbidea_report 的 prototypes 段一共 29 个方法名、77 条重载声明,全部是 PbIdea.dll 的别名映射。按功能分组如下:
| 分组 | 方法(重载数) | 说明 | | 生命周期 | Create(2) / Open(2) / Close(1) / GetXML(1) | Create(units, title[, isTemplate]);Open 可吃 XML 文件路径、以 < 开头的 XML 内容串、zip 文件路径或 blob | | 保存 | save(2) | save(xmlfile)、save(zipfile, compress) | | 输出 | Print(1) / ExportPDF(2) / ExportImage(2) / Preview(3) | ExportPDF 另有返回 blob 的重载;Preview(parentWin, modal) 的 modal=false 可多开 | | 页面 | GetPageCount(1) / CreatePage(2) / CreatePageObj(2) / GetPageId(1) | CreatePage 返回裸指针,CreatePageObj 返回页对象 | | 样式/资源 | addStyle(2) / addResource(5) | 都返回 id;addResource 支持 blob/文件、可指定 id、可指定 resType(0 图片/1 文本) | | 元素·文本 | addtext(14) | 7 条 string 版 + 7 条 blob(UTF-8) 版,从「只给坐标+文本」到「全参数含对齐/字色/加粗/斜体/背景色」 | | 元素·其他 | addImage(4) / addRect(4) / addLine(3) / addCircle(4) / addChart(2) / addCode(2) | addCircle 的 x,y 是圆心,rx/ry 是半径;addCode 画条码/二维码 | | 命名 | setName(1) | 给元素起名,供 ${字段名} 引用 | | 模板 | beginRepeat(1) / endRepeat(1) / fillTemplate(9) / GetJsonDataTemplate(1) | fillTemplate 支持「单参(用当前报表为模板)」与「双参(XML 模板 + JSON 数据)」,每种又分文件路径 / blob / uo_json | | 工具 | chart(1) / color2string(1) | chart 把图表 JSON 单独渲染成图片 blob;color2string(color, convType) 转 #RRGGBB / rgb() / 十进制 |
uo_pbidea_report_page 则是同名同参、去掉首参 pageId 的镜像:pageId()、addtext(14)、addImage(4)、addRect(4)、addLine(3)、addCircle(4)、addChart(2)、addCode(2)、setName(1)、beginRepeat(1)、endRepeat(1),外加一个私有 DestroyPage()(在 destructor 里自动调用)。
元素方法的返回值都是 ulong(元素句柄,0 表示失败)。这个句柄正是 setName 的第二个参数。
五、单位、坐标与「画表格」的思路
Create(units, title) 的第一个参数决定整份报表所有坐标数值的单位:mm / cm / inch / pt / px / pb。本文全部用 mm。
思路很清楚:A4 是 210×297(mm),你就当自己在往一张 210×297 的方格纸上摆方块。 每个元素给出左上角坐标(circle 给圆心)和宽高,剩下的交给引擎。
画一张财务表格,本质是三步:
- 先定列宽:把 190mm 的内容区切成「项目 / 行次 / 金额」几段;
- 再定行高:rh 给数据行,hh 给表头;
- 最后循环:第 i 行的横线画在 表头底 + i×rh,文字基线画在 表头底 + (i-1)×rh + 偏移。
下面三张报表的坐标全部按这个套路算出来(第九节给出实际公式与实测 XML 对照)。
六、样式(style)与资源(resource)
6.1 样式:一个 uo_map 反复改,addStyle 存的是当时的快照
- uo_map lm
- lm = create uo_map
- lm.Set("font_name", "微软雅黑")
- lm.Set("font_size", "18")
- lm.Set("bold", "1")
- lm.Set("align", "center")
- job.addStyle(lm, "bs_title") // 保存此刻快照
- lm.Set("font_name", "宋体")
- lm.Set("font_size", "9")
- lm.Set("bold", "0")
- lm.Set("align", "left vcenter")
- job.addStyle(lm, "bs_body") // 同一个 map,只改了几个键
- destroy lm
复制代码
这是我实测确认的机制:同一个 uo_map 反复 Set 再 addStyle,每次存进去的是当时那一份属性。资产负债表 8 个样式就是这么定义出来的,不用 create 8 个 map。
序列化后长这样:
- <style id="bs_num_b" align="right vcenter" font_name="Arial" bold="1" font_size="9" />
- <style id="bs_title" align="center" font_name="微软雅黑" bold="1" font_size="18" />
- <style id="bs_body" align="left vcenter" font_name="宋体" bold="0" font_size="9" />
- ...
复制代码
⚠️ 注意样式在 XML 里的顺序和你的插入顺序不一致——底层用无序哈希表存样式。文档第 11 节专门提过这一点,我这次拿到了实证:
- 插入顺序: bs_title, bs_body, bs_bold, bs_head, bs_num, bs_num_b, bs_num_c, bs_foot
- XML 顺序 : bs_num_b, bs_title, bs_body, bs_bold, bs_head, bs_foot, bs_num, bs_num_c
复制代码
样式集合完全相同,只是排列不同。做 XML 文本 diff 时别被这个顺序吓到。
引用的三种写法:
- job.addtext(pageId, x, y, "正文", "bs_body") // 1) 单个样式 id
- job.addtext(pageId, x, y, "正文", "ap_font" + " " + "ap_space") // 2) 多样式空格叠加
- job.addtext(pageId, x, y, "正文", "font_name=SimSun;font_size=12;align=left vcenter") // 3) 内联键值串
复制代码
应用规则是「只补全、不覆盖」:元素自己显式设过的属性优先级更高,样式只填它没定义的那些。
6.2 资源:把图片内嵌进报表,报表从此自包含
- string ls_res
- ls_res = job.addResource("ap_logo.png", "ap_logo") // 指定 id
- job.addImage(pageId, 10, 52, 12, 12, "res:" + ls_res)
复制代码
实测 XML:
- <resource id="ap_logo" type="image" md5="1726a236c307b640a08b5411b3abc720">data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAg...</resource>
- <image id="7" x="10.0" y="52.0" width="12.0" height="12.0" src="res:ap_logo" />
复制代码
引用的确是 src="res:<资源id>",资源以 base64 内嵌,类型自动识别(PNG/BMP → image,SVG → svg)。代价是 XML 会变大,好处是这份报表可以脱离原图单独分发。 二维码要嵌 logo 时同理:options="...;logo=" + ls_res + ";..."。
七、占位符:两个求值阶段(这是模板报表的地基)
文本里可以写 ${表达式},由底层表达式引擎求值。它分两个完全不同的阶段:
| 阶段 | 触发时机 | 解析什么 | 不解析的怎么办 | | 模板填充 | fillTemplate(...) | JSON 数据字段 ${name}、循环序号 ${index()} | 原样保留 | | 渲染输出 | Print / Preview / ExportPDF / ExportImage | ${now()}、${today()}、${pageNo()}、${pageCount()}、${unit()}、命名元素引用 | 原样输出 |
内置函数(大小写不敏感):now([fmt])、today([fmt])、time([fmt])、pageNo()、pageCount()、unit()、index([offset])、concat(a,b,...)、repeat(str,n)、trim(s)、default(field,fallback)。
还有一条很实用的特性:setName 命名过的元素,可以在别的文本里用 ${元素名} 引用,而且会递归求值(深度上限 8,防自引用)。我们实测的这行就是把命名引用、单位、页号混在一起:
- ulong ele = job.addtext(pageId, 20, 14, "标题文本(命名后可在别处引用)", "黑体", 14, 170, 12, &
- "center vcenter", RGB(60,60,160), false, true, RGB(192,192,192))
- job.setName(pageId, ele, "ap_title")
- job.addtext(pageId, 10, 42, "${ap_title} / ${unit()} / ${pageNo()}/${pageCount()}", "ap_font")
复制代码
导出的 XML 里可以看到命名确实写进了属性、占位符原样保留(等渲染期再算):
- <text id="4" name="ap_title" fill="#c0c0c0" x="20.0" y="14.0" font_name="黑体" font_size="14.0" width="170.0" height="12.0" align="center vcenter" color="#3c3ca0">标题文本(命名后可在别处引用)</text>
- <text id="6" style="ap_font" x="10.0" y="42.0">${ap_title} / ${unit()} / ${pageNo()}/${pageCount()}</text>
复制代码
八、模板报表:骨架 → 填充 → 展开
模板报表 = 带占位符与循环区的报表,可以用不同 JSON 反复填充生成多份报表。
8.1 制作模板
- job.Create("mm", "{reportTitle}", true) // 第三个参数 isTemplate = true
- job.addtext(pageId, 10, 10, "{header}", "tp_body")
- job.addtext(pageId, 10, 22, "{title}", "tp_title")
- job.beginRepeat(pageId, "detail", 8) // 绑定 JSON 字段 detail,行高 8
- job.addtext(pageId, 10, 60, "{index(1)}", "tp_body")
- job.addtext(pageId, 20, 60, "{name}", "tp_body")
- job.addtext(pageId, 80, 60, "{amount}", "tp_body")
- job.endRepeat(pageId)
- job.addtext(pageId, 10, 285, "第 {pageNo()} 页 共 {pageCount()} 页", "tp_body")
复制代码
注意模板模式下写的是 {字段}(不带 $),序列化时会自动补成 ${字段}(已经是 ${...} 的不会重复加)。
8.2 取骨架
- string ls_skel = job.GetJsonDataTemplate()
复制代码
实测输出(352 字符,节选):
- {
- "reportTitle": "",
- "pages": [
- {
- "header": "",
- "title": "",
- "detail": [
- {
- "index(1)": "",
- "name": "",
- "amount": ""
- }
- ]
- }
- ]
- }
复制代码
骨架是一份嵌套 JSON,不是 JSON-Pointer 路径清单(我一开始按 /pages/0/detail/0/name 去搜骨架文本,当然搜不到)。但填数据时用的是路径写法:
- uo_json js
- js = create uo_json
- js.set("reportTitle", "模板报表演示")
- js.set("/pages/0/header", "费用明细模板 - 2026年08月")
- js.set("/pages/0/detail/0/name", "差旅费")
- js.set("/pages/0/detail/0/amount", "1200.00")
- // ... 第 2、3 行同理
- lb_ok = job.fillTemplate(job.GetXML(), js) // 双参版:XML 模板 + JSON 数据
复制代码
8.3 填充后的 XML:循环真的展开了
fillTemplate 返回 true 之后,<repeat> 节点被展平。实测产物(diag_tpl.xml,只留关键行):
- <text id="2" style="tp_body" x="10.0" y="10.0" content="费用明细模板 - 2026年08月" />
- <text id="3" style="tp_title" x="10.0" y="22.0" content="部门费用明细表" />
- <text id="9" style="tp_body" x="10.0" y="60.0" content="1" />
- <text id="10" style="tp_body" x="20.0" y="60.0" content="差旅费" />
- <text id="11" style="tp_body" x="80.0" y="60.0" content="1200.00" />
- <text id="12" style="tp_body" x="10.0" y="68.0" content="2" />
- <text id="13" style="tp_body" x="20.0" y="68.0" content="办公费" />
- <text id="14" style="tp_body" x="80.0" y="68.0" content="860.00" />
- <text id="5" style="tp_body" x="10.0" y="76.0" content="3" />
- <text id="6" style="tp_body" x="20.0" y="76.0" content="通讯费" />
- <text id="7" style="tp_body" x="80.0" y="76.0" content="430.00" />
- <text id="8" style="tp_body" x="10.0" y="285.0">第 ${pageNo()} 页 共 ${pageCount()} 页</text>
复制代码
三处细节全部对上:
- ${index(1)} 变成了 1 / 2 / 3(从 1 起,不是 0);
- y 按 row_height=8 递增:60 → 68 → 76,与声明的行高严丝合缝;
- 最后一行 ${pageNo()} 仍然保留——证明「数据变量在填充期落实、运行时变量留到渲染期」这条两阶段规则是真的。
九、三大财务报表实战
先给结论:三张报表都画出来了,而且勾稽关系是程序自己复算的(不是我手填一个「合计」了事)。
9.1 通用布局参数(全部单位 mm)
| 报表 | 内容区 | 列定义(左→右) | 行高 | 表头带 | | 资产负债表 | x 10~200,双栏各 95 宽 | 项目 47.5 / 行次 19 / 金额 28.5 | 7.0 | 7.0 | | 利润表 | x 10~200 | 项目 100 / 行次 20 / 本月数 35 / 累计数 35 | 11.0 | 8.0 | | 现金流量表 | x 10~200 | 项目 122 / 行次 18 / 本期 25 / 上期 25 | 7.4 | 7.6 |
三张表的行定位公式完全一致:
- 第 i 行底线 y = 表头底 + i × rh
- 第 i 行文字基线 y = 表头底 + (i-1) × rh + 偏移 (偏移约 2.2~3.0)
复制代码
以现金流量表为例:表头从 y=30 起、高 7.6,所以表头底 = 37.6;31 行 × 7.4 = 229.4,最后一条横线落在 y=267.0,稳稳装进 A4 的 297mm 里。文字列 x 分别是 11 / 132 / 150 / 175。
9.2 场景一:资产负债表(会小企01表)
难点:它是一张左右双栏表——左边资产 30 行、右边负债和所有者权益 26 行 + 6 行留白,一共 32 行;两栏共用同一组横线,中间还要 7 条竖线。
做法:一行循环同时画左右两栏。
- ml = 10 : cw = 190 : half = 95 : cw_nm = 47.5 : cw_rw = 19 : rh = 7 : hh = 7
- lul_page = job.CreatePage(210, 297)
- job.addtext(lul_page, ml, 8, "资产负债表", "bs_title")
- job.addtext(lul_page, ml + cw - 25, 11, "会小企01表", "bs_num")
- ld_ty = 30 // 表头带顶
- job.addline(lul_page, ml, ld_ty, ml + cw, ld_ty, 0.5, RGB(0,0,0))
- job.addtext(lul_page, ml, ld_ty + 1.5, "资产", "bs_head")
- job.addtext(lul_page, ml + cw_nm, ld_ty + 1.5, "行次", "bs_head")
- job.addtext(lul_page, ml + cw_nm + cw_rw, ld_ty + 1.5, "期末余额", "bs_head")
- job.addtext(lul_page, ml + half, ld_ty + 1.5, "负债和所有者权益", "bs_head")
- job.addtext(lul_page, ml + half + cw_nm, ld_ty + 1.5, "行次", "bs_head")
- job.addtext(lul_page, ml + half + cw_nm + cw_rw, ld_ty + 1.5, "期末余额", "bs_head")
- ld_ty = ld_ty + hh
- job.addline(lul_page, ml, ld_ty, ml + cw, ld_ty, 0.5, RGB(0,0,0))
- ld_y = ld_ty
- for i = 1 to 32
- ld_y = ld_y + rh
- job.addline(lul_page, ml, ld_y, ml + cw, ld_y, 0.5, RGB(0,0,0))
- ld_bx = ld_y - rh + 2.2
- if ab_a_sec[i] then // 小标题行(如「流动资产:」)
- job.addtext(lul_page, ml + 1, ld_bx, ab_a_nm[i], "bs_bold")
- else
- job.addtext(lul_page, ml + 1, ld_bx, ab_a_nm[i], "bs_body")
- job.addtext(lul_page, ml + cw_nm, ld_bx, ab_a_rw[i], "bs_num_c")
- if ab_a_sum[i] then // 合计行加粗、不加行次
- job.addtext(lul_page, ml + cw_nm + cw_rw, ld_bx, of_money(ad_a_v[i]), "bs_num_b")
- else
- job.addtext(lul_page, ml + cw_nm + cw_rw, ld_bx, of_money(ad_a_v[i]), "bs_num")
- end if
- end if
- // 右栏:同一套 y,x 偏移 half
- ...
- next
复制代码
实测导出的 XML 与公式完全吻合(节选):
- <page id="1" pageNo="1" width="210.0" height="297.0" padding="0 0 0 0">
- <text id="2" style="bs_title" x="10.0" y="8.0" content="资产负债表" />
- <text id="3" style="bs_num" x="175.0" y="11.0" content="会小企01表" />
- <line id="7" stroke_width="1.0" stroke="#000000" x1="10.0" y1="30.0" x2="200.0" y2="30.0" />
- <text id="8" style="bs_head" x="10.0" y="31.5" content="资产" />
- <text id="9" style="bs_head" x="57.5" y="31.5" content="行次" />
- <text id="10" style="bs_head" x="76.5" y="31.5" content="期末余额" />
- <text id="11" style="bs_head" x="105.0" y="31.5" content="负债和所有者权益" />
- <text id="12" style="bs_head" x="152.5" y="31.5" content="行次" />
- <text id="13" style="bs_head" x="171.5" y="31.5" content="期末余额" />
- <text id="16" style="bs_bold" x="11.0" y="39.2" content="流动资产:" />
- <text id="17" style="bs_bold" x="106.0" y="39.2" content="流动负债:" />
- <text id="19" style="bs_body" x="11.0" y="46.2" content="货币资金" />
- <text id="20" style="bs_num_c" x="57.5" y="46.2" content="1" />
- <text id="21" style="bs_num" x="76.5" y="46.2" content="856,420.50" />
- <text id="22" style="bs_body" x="106.0" y="46.2" content="短期借款" />
- <text id="23" style="bs_num_c" x="152.5" y="46.2" content="31" />
- <text id="24" style="bs_num" x="171.5" y="46.2" content="500,000.00" />
- ...
复制代码
勾稽复算(写在程序里,不是手填):
- ld_ca = ad_a_v[2..10] 累加 + ad_a_v[15] // 流动资产合计
- ld_nca = ad_a_v[18]+ad_a_v[19]+ad_a_v[22..30] // 非流动资产合计
- ld_cl = ad_l_v[2..11] 累加 // 流动负债合计
- ld_ncl = ad_l_v[14..17] 累加 // 非流动负债合计
- ld_eq = ad_l_v[21..24] 累加 // 所有者权益合计
- if Round(ld_tle,2) = Round(ld_ta,2) then // 资产总计 == 负债和所有者权益总计
- ls_r = ls_r + ";BAL_TIE=OK"
- end if
复制代码
实测结果:CA=2083550.80 NCA=1671000.00 TA=3754550.80 CL=1364550.80 NCL=300000.00 TL=1664550.80 EQ=2090000.00 TLE=3754550.80,且 CA_TIE / NCA_TIE / CL_TIE / EQ_TIE / BAL_TIE 五项全 OK。
9.3 场景二:利润表(会小企02表)
12 行 × 4 列(项目 / 行次 / 本月数 / 本年累计数),结构比资产负债表简单,但加粗规则不同:只有三个「小计级」行加粗——营业利润(第 7 行)、利润总额(第 10 行)、净利润(第 12 行),其余用普通样式。行内首尾两个「一、营业收入」「四、净利润」还要靠左对齐。
- for i = 1 to 12
- ld_y = ld_y + rh
- job.addline(lul_page, ml, ld_y, ml + cw, ld_y, 0.5, RGB(0,0,0))
- ld_bx = ld_y - rh + 3
- job.addtext(lul_page, ml + c1, ld_bx, ls_rw[i], "is_num_c")
- if i = 7 or i = 10 or i = 12 then
- job.addtext(lul_page, ml + 1, ld_bx, ls_nm[i], "is_bold")
- job.addtext(lul_page, ml + c1 + c2, ld_bx, of_money(ad_c1[i]), "is_num_b")
- job.addtext(lul_page, ml + c1 + c2 + c3, ld_bx, of_money(ad_c2[i]), "is_num_b")
- else
- job.addtext(lul_page, ml + 1, ld_bx, ls_nm[i], "is_body")
- job.addtext(lul_page, ml + c1 + c2, ld_bx, of_money(ad_c1[i]), "is_num")
- job.addtext(lul_page, ml + c1 + c2 + c3, ld_bx, of_money(ad_c2[i]), "is_num")
- end if
- next
复制代码
三级勾稽:
- 营业利润 = 营业收入 - 营业成本 - 营业税金及附加 - 销售费用 - 管理费用 - 财务费用
- 利润总额 = 营业利润 + 营业外收入 - 营业外支出
- 净利润 = 利润总额 - 所得税费用
复制代码
实测:OP=38220.00/337000.00 TP=42020.00/369200.00 NP=31515.00/276900.00,四项断言(含「所得税 = 利润总额 × 25%」)全 OK。
财务上的一点说明:这里用的所得税公式是「利润总额 × 25%」,只是为了制造一个能自洽的数;真实企业要考虑纳税调整与递延所得税,报表本身不关心你怎么算出来,它只负责画。
9.4 场景三:现金流量表(会小企03表)
这张最麻烦:31 行,其中夹着 3 个「活动小标题」行(经营活动 / 投资活动 / 筹资活动),小标题行只有项目名、没有行次和金额,还得跨列占位。所以循环体里要按行型三分支。
- for i = 1 to 31
- ld_y = ld_y + rh
- job.addline(lul_page, ml, ld_y, ml + cw, ld_y, 0.5, RGB(0,0,0))
- ld_bx = ld_y - rh + 2.2
- job.addtext(lul_page, ml + c1, ld_bx, ls_rw[i], "cf_num_c")
- if i = 1 or i = 11 or i = 20 then // 三个活动小标题行
- job.addtext(lul_page, ml + 1, ld_bx, ls_nm[i], "cf_group")
- else
- if i = 4 or i = 9 or i = 10 or i = 15 or i = 18 or i = 19 or i = 23 or i = 27 or i = 28 or i = 29 or i = 31 then
- // 小计 / 净额 / 净增加额 / 期末余额行:加粗
- job.addtext(lul_page, ml + 1, ld_bx, ls_nm[i], "cf_bold")
- job.addtext(lul_page, ml + c1 + c2, ld_bx, of_money(ad_c1[i]), "cf_num")
- job.addtext(lul_page, ml + c1 + c2 + c3, ld_bx, of_money(ad_c2[i]), "cf_num")
- else
- job.addtext(lul_page, ml + 1, ld_bx, ls_nm[i], "cf_body")
- job.addtext(lul_page, ml + c1 + c2, ld_bx, of_money(ad_c1[i]), "cf_num")
- job.addtext(lul_page, ml + c1 + c2 + c3, ld_bx, of_money(ad_c2[i]), "cf_num")
- end if
- end if
- next
复制代码
勾稽链(每一环都实测):
- 经营活动现金流入小计 = 销售收到 + 其他收到
- 经营活动现金流出小计 = 购原材料 + 付职工薪酬 + 付税费 + 付其他
- 经营活动净额 = 流入小计 - 流出小计
- 投资活动净额 = 流入小计 - 流出小计
- 筹资活动净额 = 流入小计 - 流出小计
- 现金及现金等价物净增加额 = 经营净额 + 投资净额 + 筹资净额
- 期末现金余额 = 期初现金余额 + 净增加额
复制代码
实测:IN=1318900.00/1128800.00 OUT=1132500.00/986000.00 OPNET=186400.00/142800.00 NET=38500.00/34100.00 END=856420.50/817920.50,四项断言全 OK。
9.5 跨表校验:三张表要能对上
这是我觉得最值得做的一步——报表能画对,不代表数字能对上:
- 现金流量表「期末现金及现金等价物余额」= 资产负债表「货币资金」:856,420.50 == 856,420.50 ✔
- 现金流量表本期「期初余额」= 上期「期末余额」:817,920.50 == 817,920.50 ✔
程序里就是一句:
- if Round(ld_end1,2) = 856420.50 then
- ls_r = ls_r + ";CROSS_BS=OK" // 与资产负债表货币资金对账
- else
- ls_r = ls_r + ";CROSS_BS=FAIL"
- end if
复制代码
9.6 实测输出
.pbtest 里把四组探针的输出写进诊断文件,20 项硬断言全过:
- API[tags=8-1-3-1-1-1-2;len=3115;pages=2;style=4;page=2;res=1;pages=2;resid=ap_logo;named=1;resuse=1;save=1;savezip=0;savezip2=0;xml_exists=1;pdf=1;png=1;zip_exists=1;zip2_exists=1;pdf_exists=1;reopen_xml=1;pages_after_open=2;pageid_2=8282880;reopen_zip=1;pages_after_zip=2]
- BS[len=18800;pages=1;style=8;page=1;res=0;CA=2083550.80;NCA=1671000.00;TA=3754550.80;CL=1364550.80;NCL=300000.00;TL=1664550.80;EQ=2090000.00;TLE=3754550.80;CA_TIE=OK;NCA_TIE=OK;CL_TIE=OK;EQ_TIE=OK;BAL_TIE=OK]
- IS[len=7202;pages=1;style=8;page=1;res=0;OP=38220.00/337000.00;TP=42020.00/369200.00;NP=31515.00/276900.00;OP_TIE=OK;TP_TIE=OK;NP_TIE=OK;TAX_TIE=OK]
- CF[len=14292;pages=1;style=8;page=1;res=0;IN=1318900.00/1128800.00;OUT=1132500.00/986000.00;OPNET=186400.00/142800.00;NET=38500.00/34100.00;END=856420.50/817920.50;IN_TIE=OK;OUT_TIE=OK;OPNET_TIE=OK;NET_TIE=OK;END_TIE=OK;CROSS_BS=OK;CROSS_PREV=OK]
- TPL[tpl_repeat=2;tpl_desc=1;tpl_ph=8;skel_len=352;skel_ok=1-1-1;fill_ret=1;fill_repeat=0;fill_name=1-1;fill_left=0-0;fill_keep=1]
- ASSERT[API_TAGS_OK API_SETNAME_OK API_RES_OK API_RESUSE_OK API_SAVE_OK API_ZIPFILE_OK API_PDFFILE_OK API_FILE_OK API_OPEN_XML_OK API_OPEN_ZIP_OK API_PAGES_OK BS_TIE_OK IS_TIE_OK CF_TIE_OK TPL_REPEAT_OK TPL_SKEL_OK TPL_FILLRET_OK TPL_FLAT_OK TPL_FLAT_OK TPL_DATA_OK TPL_KEEP_OK]
- OKCOUNT=20/20
复制代码
产物(都是真机跑出来的文件):
| 文件 | 大小 | 说明 | | diag_api.xml | 3,313 | 全类型探针报表,2 页 | | diag_api.zip | 1,540 | save(zip, true) 产物,内部条目名 pbidea_report.xml | | diag_api.pdf | 68,242 | ExportPDF 产物 | | API探测报表_1.png / _2.png | 34,145 / 6,030 | ExportImage("png", ".") 产物 | | diag_bs.xml | 19,606 | 资产负债表:1 页 / 8 样式 / 196 个文本 + 41 条线 | | diag_is.xml | 7,562 | 利润表:1 页 / 8 样式 / 60 个文本 + 19 条线 | | diag_cf.xml | 15,300 | 现金流量表:1 页 / 8 样式 / 130 个文本 + 38 条线 | | diag_tpl.xml | 1,318 | 模板填充结果,repeat 已展开 |
十、其它场景实例
10.1 官方 demo 的体温单(of_make_twd)
uo_tabpage_report_demo.sru 里有一个完整度很高的范例:A4 体温单。它演示了两件在本篇里很关键的事:
- lr = create uo_pbidea_report
- if not lr.Create("mm", "A4体温单", false) then
- destroy lr
- return lr
- end if
- ll_page = lr.CreatePage(210, 297)
- if ll_page = 0 then // ★ 返回 0 说明建页失败,要处理
- lr.Close()
- destroy lr
- return lr
- end if
- ld_step = 22.0 / 6.0 // 一天 6 个时点,每格 22/6 mm
复制代码
- CreatePage 返回 0 要当场判断(文档里写「0 表示失败」,demo 里就是这么防的);
- 列宽交给样式:lm.set("width", "3.666667") 就是 22/6,每个小时格的宽度写进样式 hour,后面全部元素共用;
- 表头、患者信息、网格线、曲线区全部用 addRect / addLine / addText 手绘,把「表格 + 折线图」这类纸质单据的版式完全复刻出来。
10.2 对象式写法(of_make_sample_page)
同一个 demo 里另有一份对象式版本,产物与过程式版本渲染结果一致。差别只有三处:样式在 XML 里的顺序不同(哈希表),以及图片与图表元素多带一个显式的 scale="1"。
- uo_pbidea_report_page page
- int n
- for n = 1 to 2
- page = job.createPageObj(210, 297)
- page.addRect(10, 10, 190, 277, styleBox)
- page.addText(10, 6.5, "PBidea报表控件演示页", "宋体", 10)
- page.addText(175, 6.5, "${now()}", "Arial", 10)
- page.addChart(50, 180, 110, 60, ls_json)
- destroy page // 用完即销毁页对象(数据仍在 job 中)
- next
复制代码
10.3 费用明细类「一维表」——模板填充最划算
只要你的单据是「表头 + 若干行明细」(费用明细、采购单、对账单、花名册、工资条都属于这一类),模板报表远比手绘划算:版式做一次,之后每次只换 JSON。本文第八节的实测例子就是完整的费用明细表。
10.4 图表与码
- 图表 addChart:jsonData 支持 bar / line / curve / pie / donut,13 种配色皮肤(skin 0~13,含 TABLEAU_10、DARK、GOOGLE、PLOTLY),而且单个 series 可以覆盖主类型,实现柱线混合:
- {"type": "bar", "skin": 9, "title": "月度销售统计",
- "categories": ["一月","二月","三月","四月","五月","六月"],
- "series": [{"name": "实际销量", "data": [120,205,150,80,190,210]},
- {"name": "目标销量", "type": "curve", "data": [100,180,160,100,170,200]}]}
复制代码
- 二维码 / 条码 addCode:options 用 ; 或 : 或 , 分隔,type 可以写数字 zint ID(如 20 = Code128)或名称(QRCODE、DATAMATRIX,大小写不敏感):
- job.addCode(lul_page, 10, 160, 30, 30, "https://sybasebbs.com", "type=QRCODE;version=4;showhrt=false")
- job.addCode(lul_page, 50, 160, 40, 14, "B/N: 1234567890", "type=20;showhrt=true")
复制代码
- 图片 addImage:src 可以是文件路径、res:<资源id>、vfs:/...,也可以直接塞 blob(此时底层会自动登记成资源)。
十一、实测踩坑清单(14 条)
- websuite.pbl 版本必须与 PB 大版本匹配:UPDATE\10\websuite.pbl 在 PB12.5 下引用会报 C0101 Referenced object ... must be converted;换 UPDATE\125\websuite.pbl 立刻 0 错误。
- PbIdea.dll 必须含报表导出:旧版只有 25 个 report*,缺 reportCreatePageObj / reportSetName / reportGetJsonDataTemplate 和全部 reportpage*。文件名一样,内容不一样,先查导出表。
- save(zipfile, true) 返回 false,但 zip 其实写成功了:实测连续两次调用都返回 0(false),可 FileExists 为真、Open 能读回、内部条目名正是 pbidea_report.xml。这个重载的返回值不可信,请以文件是否生成为准。
- ExportImage 传 jpg/jpeg 时,内部仍按 PNG 编码,只是扩展名是 jpg。要真 JPEG 得自己转码。
- 导出图片的命名规则:多页时是 title_页码.ext,单页时是 title.ext。实测产物就是 API探测报表_1.png 与 API探测报表_2.png。
- pageId 是裸指针,不是稳定编号:GetPageId(2) 实测两次分别返回 8282880 和 101454104。Open 之后必须重新 GetPageId(pageNo)。
- 页对象销毁顺序:先 destroy page,再 job.Close() / destroy job。反了会让页对象指针悬空。(uo_pbidea_report 自身在 destructor 里会自动 Close()。)
- 销毁页对象不等于删页:DestroyPage() 只释放 PB 侧句柄,页面数据仍留在报表里——所以我先建页面 1 与页面 2、destroy page 之后,GetPageCount() 依然是 2,存盘再 Open 也还是 2 页。
- 直接参数传线宽会被取整:我传 0.5 的 addRect / addLine / addCircle,XML 里全部变成 stroke_width="1.0";而通过样式传的 0.3 原样保留(<style id="ap_box" ... stroke_width="0.3" />)。要精细线宽,走样式或内联样式串。
- fillTemplate 双参版的首参用 blob 最稳:文档里 string 形参既可能是「模板文件路径」也可能是「模板内容」,二义性明显;直接传 job.GetXML()(blob)走的是无歧义的 fillTemplate(blob xmlContent, uo_json json) 重载。
- GetJsonDataTemplate() 返回的是嵌套 JSON,不是路径清单。填数据时才用 /pages/0/detail/0/name 这种路径写法。
- 占位符大小写敏感:模板里写 {pageNo()},渲染期公式是 ${pageNo()}。如果写成 {PageNo()},序列化后就是 ${PageNo()},用 Pos() 去找 ${pageNo() 会找不到——这不是功能问题,是我一开始的断言写错了。
- 样式只补全不覆盖,且 XML 里的顺序不固定(底层是无序哈希表)。元素自己设过的属性优先。
- 长文本会写成子节点:实测同一份报表里两种形式都会出现——单位:元(4 字) 走 content 属性,编制单位:某某有限公司(11 个汉字)、一、经营活动产生的现金流量: 走 <text ...>内容</text> 子节点。如果你要写解析器,两种都要处理(Open 反序列化时两种都支持)。
另:表达式求值与模板节点处理都有递归深度上限 8,别写自引用字段或过深嵌套。
十二、交付物
随帖附件里是一份可直接编译运行的完整工程(含真机运行产物):
- src/nvo_report_demo.sru 验证对象(NVO,10 个函数:4 个探针 + 3 个格式化 + 2 个辅助 + 入口)
- repdemo.pbt 工程文件(liblist 指向 websuite.pbl)
- tests/repdemo.pbtest 隔离测试用例(20 项硬断言,不达标即 halt)
- ap_logo.png 32×32 PNG,用于资源内嵌验证
- diag_repdemo.txt 运行输出(含 ASSERT[...] 与 OKCOUNT=20/20)
- diag_api.xml / .zip / .pdf 全类型探针报表 / 压缩包 / PDF
- API探测报表_1.png / _2.png 导出图片产物
- diag_bs.xml / diag_is.xml / diag_cf.xml / diag_tpl.xml 三大报表与模板填充的 XML
- README.txt 环境要求、复现步骤、勾稽清单、已知实现细节
复制代码
三步复现:
- 1) 确认 PbIdea.dll 版本(见第三节),放到运行目录
- 2) pypower pbl import repdemo.pbl src/nvo_report_demo.sru --pb-version 125 --encoding gbk --pbt repdemo.pbt
- 3) pypower test tests/repdemo.pbtest --pb-version 125 --build-exe --pbt repdemo.pbt --dll-dir "<PB目录>" --run-dir <运行目录> --encoding gbk
复制代码
本次验证结果汇总(四绿):
| 检查项 | 结果 | | pbl import | imported: nvo_report_demo,0 错误 | | build rebuild --type full | 0 错误(仅 1 条 C0210 警告,来自 websuite.pbl 既有对象 uo_file,与本文代码无关) | | pypower test(PB12.5 PBVM 真机运行) | PASS,20/20 断言 | | 源文件编码/格式自检 | GBK + CRLF + 无 BOM,bad=0 | | API 核对自检 | 0 错误 / 0 提醒 |
十三、小结
- uo_pbidea_report 的能力边界比想象中大:从「画一个矩形」到「导 PDF / 出图片 / 模板批量套打」,一套 API 全覆盖,而且 XML 中间态是可以直接读、直接改、直接分发的。
- 对象式与过程式等价,选哪个只看可读性;页对象记得先 destroy。
- 三大财务报表完全可以用它做:坐标推演一次、样式定义一次,之后换数据就是换数组——现金流量表那种 31 行 + 3 个活动小标题的版式,循环体里三分支就够。
- 最值得花时间的是勾稽复算。报表能画对不等于数字对得上;把「资产=负债+权益」「期末现金=货币资金」这些等式写进程序,报表才真正可信。
- 两个前提务必先查:PbIdea.dll 的报表导出是否齐全、websuite.pbl 是否与 PB 大版本匹配。这两件事会决定你后面是在写代码,还是在猜错误。
有问题欢迎回帖,我会跟进补充。
本文 API 与行为均对照 uo_pbidea_report.sru(169 行) / uo_pbidea_report_page.sru(96 行) 原型、PbIdea报表开发文档.md 与 export_20260917 导出源码核实;运行数据来自 PB12.5 PBVM 真机执行,未实测部分已在文中明确标注。 |