☰
从数据模型到套打出票:房租水电费收据打印系统落地实践
2026/10/24 4:30:06 网站建设 项目流程

简介:面向房东、物业及小微企业收款人员的房租水电费收据打印工具,聚焦房租、押金、水费、电费、燃气费等日常收款场景,意在替代手写收据和Excel对账的繁琐流程。软件将收据管理、出租房管理、客户资料库与数据分析整合在一起,支持即输即打、单据编号自定义、金额自动计算与大小写转换、批量打印、按收据生成对账单,并可将数据或单据导出为Excel、图片、PDF,提供打印样式自定义及导入导出、数据备份恢复、U盘便携使用等功能。包体为zip压缩包,约3.39MB;上游未提供可核验的文件总数与主要文件类型明细,具体以实际包内内容为准。资源页显示已有292人浏览学习,适合需要规范开具房租水电收据、减少手工录入与对账差错的个人房东、公寓管理者和中小商户参考使用。

1. 房租水电费收据打印:把月底最脏的活压成一条命令

月底最后一天,二三十套房、四五十个租户的水电表数还散在两三个 Excel 和一堆聊天记录里,手写收据写到手腕发酸,写错一个字整张重来。房租水电费收据打印这件事,真正麻烦的地方不在"打印",而在把抄表数、租金、水电单价、上期欠费合成一张能直接撕给租户的两联票据,同时把编号、留底、跨月对账一起管住。

它不适合大型物业的全套计费系统,但对付几十套自持房源、每月集中开一次票的场景,比通用财务软件顺手得多。这篇按我实际落地的顺序走:先把数据模型立住,再解决算钱,再解决套打坐标,最后讲那些只有真打过错位票据才懂的坑。

2. 收据的数据底座:房间、租户、抄表、账单四张表怎么拆

2.1 房租水电费收据打印为什么不能只靠一张 Excel 模板

用 Excel 做邮件合并出收据,第一年确实够用。房源数量一过二十套,问题就一茬接一茬地冒出来:编号靠手填,两个人同时开单必然撞号;收据打废了重打一张,旧号是作废还是留着,表格里根本看不出来;租户中途退租,按天折算的租金在下个月对账时已经找不到计算依据;更麻烦的是历史数据——你想查某个房间过去十二个月的水电用量曲线,得手工把十二张表拼起来。

把数据拆成四张表的理由很朴素:房间和租户是相对稳定的事实,抄表读数和账单是按月追加的事件。稳定的事实改动少、可以放心做外键;按月追加的事件只允许插入、不允许覆盖,否则一旦算错,连回滚的基准都没有。账单和收据还要再拆一层——账单是可重算的业务结果,收据是已经交给租户的凭证,两者的生命周期完全不同。账单算错了可以作废重开,收据一旦递出去,只能标记作废后另开一张新号的,绝不能改数字。

这个拆分带来的直接好处是:出票失败、打印机卡纸、租户要求补打,都只是往收据表里插一条新记录,账单本身不动;月底对账时,"应收"看账单表,"已开票"看收据表,两者的差额就是还没送到租户手上的那部分工作量。

2.2 四张表的建表语句与字段取舍

SQLite 最省事,单文件、免安装、拷走就能换机器,和这类轻量工具的气质匹配。下面这套结构在生产里跑了两年多,改动很少。

