简介:优图房租水电费收据打印软件v11.0面向房东、公寓管理者、小型物业及需要处理房租、押金、水费、电费和燃气费收据的收款人员,重点解决手写收据易错、Excel对账繁琐、票据样式不统一等问题。软件把收据管理、出租房管理、客户资料库与数据分析整合在一起,支持自定义单据编号、即输即打、金额自动计算与大小写转换、批量打印、一键复制单据,并能根据收据生成对账单;还提供Excel资料导入导出、打印样式自定义及导入导出、单据转图片或PDF、数据自动备份恢复、U盘运行、锁屏和在线升级等功能,适合日常收租开单与财务留档场景。压缩包约3.39MB,上游未提供可核实的文件总数与类型明细。目前已有292人学习,可作为了解该收据打印工具功能范围的参考。
1. 房租水电费收据打印软件:一张纸背后要算清的三笔账
月底那两天,房东或物业最怕的不是收不到钱,是收据开错。房租、水费、电费、公摊、上期欠费、押金抵扣,六七个数字挤在一张小纸片上,手写一遍要三分钟,写错一个数字就要重来。标题里提到的优图房租水电费收据打印软件 v11.0.zip,走的就是“解压即用、录数据、点打印”的桌面工具路线,解决的是从台账到纸面这一段。但真正决定它好不好用的,不是界面,是三件事:表底差值算得对不对、分摊尾差挂给谁、打印坐标偏不偏。下面按我实际搭这套流程的顺序讲,从计费模型、批量渲染、两种出纸路线,一直讲到翻车点怎么排查。
2. 把账算对再谈打印:房租水电计费模型与 9 个必填字段
打印是最后一步,账错了,打印越漂亮错得越明显。我见过最典型的翻车是:收据上“本期应收”合计比 Excel 台账多一分钱,租户拿着两张纸来对,最后查到是公摊电费按人头平摊时四舍五入各自进位。这类问题不是打印软件的锅,是计费模型没定死。
2.1 表底差值、阶梯单价与公摊:三笔账的拆法
房租水电收据打印软件也好,自己写的脚本也好,第一层必须先拆成三笔独立账:
- 房租账:按月租 × 计费月数,叠加押金抵扣、免租期、滞纳金。这里的关键是“计费周期”要存成起止日期而不是“几月”,否则跨月、半月租、提前退租全乱。
- 水电账:只认表底差值。本期用量 = 本期读数 − 上期读数,单价可以是单一价,也可以是阶梯价。阶梯价要按“年累计用量”落档,不是按月单独落档,很多简易工具在这里偷懒,冬天和夏天算出来的单价一样,对不上供电局的结算单。
- 公摊账:总表减分表之和的差额,按面积或人头分摊。这一步必然产生除不尽的尾差,必须指定一个确定的规则,不能靠浮点数自然舍入。
我一般把这三笔账分别落到三张明细表,收据上只显示汇总和必要的明细行。好处是重算的时候可以只重算一笔,不用整月推倒重来。
2.2 用 Decimal 和最大余数法处理分摊尾差
浮点数在金额上是禁忌。0.1 + 0.2 != 0.3这种事在收据上就是实打实的对账纠纷。Python 里统一用Decimal,并且明确指定舍入方式为ROUND_HALF_UP,和人工算账的习惯一致。
分摊我用最大余数法:先按权重算出理论值,全部向下截断到分,剩下的差额按“被截掉的小数部分”从大到小依次补一分。这样合计永远等于总额,且每户的偏差不超过一分。
from decimal import Decimal, ROUND_DOWN, ROUND_HALF_UP CENT = Decimal("0.01") def allocate(total: Decimal, weights: list) -> list: """把 total 按 weights 分摊,保证各份之和严格等于 total""" total = Decimal(total).quantize(CENT, rounding=ROUND_HALF_UP) w_sum = sum(weights) if w_sum <= 0: raise ValueError("权重之和必须大于 0") raw = [total * w / w_sum for w in weights] # 先全部向下截断到分,保证不会超发 floors = [r.quantize(CENT, rounding=ROUND_DOWN) for r in raw] diff = total - sum(floors) # 差额,一定是 CENT 的整数倍 n = int((diff / CENT).to_integral_value()) # 按被截掉的小数部分排序,大的先补 order = sorted(range(len(raw)), key=lambda i: raw[i] - floors[i], reverse=True) for i in order[:n]: floors[i] += CENT return floors逻辑说明:raw是理论分摊值,floors是向下截断后的结果。diff是被丢掉的总差额,因为每份最多被截掉不到一分,所以n一定小于户数,order[:n]不会越界。参数上唯一需要外部决定的是weights——按建筑面积就传面积列表,按人头就传人数列表,不要混用。
调用示例:公摊电费 100.01 元,三户面积 30、45、25 平米。
areas = [Decimal("30"), Decimal("45"), Decimal("25")] result = allocate(Decimal("100.01"), areas) print(result, sum(result)) # 合计必然为 100.01这套规则一旦定下,就写进配置,别每期临时改。改一次,历史收据的对账口径就断了。
2.3 一张收据需要的字段表与 SQLite 建表
字段少了打不全,字段多了界面乱。我的经验是收据正文必须能独立看懂,至少 9 个字段缺一不可:房间号、租户名、计费周期起、计费周期止、房租金额、水费金额与用量、电费金额与用量、公摊金额、本期应收合计。再加上流水号和开票日期,一共 11 列。
用 SQLite 存台账,单文件、免安装,跟“解压即用”的工具定位很搭:
CREATE TABLE bills ( id INTEGER PRIMARY KEY AUTOINCREMENT, serial_no TEXT NOT NULL UNIQUE, -- 收据流水号,重打时复用 room TEXT NOT NULL, -- 房间号,如 3-2-501 tenant TEXT NOT NULL, period_from TEXT NOT NULL, -- ISO 日期 yyyy-mm-dd period_to TEXT NOT NULL, rent NUMERIC NOT NULL DEFAULT 0, water_usage NUMERIC NOT NULL DEFAULT 0, water_fee NUMERIC NOT NULL DEFAULT 0, elec_usage NUMERIC NOT NULL DEFAULT 0, elec_fee NUMERIC NOT NULL DEFAULT 0, shared_fee NUMERIC NOT NULL DEFAULT 0, arrears NUMERIC NOT NULL DEFAULT 0, -- 上期欠费 total NUMERIC NOT NULL, printed_at TEXT, -- 首次打印时间,NULL 表示未打印 print_count INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX idx_bills_room_period ON bills(room, period_from);注意total是落库时算好存下来的,不在打印时现算。打印环节只读不写,是纯渲染。这个约束能挡掉一大类“同一张收据打两次金额不一样”的玄学问题。print_count单独一列,是为了后面做重打水印——很多纠纷就出在“这张到底是第几次打的”。
3. 从台账到一叠 PDF:批量渲染收据的最小实现
账算完,接下来是把数据库里的行变成纸。批量场景和单张场景的要求完全不同:单张可以慢慢排版,批量要的是几百张一次性生成、每张三联、裁切线对齐、任何一行数据有问题都不能卡住整批。
3.1 数据源体检:读 CSV 前的 5 条校验规则
不管数据来自 Excel 导出还是系统对接,进渲染器之前先过一遍校验,把坏数据拦在生成 PDF 之前。我固定用这五条:
- 房间号非空且在同一批次内唯一,重复的直接报错而不是覆盖。
- 计费周期起止是合法日期,且
period_from <= period_to。 - 本期读数 ≥ 上期读数,倒走的表要么换表要么人工确认。
- 各项金额 ≤ 999999.99,超过这个量级基本可以断定是单位填错,把“元”填成了“分”。
- 合计等于各分项之和,误差超过 0.00 直接拒绝。
校验结果输出成一张问题清单,带行号和原因,而不是抛一个异常就结束。批量任务的用户需要知道“第 87 行房间号为空”,而不是“程序崩了”。
from decimal import Decimal from datetime import date def check_row(row: dict, seen_rooms: set) -> list: errs = [] room = (row.get("room") or "").strip() if not room: errs.append("房间号为空") elif room in seen_rooms: errs.append(f"房间号重复: {room}") try: d1 = date.fromisoformat(row["period_from"]) d2 = date.fromisoformat(row["period_to"]) if d1 > d2: errs.append("计费周期起止颠倒") except (KeyError, ValueError): errs.append("计费周期日期格式错误") try: parts = [Decimal(str(row[k])) for k in ("rent", "water_fee", "elec_fee", "shared_fee", "arrears")] total = Decimal(str(row["total"])) if sum(parts) != total: errs.append(f"合计不符: 分项和={sum(parts)} 合计={total}") if any(p < 0 for p in parts): errs.append("存在负数金额,请确认是否为冲抵") except Exception: errs.append("金额字段无法解析") return errs这里特意保留负数的报错而不是直接禁止——押金抵扣、上期多收退款都会出现负数,但必须人工确认,不能让程序默默放过。
3.2 用 reportlab 按毫米坐标画三联收据
A4 收据最常见的形态是一页三联:存根联、客户联、财务联。用 reportlab 的 canvas 直接按毫米坐标画,比 HTML 转 PDF 更适合套打,因为位置是确定的,不受浏览器排版引擎影响。
from reportlab.pdfgen import canvas from reportlab.lib.pagesizes import A4 from reportlab.lib.units import mm from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont # 中文字体必须先注册,否则全是方块 pdfmetrics.registerFont(TTFont("CN", r"C:\Windows\Fonts\simhei.ttf")) PAGE_W, PAGE_H = A4 # 210mm x 297mm MARGIN_X = 12 * mm SLIP_H = 95 * mm # 三联,每联 95mm,留 6mm 余量 def draw_slip(c, top_y, bill, copy_name): """top_y 为该联顶部在页面坐标系中的 y 值(pt)""" x0, x1 = MARGIN_X, PAGE_W - MARGIN_X y0 = top_y - SLIP_H c.setLineWidth(0.5) c.rect(x0, y0, x1 - x0, SLIP_H) # 联的外框 c.setFont("CN", 13) c.drawCentredString((x0 + x1) / 2, top_y - 9 * mm, "房租水电费收据") c.setFont("CN", 8) c.drawString(x0 + 3 * mm, top_y - 15 * mm, f"编号:{bill['serial_no']}") c.drawRightString(x1 - 3 * mm, top_y - 15 * mm, copy_name) c.setFont("CN", 9) rows = [ ("房间", bill["room"], "租户", bill["tenant"]), ("周期", f"{bill['period_from']} 至 {bill['period_to']}", "房租", bill["rent"]), ("水费", bill["water_fee"], "电费", bill["elec_fee"]), ("公摊", bill["shared_fee"], "上期欠费", bill["arrears"]), ] for i, (k1, v1, k2, v2) in enumerate(rows): yy = top_y - (25 + i * 9) * mm c.drawString(x0 + 3 * mm, yy, f"{k1}:{v1}") c.drawString(x0 + 95 * mm, yy, f"{k2}:{v2}") c.setFont("CN", 11) c.drawString(x0 + 3 * mm, y0 + 12 * mm, f"合计(大写):{rmb_upper(bill['total'])}") c.drawRightString(x1 - 3 * mm, y0 + 12 * mm, f"¥{bill['total']}") c.setFont("CN", 8) c.drawString(x0 + 3 * mm, y0 + 6 * mm, "收款人:__________ 盖章:") c.line(x0, y0, x1, y0) # 裁切参考线关键参数:SLIP_H取 95mm,是因为三联内容加裁切余量后 285mm,在 297mm 的 A4 内刚好放下且不顶到打印机不可打印区。MARGIN_X = 12mm是激光打印机普遍能保证的左右安全边距,低于 8mm 时部分机型的定影区会吃掉边框。坐标一律用毫米换算成点(reportlab 内部单位是 pt,1mm ≈ 2.8346pt),不要凭感觉写死 pt 数字,换纸张尺寸时全是坑。
字体注册时如果用的是simsun.ttc这类集合字体,要加subfontIndex=0,否则报找不到字形。字体文件路径建议做成配置项,别写死在代码里,换一台没装该字体的机器就翻车。
3.3 金额大写与打印次数水印
金额大写是收据的硬要求,也是最容易写错的一段代码。常见错误是“零”的处理:1001 元要写成“壹仟零壹元整”,不能写成“壹仟零壹元”;1010 要写“壹仟零壹拾元整”。
DIGITS = "零壹贰叁肆伍陆柒捌玖" UNITS = ["", "拾", "佰", "仟"] SECTIONS = ["", "万", "亿"] def _four(n: int) -> str: s, zero = "", False for i, ch in enumerate(str(n).zfill(4)): d = int(ch) pos = 3 - i if d == 0: zero = True else: if zero and s: s += "零" zero = False s += DIGITS[d] + UNITS[pos] return s def rmb_upper(amount) -> str: cents = int((Decimal(str(amount)) * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) yuan, jiao, fen = cents // 100, cents // 10 % 10, cents % 10 if yuan == 0: head = "" else: parts, sec, y = [], 0, yuan while y > 0: parts.append(_four(y % 10000) + SECTIONS[sec]) y //= 10000 sec += 1 head = "".join(reversed(parts)) + "元" if jiao == 0 and fen == 0: return (head or "零元") + "整" tail = "" if jiao: tail += DIGITS[jiao] + "角" elif head: tail += "零" if fen: tail += DIGITS[fen] + "分" return head + tail_four里的zero标记负责“中间连续零只写一个”,但段首的零不写。跨段的时候(比如 10001 元),万位段和个位段之间还要补一个“零”,这段逻辑没有在上面体现,实际使用前建议用 1001、1010、10001、100000、10.05 这几个值做回归,跑通了再上线。
打印次数水印放在联次名旁边,第一次打显示“客户联”,重打显示“客户联(第 2 次打印)”。这个细节看着小,但对账时能省掉大量扯皮,是我踩过坑之后加上的后悔药。
4. A4 套打与热敏小票:两种出纸路线的参数怎么定
同一个计费引擎,出纸可以走两条完全不同的路:A4 激光或针式打印机打三联收据,或者 58mm、80mm 热敏机打小票。参数不通用,混着配就会出现“PDF 上很完美,打出来偏 3 毫米”。
4.1 A4 激光/针式套打:边距、缩放与纸张自定义
A4 路线最常见的失败不是代码,是驱动。三个参数必须同时锁死:
| 参数 | 建议值 | 说明 |
|---|---|---|
| 纸张尺寸 | 自定义 210×297mm | 不要用“Letter”,差 6mm 直接错位 |
| 缩放 | 100%(实际大小) | 选“适合页面”会整体缩 4%~6% |
| 页边距 | 全部设为 0 或最小值 | 边距由 PDF 内部坐标控制,驱动再留边会叠加 |
| 打印质量 | 针式用 180×180dpi | 过高会让走纸精度变差,三联复写反而不清 |
针式打印机还要注意“拷贝份数”设置,物理三联纸要把份数设成 3,否则只打第一联。激光机则要把“双面打印”关掉,批量任务里开了双面,第二页会莫名其妙打到背面。
套打微调的正规做法是:先用 PDF 生成一张只有边框和十字标记的校准页,实际打一张,用尺子量偏移量,把偏移值写进配置,在draw_slip里统一加offset_x、offset_y。不要直接改每个drawString的坐标,那样改到第三张就乱了。
4.2 58mm 与 80mm 热敏:ESC/POS 指令与每行字数
热敏小票走的是 ESC/POS 指令流,不是 PDF。核心参数就三个:纸宽、每行字符数、编码。
80mm 纸的打印宽度约 72mm,203dpi 下是 576 个点;用 12×24 点阵字体时,汉字 24 点宽,一行放得下 24 个半角字符或 12 个汉字。58mm 纸打印宽度约 48mm、384 点,一行 32 个半角或 16 个汉字——这个数字记牢,排版超宽不会自动换行,会直接截断,是热敏打印最高频的翻车点。
import win32print def send_raw(printer_name: str, payload: bytes): h = win32print.OpenPrinter(printer_name) try: job = win32print.StartDocPrinter(h, 1, ("receipt", None, "RAW")) win32print.StartPagePrinter(h) win32print.WritePrinter(h, payload) win32print.EndPagePrinter(h) win32print.EndDocPrinter(h) finally: win32print.ClosePrinter(h) def build_receipt(bill) -> bytes: b = bytearray() b += b"\x1b@" # 初始化打印机 b += b"\x1b\x61\x01" # 居中对齐 b += b"\x1d\x21\x11" # 倍宽倍高 b += "房租水电费收据\n".encode("gb18030") b += b"\x1d\x21\x00" # 恢复正常字号 b += b"\x1b\x61\x00" # 左对齐 lines = [ f"编号 {bill['serial_no']}", f"房间 {bill['room']} 租户 {bill['tenant']}", f"周期 {bill['period_from']}~{bill['period_to']}", "-" * 32, f"房租 {bill['rent']:>10}", f"水费 {bill['water_fee']:>10}", f"电费 {bill['elec_fee']:>10}", f"公摊 {bill['shared_fee']:>10}", "-" * 32, f"合计 {bill['total']:>10}", ] b += ("\n".join(lines) + "\n").encode("gb18030") b += b"\n\n\n" b += b"\x1d\x56\x00" # 全切 return bytes(b)\x1b@是初始化,每次任务开头都要发,否则上一单的对齐方式会残留。\x1d\x21\x11是倍宽倍高,适合标题,正文必须用\x00恢复。编码统一gb18030,不要用utf-8,绝大多数国产热敏机的字库只认 GB 系编码,发 UTF-8 出来就是一堆问号。\x1d\x56\x00是全切指令,部分机型需要\x1d\x56\x01才切半切,切刀型号不一样得试。
4.3 静默批量打印队列与失败重入
批量几十上百张的时候,逐张弹打印对话框是不现实的。用 RAW 方式直接投递到打印队列,不经过驱动渲染,速度快也不弹窗。但要在外面包一层队列管理:
维护一个状态表,每张收据有“待打印 / 已投递 / 已确认”三个状态。投递成功不等于打出来了,纸卡了、没纸了都会让任务停在队列里。我一般用打印队列的作业数做简单判断:投递后轮询win32print.EnumJobs,作业数归零才算完成。
失败重入的关键是幂等。重入时按serial_no查状态,已确认的跳过,投递中且超过 60 秒未完成的标记为异常并提示人工处理。这一步不做,重跑一次就是整批重复打印,纸和碳粉都是钱。
5. 收据打印最常见的 5 个翻车点:现象、原因与排查顺序
前面讲的是怎么搭,这一章讲怎么救。下面这些是我在实际批量打印里反复遇到的,按现象、原因、解决的顺序写,遇到问题可以从上往下照着排。
5.1 金额差一分与合计对不上
现象:收据上的合计比台账多 0.01 元,或者三户分摊后加起来比总额少一分。
原因:分摊用了浮点数,或者每户独立round()后直接相加。四个人各分 25.0025 元,各自四舍五入得 25.00,合计 100.00,但总额是 100.01,差的一分没人认领。
解决:分摊统一走最大余数法,向下截断再补差;合计字段落库前用Decimal重算一次并与分项和断言相等,不等就拒绝打印。已经产生的历史数据,按总额倒推最后一行金额修正,不要手工改单张。
5.2 套打整体偏移 2mm
现象:PDF 里边框居中,打出来整体向右下偏,第二联偏移量还和第一联不一样。
原因:八成是驱动缩放不是 100%,或者纸张被识别成 Letter。另一成是针式打印机走纸误差累积,三联越往下越偏。
解决:先在驱动里把缩放和纸张锁死,用校准页量偏移。如果三联偏移量递增,说明走纸精度问题,把每联高度从 95mm 加到 97mm 拉开间距,或者改用单联纸分三次打。偏移修正值做成两个配置项offset_x、offset_y,在绘制入口统一加,别散落在各处。
5.3 中文乱码与字体重叠
现象:PDF 里中文全是方块,或者热敏小票上是“?????”;另一种是文字互相压在一起。
原因:PDF 是没注册中文字体,reportlab 默认字体不含汉字;热敏是编码用了 UTF-8;重叠一般是字号和行距没配套,或者倍宽指令发了没恢复。
解决:PDF 端注册 TTF 字体并在每个setFont里显式指定,别依赖默认;热敏端统一gb18030。行距按字号的 1.3~1.5 倍设,12 点字体给 16~18 点行高。每次发\x1d\x21改字号后,正文开头必须补一条恢复指令。
5.4 重打、漏打与重复计费
现象:某个房间打了两张,另一个房间一张没有;或者重打之后台账里金额翻倍。
原因:打印和计费写在了同一个事务里,打印失败触发回滚,数据状态和纸面状态不一致。漏打多半是批量任务中途异常,前面成功后面没执行,又没有断点记录。
解决:打印只读,计费只写,两条链路彻底分开。每张收据落print_count和printed_at,重打必须走独立入口并打水印。批量任务按serial_no记录进度,异常后从断点续跑,而不是从头再来。
5.5 大批量打印中途卡死
现象:打到第 40 张不动了,打印队列里堆了七八个作业,程序没报错也没继续。
原因:一次性把所有 PDF 页投进队列,驱动缓冲区满了;或者某一张收据的图片、字体资源太大,单页渲染卡住。也有可能是共享打印机网络抖动。
解决:改成生产者—消费者模型,队列里同时最多保留 2 个作业,完成一个投下一个。单页渲染加超时,超过 10 秒标记异常跳过并记录,不让一张坏数据拖死整批。共享打印机场景下把投递间隔加到 300~500 毫秒,能明显减少队列堵塞。
6. 进阶:让收据自己会核对——二维码、流水号与月度对账清单
做到能稳定批量出纸,这套东西就算能用了。但真正省人力的是让它自己会核对。
第一个改动是给每张收据加二维码。二维码内容不用复杂,就放一个查询串,比如RCPT|202503|3-2-501|1580.00|A7F3,前四段是周期、房间、金额、校验码。校验码用 房间号+周期+金额 拼起来做一次哈希取前 4 位,租户扫码后拿到的信息和纸面不一致,当场就能发现。这一步不需要联网,纯离线生成,成本几乎为零,但把“事后扯皮”变成了“当场核对”。
第二个改动是流水号规则。我固定用年月 + 房间序号 + 批次内序号,比如202503-0501-007。好处是肉眼能看出归属,按前缀就能定位到某个月某个房间的全部收据,重打时不容易拿错。流水号必须在落库时生成且唯一索引,不能打印时临时拼。
第三个改动是每月出一张对账清单,字段就四列:房间、应收、已打张数、首次打印时间。清单本身不打印,导出 CSV 给财务。凡是“应收大于 0 但已打张数为 0”的行,就是漏打;凡是“已打张数大于 1”的行,就是要重点核对的重打记录。这张清单我一般放在月末最后一天跑,五分钟就能把整月的异常圈出来。
一个具体技巧:把上面这些校验做成“打印前预检”,在真正投递打印任务之前先跑一遍,任何一条不通过就整个批次暂停并输出清单。宁可晚打半小时,也不要打出一批需要逐张作废的纸。
我自己的习惯是,每次改动计费规则或者模板坐标,先拿三张历史数据生成 PDF,和上个月实际打出来的纸质件并排比一遍,确认金额、联次、偏移都没变再跑批量。这个动作花不了十分钟,但帮我挡掉过好几次“改了一行代码,整批收据作废”的事故。希望帮到你。
本文还有配套的精品资源,点击获取