-- 金额统一用 INTEGER 存"分",任何情况下不落 REAL CREATE TABLE room ( id INTEGER PRIMARY KEY, code TEXT NOT NULL UNIQUE, -- 房号,A-302,导入前统一大写去空格 area REAL NOT NULL DEFAULT 0, -- 建筑面积,只用于公摊分摊 rent_cent INTEGER NOT NULL, -- 月租金(分) status TEXT NOT NULL DEFAULT 'vacant' -- vacant / rented ); CREATE TABLE tenant ( id INTEGER PRIMARY KEY, room_id INTEGER NOT NULL REFERENCES room(id), name TEXT NOT NULL, phone TEXT, start_date TEXT NOT NULL, -- ISO 8601:2024-03-01 end_date TEXT, -- NULL 表示在租 deposit_cent INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE meter_reading ( id INTEGER PRIMARY KEY, room_id INTEGER NOT NULL REFERENCES room(id), kind TEXT NOT NULL, -- 'water' / 'electric' read_at TEXT NOT NULL, -- 抄表日 value REAL NOT NULL, -- 本期表读数 prev_value REAL, -- 上期读数冗余存一份,用于校验与追溯 source TEXT DEFAULT 'manual' -- manual / photo / import ); CREATE TABLE bill ( id INTEGER PRIMARY KEY, room_id INTEGER NOT NULL REFERENCES room(id), period TEXT NOT NULL, -- '2024-06' rent_cent INTEGER NOT NULL DEFAULT 0, water_cent INTEGER NOT NULL DEFAULT 0, elec_cent INTEGER NOT NULL DEFAULT 0, other_cent INTEGER NOT NULL DEFAULT 0, -- 卫生费、车位、滞纳金 total_cent INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'open', -- open / paid / void UNIQUE (room_id, period) -- 同一房间同一账期只能有一张有效账单 ); CREATE TABLE receipt ( id INTEGER PRIMARY KEY, bill_id INTEGER NOT NULL REFERENCES bill(id), serial TEXT NOT NULL UNIQUE, -- 收据号,全局唯一 printed_at TEXT, status TEXT NOT NULL DEFAULT 'valid' -- valid / reprint / void ); CREATE INDEX idx_bill_period ON bill(period); CREATE INDEX idx_receipt_bill ON receipt(bill_id);

几个字段的取值不是随手定的:

字段类型为什么这么设
rent_cent/total_centINTEGER 分浮点乘 100 会出现 12999.999999 这类值,取整方向一错就差一分
prev_valueREAL 冗余上期读数本该从上一行推导,但跨年、换表、换租户时推导会断
UNIQUE(room_id, period)约束防止重复算费时插出两张账单,月底对账直接翻倍
serialUNIQUE编号重复是收据类工具最致命的错,靠约束兜底
status文本枚举作废用状态位表达,不 DELETE,留痕比省空间重要

注意:不要用"是否已打印"来控制收据能否重打,用status加一张receipt_log表记流水。状态位丢失可以重建,流水丢了就真丢了。

2.3 三个必须提前定死的约束

编号唯一且不可复用。编号规则常见的是前缀 + 年月 + 四位流水的:SD2024060001。生成时不要用SELECT MAX(serial)+1,那是并发下的经典翻车点。要么单独建一张counter(period, next_no)表在事务里自增,要么直接依赖serial` 的 UNIQUE 约束做"插入失败就重试三次"。后者实现最省事:

import sqlite3, time def next_serial(conn, period: str, prefix: str = 'SD') -> str: """在事务里自增计数器;SQLite 的写锁是库级的,天然串行""" with conn: # with 块自动 commit / rollback conn.execute("INSERT OR IGNORE INTO counter(period, next_no) VALUES (?, 1)", (period,)) row = conn.execute("SELECT next_no FROM counter WHERE period=?", (period,)).fetchone() no = row[0] conn.execute("UPDATE counter SET next_no=? WHERE period=?", (no + 1, period)) return f"{prefix}{period.replace('-', '')}{no:04d}"

这里的关键是把"取号"和"占号"放在同一个写事务里,中途崩溃会整体回滚,不会出现号被吃掉的情况。如果业务允许跳号(作废的号保留空缺),把next_no的自增和收据插入也放进同一事务,失败就整笔重来。

抄表记录只追加。抄错了怎么办?插一条source='correct'的新记录,用read_at的时间戳排序取最新,而不是 UPDATE 旧行。原因是月底对账时经常有人问"这户上个月到底抄的是多少",改过的记录回答不了这个问题。

金额只存整数分。这条约束要从数据入口一直贯穿到打印,中间任何一次float * 100都是隐患。取整方向统一用四舍五入到分,不要一会儿int()截断一会儿round()。

2.4 从旧 Excel 导历史数据:一次性的迁移脚本

换工具最大的阻力从来不是功能,是老数据搬不过去。房源表通常是最规整的一张,先导它。

import re, sqlite3 from decimal import Decimal from openpyxl import load_workbook def to_cent(v) -> int: """任何输入先转字符串再进 Decimal,绕开 float 的二进制误差""" return int((Decimal(str(v)) * 100).quantize(Decimal('1'))) def import_rooms(xlsx_path: str, db_path: str) -> None: wb = load_workbook(xlsx_path, data_only=True) # data_only 取公式结果而不是公式文本 ws = wb.active conn = sqlite3.connect(db_path) ok = skip = 0 for row in ws.iter_rows(min_row=2, values_only=True): code, area, rent = row[0], row[1], row[2] if not code or rent in (None, ''): skip += 1 continue # 去空格、统一大写,避免 A-302 / a302 / A 302 在库里变成三个房间 code = re.sub(r'\s+', '', str(code)).upper() cur = conn.execute( "INSERT OR IGNORE INTO room(code, area, rent_cent) VALUES (?,?,?)", (code, float(area or 0), to_cent(rent)), ) ok += cur.rowcount conn.commit() conn.close() print(f"导入 {ok} 条,跳过 {skip} 条")

data_only=True这个参数容易被忽略:不加的话,单元格里如果是公式,读出来是=B2*C2这种字符串,Decimal直接抛异常。INSERT OR IGNORE配合code的 UNIQUE 约束,让脚本可以反复跑,跑第二遍不会插重复行,这对迁移期反复试错很友好。跑完一定要抽三条数据手动核对租金金额,尤其是表格里带千分位逗号的写法——"1,200"进Decimal会直接报错,宁可让脚本炸掉,也不要静默跳过。

3. 从抄表数字到应收金额:租金折算、阶梯水电和金额大写的写法

3.1 计费规则的三种常见形态与边界

几十套房源的计费规则听上去简单,真落到代码里,边界情况比规则本身多:

计费项常见规则容易漏的边界
月租金固定金额,按月收中途入住/退租按天折算;大小月天数不同
水费本期读数减上期读数,乘单价换表导致读数为 0;抄表跨月漏抄
电费阶梯计价或单一单价阶梯跨档时的分段计算;公摊电量按户/按面积
其他卫生费、车位、滞纳金滞纳金是否计入本金、是否参与下期滚动

按天折算这块最容易扯皮。行业里两种算法:按当月实际天数折算,或者统一按 30 天折算。前者更公平但每个月的日租金都不一样,后者好算但二月会出现"住满 28 天交整月"的争议。我一般选按实际天数,并在账单备注里把起始日 / 天数 / 当月天数三个数字打出来,收据上看得见,争议就少一半。

3.2 用 Decimal 把钱算准:计费函数的最小实现

from decimal import Decimal, ROUND_HALF_UP CENT = Decimal('0.01') def q(x: Decimal) -> Decimal: """所有金额出口统一走这个函数,保证取整方向一致""" return x.quantize(CENT, rounding=ROUND_HALF_UP) def rent_for_partial_month(month_rent: Decimal, days_used: int, days_in_month: int) -> Decimal: """中途入住/退租的租金折算,按当月实际天数""" if days_used <= 0: return Decimal('0.00') if days_used >= days_in_month: return q(month_rent) # 住满就按整月,不做除法,规避 1/31 的无限小数 return q(month_rent * Decimal(days_used) / Decimal(days_in_month)) def tiered_fee(usage: Decimal, tiers): """阶梯计价:tiers = [(档位上限, 单价), ...],最后一档上限写 None""" fee, lower = Decimal('0'), Decimal('0') for cap, price in tiers: if usage <= lower: break upper = Decimal(str(cap)) if cap is not None else usage seg = min(usage, upper) - lower fee += seg * Decimal(str(price)) lower = upper return q(fee)

三个函数各有各的用意。q()是唯一的取整出口,把ROUND_HALF_UP收在一处,比散落在十几个地方各写一遍要安全——只要有一处用了默认的银行家舍入,就会出现"这户差一分、那户多一分"的玄学账。rent_for_partial_month()里那个days_used >= days_in_month的短路分支是有意加的:住满整月却走除法,1200 * 31 / 31在某些精度设置下不一定回到 1200.00,多一次除法就多一次风险。tiered_fee()用min(usage, upper) - lower计算每档内的用电量,而不是预先判断落在第几档,好处是档位数可以随便改,改配置不改代码。

单价参数建议放在一张price_config(period, kind, tiers_json)表里,按账期存快照。电价调整是常事,如果只存一份"当前单价",去年的账单重算时会被新价覆盖,对账就再也对不上了。

3.3 金额大写:收据上最容易被挑错的一栏

收据上数字金额和大写金额不一致,是最没面子的一种错。中文大写的规则不难,难在零的处理:1005是"壹仟零伍元整",1000005是"壹佰万零伍元整",10是"壹拾元整"而不是"拾元整"。

DIGITS = '零壹贰叁肆伍陆柒捌玖' UNITS = ['', '拾', '佰', '仟'] GROUPS = ['', '万', '亿'] def _four(n: int) -> str: """0-9999 转大写,内部函数,不处理组间的零""" s, pending_zero = '', False for i, ch in enumerate(str(n).zfill(4)): d, pos = int(ch), 3 - i if d == 0: pending_zero = True else: if pending_zero and s: s += '零' s += DIGITS[d] + UNITS[pos] pending_zero = False return s def to_rmb_upper(cent: int) -> str: """整数分 -> 中文大写,如 100500 -> 壹仟零伍元整""" if cent < 0: return '负' + to_rmb_upper(-cent) yuan, jiao, fen = cent // 100, cent // 10 % 10, cent % 10 if yuan == 0 and jiao == 0 and fen == 0: return '零元整' out = '' if yuan: groups, y = [], yuan while y > 0: groups.append(y % 10000) y //= 10000 for idx in range(len(groups) - 1, -1, -1): g = groups[idx] if g == 0: if out and not out.endswith('零'): out += '零' # 组内全零,如 100000005 中间的"亿"后零 continue if out and g < 1000 and not out.endswith('零'): out += '零' # 高位组与低位组之间的零,如 壹佰万零伍 out += _four(g) + GROUPS[idx] out = out.rstrip('零') + '元' if jiao == 0 and fen == 0: out += '整' else: if jiao: out += DIGITS[jiao] + '角' elif yuan: out += '零' # 如 100.05 -> 壹佰元零伍分 if fen: out += DIGITS[fen] + '分' return out

拿到手先跑一组边界用例,别信"看着差不多":

输入(分)期望输出考察点
0零元整全零
1000壹拾元整十位不写"拾元"
100500壹仟零伍元整中间单个零
100000500壹佰万零伍元整跨组补零
10005壹佰元零伍分角位为零、分位非零
123456壹仟贰佰叁拾肆元伍角陆分完整形态

提示:大写函数别在打印时才调用,算完账就存进bill.total_upper字段。收据、对账单、导出表格三处如果各调一次,改一次规则就有三处不同步。

3.4 写库前的一致性校验

账单落库前的最后一道门,是几条assert:

def check_bill(b) -> None: parts = b['rent_cent'] + b['water_cent'] + b['elec_cent'] + b['other_cent'] assert parts == b['total_cent'], f"分项之和 {parts} != 总额 {b['total_cent']}" assert b['total_cent'] >= 0, "出现负数总额,检查是否把退款算进了本期" assert to_rmb_upper(b['total_cent']) == b['total_upper'], "大写与数字不一致"

这类断言上线时看着多余,出过一次事就知道值。有个房间因为上期读数录成了 99999,本期读数 1234,用量算成负数,账单总额直接变成负的,收据上打出"-XXX 元"。数值校验一分钟能写完,事后追查要半天。

4. 套打与批量出票:坐标校准、票据打印机和 PDF 留底

4.1 收据模板的三种落地方式和取舍

方式套打精度批量速度维护成本适合场景
Word/Excel 邮件合并中,靠表格边距慢,几十张就卡低,人人会改一次性出票,套数少
HTML + 浏览器打印低,受浏览器缩放影响中中屏幕预览为主,A4 普通纸
代码生成 PDF高,坐标可控到 0.1mm快中,改动要发版两联针式票据、需要留底

走两联针式票据纸的,基本只有第三条路。浏览器打印的边距、缩放、页眉页脚三个变量叠在一起,同一份 HTML 在两台机器上能打出两种结果,校准一次管不了多久。生成 PDF 的好处是所见即所得——你看到的 PDF 坐标,就是打印机上纸的位置,偏差只来自打印机本身的机械误差,是个可测量的常量。

4.2 用 reportlab 画一张固定坐标的收据

from reportlab.pdfgen import canvas from reportlab.lib.pagesizes import mm from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont # 中文字体必须先注册,否则中文全变方块,这是最常见的第一次翻车 pdfmetrics.registerFont(TTFont('CN', '/usr/share/fonts/truetype/noto/NotoSansSC-Regular.ttf')) PAPER = (241 * mm, 140 * mm) # 两联票据纸单联尺寸,按实际纸张改 OX, OY = 3.5 * mm, -1.2 * mm # 全局套打偏移,正值向右/向上 def draw_receipt(path: str, r: dict) -> None: c = canvas.Canvas(path, pagesize=PAPER) c.setFont('CN', 10.5) # 针式打印机 10.5pt 是最稳的字号,再小容易糊 W, H = PAPER def t(x_mm, y_mm, s): c.drawString(x_mm * mm + OX, H - y_mm * mm + OY, s) # y 从顶部往下算,便于对纸 t(18, 16, f"收据号:{r['serial']}") t(150, 16, f"日期:{r['date']}") t(18, 30, f"房间:{r['room_code']}") t(90, 30, f"租户:{r['tenant']}") t(18, 42, f"房租:{r['rent']:.2f}") t(90, 42, f"水费:{r['water']:.2f}({r['water_usage']}吨)") t(18, 54, f"电费:{r['elec']:.2f}({r['elec_usage']}度)") t(90, 54, f"其他:{r['other']:.2f}") t(18, 70, f"合计:{r['total']:.2f} 元") t(18, 86, f"大写:{r['total_upper']}") t(150, 100, "收款人:__________") c.showPage() c.save()

t()这个包装函数是为了让坐标可读——直接写H - 30*mm的话,调一次位置就要在脑子里做一次减法。OY取负值是因为针式打印机的进纸定位通常比 PDF 原点偏上,试出来是负的就写负的。字号钉在 10.5pt 是踩出来的:针式打印机分辨率低,9pt 以下汉字的横画会断成虚线,12pt 以上两联纸的行距放不下五行。

4.3 批量出票:编号、命名、失败重试

import csv, os, time, traceback def batch_output(bills, outdir='out', retries=2): os.makedirs(outdir, exist_ok=True) ok, fail = [], [] for b in bills: fname = f"{b['serial']}_{b['room_code']}_{b['period']}.pdf" path = os.path.join(outdir, fname) for attempt in range(retries + 1): try: draw_receipt(path, b) ok.append(path) break except Exception: if attempt == retries: fail.append((b.get('room_code'), b.get('serial'), traceback.format_exc(limit=2))) else: time.sleep(0.2) # 字体未加载完、磁盘瞬时占用都可能一次失败 with open('print_fail.csv', 'w', newline='', encoding='utf-8-sig') as f: csv.writer(f).writerows([('room', 'serial', 'error')] + fail) return ok, fail

命名里带serial是为了让归档目录按文件名排序就等于按开票顺序排,事后找一张票不用翻数据库。utf-8-sig的 BOM 是给 Excel 看的,否则失败清单在 Windows 上打开全是乱码。先生成 PDF 再统一送打印机,而不是边算边打,理由很实际:批量出票时最怕打到第 17 张卡纸,前面 16 张已经吐出来了,后面 20 张还在不在队列里说不清。先生成、核对、再一次性打印,卡纸了重打整批也不心疼。

打印环节用系统命令行最省事:

# Linux / macOS:批量送默认打印机,份数 1 for f in out/*.pdf; do lp -n 1 -o media=Custom.241x140mm "$f"; done # Windows:用默认关联程序打印,注意引号处理带空格的路径 powershell -Command "Get-ChildItem out\*.pdf | ForEach-Object { Start-Process -FilePath $_.FullName -Verb Print }"

media=Custom.241x140mm这个参数不能省。纸张尺寸不显式传,打印机会退回 A4 缩放,内容整体缩到 70%,坐标全乱。Windows 那边-Verb Print走的是关联程序,打印完不关进程,几百张会堆一堆后台窗口,批量超过 50 张建议还是走 PDF 阅读器的命令行参数。

4.4 打印偏移校准:三张测试页定坐标

校准是个纯体力活,但有固定套路:

  1. 把OX/OY归零,用同一份模板打一张只有边框和十字线的校准页。
  2. 用钢尺量四条边到纸张边缘的实际距离,和设计值相减,得到上下左右的偏差。
  3. 取上下偏差的均值填OY,左右偏差的均值填OX。注意 PDF 的 Y 轴向上为正,所以"内容整体偏上"意味着要往负方向调。
  4. 再打一张确认,四个角的误差控制在 ±0.5mm 以内就算过。
  5. 把这组值写进配置文件,别写死在代码里——换一台打印机就得重新校一次。
打印机侧设置建议值说明
纸张尺寸自定义 241×140mm不要用 A4 + 缩放
缩放比例100% / 实际大小驱动里"适应页面"必须关掉
边距全部 0由 PDF 内部坐标控制
打印质量草稿或普通高质量模式进纸速度变化会导致纵向漂移

注意:同一份 PDF 在两台同型号打印机上也可能有 1mm 差异,属于机械公差。真要做到零误差,只能一台机器一套配置,按打印机名称存偏移参数。

5. 避坑与排查:五类错票现场的现象、成因和处置

5.1 解压即用的目录选错,程序起来就报错

现象:双击启动后提示找不到模板文件或数据库,或者干脆闪退,同一份程序在别人电脑上正常。

原因:这类免安装工具默认按相对路径找template/和data.db,一旦把整个文件夹丢到C:\Program Files或者桌面某个带中文和空格的路径下,写权限被系统收走,或者路径解析出问题。更隐蔽的是从压缩包里直接双击运行,程序实际在临时目录里展开,退出就没了。

解决:固定解压到D:\ReceiptTool\这种纯英文无空格的短路径,先完整解压再运行。如果必须放共享盘,只读运行可以,写数据库不行,单文件数据库在网络盘上的锁机制不可靠。

5.2 收据号重复或者整段跳号

现象:两户拿到同一个收据号;或者打印中途崩溃,重新打开后编号从 0050 直接跳到 0100。

原因:重复多半是取号用了MAX(serial)+1且没有事务保护,两个人同时开单读到同一个最大值。跳号则是把"占号"写成了"预分配一批"——启动时先取 50 个号放内存,崩溃后这 50 个号就丢了。

解决:取号必须和插入收据在同一个事务里,靠 UNIQUE 约束兜底,冲突就重试;绝不预分配号段。已经跳号的历史数据不要手工补,在收据表里插一条status='void'的占位记录说明原因,比改历史安全。

5.3 数字和大写差一分钱

现象:收据上合计 1234.56,大写却是"壹仟贰佰叁拾肆元伍角柒分"。

原因:金额在中间环节被取整了两次。比如水费先round(x, 2)存成浮点,出票时又格式化一次,两次舍入方向不一致;或者大写是由浮点金额直接乘 100 再转整数,1234.56 * 100得到123455.99999999999,int()截断成 123455。

解决:全链路只保留整数分,浮点只在最外层格式化输出时出现。大写函数只接受 int 类型的分数值,传 float 进来自动抛异常,逼着调用方在入口就把类型理顺。

5.4 换一台电脑,套打位置整体偏

现象:同一份 PDF,在办公室那台针式打印机上正正好好,搬到另一台机器打出来整体偏下 4mm,最后一行压到撕线。

原因:打印机驱动里纸张定义不一致,或者"适应页面"没关,内容被缩放了 2% 到 5%。也有可能是两台机器的纸张装订位置本来就差几毫米。

解决:偏移量存配置文件,按打印机名做 key,第一次在新机器上跑一遍 4.4 的校准流程。同时把驱动的纸张尺寸、缩放、边距三项截图存档,换机器时逐项比对。

5.5 单文件数据库在共享盘上损坏

现象:某天打开程序提示"database disk image is malformed",或者账单里凭空多出半条记录。

原因:多个客户端通过共享盘同时打开同一个 SQLite 文件,写锁在网络文件系统上不生效,两个进程同时写页,文件结构就烂了。

解决:单文件数据库只允许单机单进程访问。多人开单的需求,要么改成局域网内跑一个服务端(哪怕是个几十行的 HTTP 接口),要么按人分库、月底合并。日常备份用VACUUM INTO 'backup.db'而不是直接拷文件,拷贝过程中有写入的话,拷出来的备份本身就是坏的。

6. 出票前的验收:用固定样例集把错票拦在打印机之外

真正让我少赔笑脸的,不是打印代码写得多漂亮,而是一套固定样例集。做法很土:造 12 个房间,覆盖住满整月、月中入住、月中退租、零用水、阶梯跨档、有上期欠费、其他费用为负(退押金)这七种情况,账期固定成三个月。每次改完计费或模板,先跑这批样例,把生成的 PDF 和上一次的逐字段比对,再把下面这条 SQL 跑一遍。

-- 三表闭合校验:账单总额 - 已开票收据总额 应当等于未出票金额,不为 0 就是有孤儿数据 SELECT b.period, SUM(b.total_cent) AS bill_total, SUM(CASE WHEN r.status = 'valid' THEN b.total_cent END) AS receipt_total, SUM(b.total_cent) - SUM(CASE WHEN r.status='valid' THEN b.total_cent END) AS diff, COUNT(*) AS bill_cnt FROM bill b LEFT JOIN receipt r ON r.bill_id = b.id WHERE b.status <> 'void' GROUP BY b.period HAVING diff <> 0;

diff不为零有两种解释:账单开了没收据(正常,还没送出去),或者收据挂在了不存在的账单上(异常,外键被绕过写进去的)。所以这条 SQL 的正确用法不是"必须为零",而是"每个账期的 diff 必须等于该期status='open'的账单总额",两个数一对照,异常立刻现形。

验收清单我压成了五行,每次发版对着走一遍:

检查项通过标准
大写金额12 组样例全对,含零值和跨组补零
分项合计每张账单分项之和等于总额,无负数异常
收据编号连续不重复,作废号有占位记录
套打位置首行、末行、右边界三处误差 ≤0.5mm
归档完整性每张有效收据都有对应 PDF,文件名含编号

归档那块我吃过一次亏:某月清理磁盘时按修改时间删了一批"旧"PDF,结果里面有 6 张是补打过的收据,编号对不上,只能凭记忆重开。后来改成按账期/编号.pdf分层存,每月出一次sha256sum清单和账单表一起打包,谁动过文件一查就知道。

我现在的习惯是:任何跟金额、编号、坐标相关的改动,先跑样例集,再看对账 SQL,最后才允许碰打印机。这三步顺序颠倒过一次,直接打废了两联纸一沓,重开还得挨个解释。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询