全部文章

用 Go 生成会计报表 PDF:日记账、总账、试算平衡表

用 Go 生成会计报表 PDF:从同一份分录切片输出日记账、总账和试算平衡表。附完整 gpdf 代码,含带框线的表格、金额右对齐与自动分页。

发布于 2026年11月14日
作者: Taiki Noda

TL;DR

只要一份分录切片,同一个 Go 程序就能输出日记账、总账和试算平衡表。用 gpdf v1.0.17,整个 main.go 是 240 行。其中包括带框线的表格、右对齐的金额、跨页时重复的表头行,以及 1 / 3 形式的页码。不需要 Chromium,不需要 CGO,也不需要报表工具的许可证。

一个月的日记账 (67 笔分录) 是 2 页,在 Apple M1 上耗时约 9 ms。10,000 笔分录时是 170 页,耗时约 0.7 秒。

报表是汇总,不是表格

有人问怎么用 Go 输出会计报表时,第一个问题通常是怎么画格线。工作量并不在那里。

日记账、总账和试算平衡表看起来是三种不同的文档,但它们来自同一份数据。日记账按日期顺序列出分录。总账拿同样的分录按科目重新分组,再加上逐笔余额。试算平衡表把每个科目折叠成一行。三者的区别只是 GROUP BY 不同。

所以设计是一种数据类型、三个汇总函数、一个渲染函数。渲染函数就是 gpdf 的 c.Table,它很薄。正因如此,本文大部分篇幅花在汇总这一侧。

本文写给用 Go 开发会计、计费或费用报销系统、并在需求里看到"报表输出"几个字的人。

示例采用日本的簿记:报表名称是 仕訳帳 (日记账)、総勘定元帳 (总账) 和 合計残高試算表 (试算平衡表),科目名称也是日文。结构就是各地通用的复式记账。保留日文文本也说明了一点:CJK 输出除了注册字体之外不需要任何特殊处理。

数据模型:只有一种分录

type Entry struct {
	Date, Debit, Credit, Memo string
	Amount                    int64
}

一笔分录就是一个借方科目、一个贷方科目和一个金额。金额是以整数日元表示的 int64。如果用 float64,试算平衡表差 1 的那天,你会花一整晚去找原因。在 0.1 + 0.2 不等于 0.3 的世界里,借贷相等的检查是做不成的。

科目用两张表来描述。

var accounts = []string{"現金", "普通預金", "売掛金", "買掛金", "資本金", "売上高", "仕入高", "支払家賃"}

var debitSide = map[string]bool{"現金": true, "普通預金": true, "売掛金": true, "仕入高": true, "支払家賃": true}

accounts 是显示顺序:现金、银行存款、应收账款、应付账款、资本金、销售收入、采购、租金。总账和试算平衡表都按这个顺序排列。debitSide 标记在借方增加的科目,也就是资产和费用。负债、所有者权益和收入在贷方增加。总账计算余额列时需要这个方向。

这是一种简化。真实的分录并不总是一借一贷。一笔工资分录有一行借方和三行贷方:银行存款、代扣所得税和代扣社会保险。要支持复合分录,把 Entry 改成凭证号加一个明细行切片即可。汇总函数改为遍历明细行,渲染代码完全不用动。

完整代码

把 NotoSansJP-Regular.ttf 放到执行过 go mod init 的目录里,并把下面的代码保存为 main.go。已在 gpdf v1.0.17 上测试。

go get github.com/gpdf-dev/gpdf
package main

import (
	"fmt"
	"log"
	"os"
	"sort"
	"strconv"

	"github.com/gpdf-dev/gpdf/document"
	"github.com/gpdf-dev/gpdf/pdf"
	"github.com/gpdf-dev/gpdf/template"
)

// Entry 是一笔分录。金额用 int64 表示整数日元,绝不用 float64。
type Entry struct {
	Date, Debit, Credit, Memo string
	Amount                    int64
}

// 按显示顺序排列的科目表:现金、银行存款、应收账款、
// 应付账款、资本金、销售收入、采购、租金费用。
var accounts = []string{"現金", "普通預金", "売掛金", "買掛金", "資本金", "売上高", "仕入高", "支払家賃"}

// 正常余额在借方的科目 (资产和费用)。其余科目的正常余额在贷方。
var debitSide = map[string]bool{"現金": true, "普通預金": true, "売掛金": true, "仕入高": true, "支払家賃": true}

func yen(n int64) string {
	if n == 0 {
		return ""
	}
	s := strconv.FormatInt(n, 10)
	for i := len(s) - 3; i > 0; i -= 3 {
		s = s[:i] + "," + s[i:]
	}
	return s
}

func newDoc(font []byte, title string) *template.Document {
	doc := template.New(
		template.WithPageSize(document.A4),
		template.WithMargins(document.UniformEdges(document.Mm(15))),
		template.WithFont("NotoSansJP", font),
		template.WithDefaultFont("NotoSansJP", 9),
	)
	doc.Header(func(p *template.PageBuilder) {
		p.AutoRow(func(r *template.RowBuilder) {
			r.Col(6, func(c *template.ColBuilder) {
				c.Text(title, template.FontSize(16))
			})
			r.Col(6, func(c *template.ColBuilder) {
				c.Text("株式会社サンプル商事", template.AlignRight())
				c.Text("自 2026年4月1日 至 2026年4月30日", template.AlignRight())
			})
		})
		p.AutoRow(func(r *template.RowBuilder) {
			r.Col(12, func(c *template.ColBuilder) { c.Spacer(document.Mm(4)) })
		})
	})
	doc.Footer(func(p *template.PageBuilder) {
		p.AutoRow(func(r *template.RowBuilder) {
			r.Col(12, func(c *template.ColBuilder) {
				c.Text(document.PageNumberPlaceholder+" / "+document.TotalPagesPlaceholder,
					template.AlignCenter(), template.FontSize(8), template.TextColor(pdf.Gray(0.4)))
			})
		})
	})
	return doc
}

func table(c *template.ColBuilder, header []string, rows [][]string, widths []float64, aligns []document.TextAlign) {
	// 截至 v1.0.17,c.Table 没有单元格内边距选项,文字会贴着
	// 单元格边框。在对齐的一侧加一个空格,让文字离开框线。
	pad := func(row []string) []string {
		out := make([]string, len(row))
		for i, s := range row {
			switch {
			case s == "":
			case aligns[i] == document.AlignRight:
				out[i] = s + " "
			case aligns[i] == document.AlignLeft:
				out[i] = " " + s
			default:
				out[i] = s
			}
		}
		return out
	}
	header = pad(header)
	padded := make([][]string, len(rows))
	for i, row := range rows {
		padded[i] = pad(row)
	}
	grid := template.Border(template.BorderWidth(document.Pt(0.5)), template.BorderColor(pdf.Gray(0.3)))
	c.Table(header, padded,
		template.ColumnWidths(widths...),
		template.ColumnAlign(aligns...),
		template.TableHeaderStyle(template.BgColor(pdf.RGBHex(0xEEEEEE))),
		template.WithTableCellBorder(grid),
		template.WithTableBorderCollapse(true),
	)
}

func save(doc *template.Document, name string) {
	b, err := doc.Generate()
	if err != nil {
		log.Fatal(err)
	}
	if err := os.WriteFile(name, b, 0o644); err != nil {
		log.Fatal(err)
	}
}

const L, R = document.AlignLeft, document.AlignRight

// 日记账 (仕訳帳):每笔分录一行,按日期顺序。
func journal(font []byte, entries []Entry) {
	var total int64
	rows := make([][]string, 0, len(entries)+1)
	for _, e := range entries {
		rows = append(rows, []string{e.Date, e.Debit, yen(e.Amount), e.Credit, yen(e.Amount), e.Memo})
		total += e.Amount
	}
	rows = append(rows, []string{"", "合計", yen(total), "", yen(total), ""})

	doc := newDoc(font, "仕訳帳")
	doc.AddPage().AutoRow(func(r *template.RowBuilder) {
		r.Col(12, func(c *template.ColBuilder) {
			table(c, []string{"日付", "借方科目", "借方金額", "貸方科目", "貸方金額", "摘要"}, rows,
				[]float64{10, 14, 14, 14, 14, 34}, []document.TextAlign{L, L, R, L, R, L})
		})
	})
	save(doc, "journal.pdf")
}

// 总账 (総勘定元帳):把每笔分录过账到对应科目,并保持逐笔余额。
func ledger(font []byte, entries []Entry) {
	doc := newDoc(font, "総勘定元帳")
	page := doc.AddPage()
	for _, acct := range accounts {
		var bal int64
		var rows [][]string
		for _, e := range entries {
			var dr, cr int64
			var counter string
			switch acct {
			case e.Debit:
				dr, counter = e.Amount, e.Credit
			case e.Credit:
				cr, counter = e.Amount, e.Debit
			default:
				continue
			}
			if debitSide[acct] {
				bal += dr - cr
			} else {
				bal += cr - dr
			}
			rows = append(rows, []string{e.Date, counter, e.Memo, yen(dr), yen(cr), yen(bal)})
		}
		if len(rows) == 0 {
			continue
		}
		page.AutoRow(func(r *template.RowBuilder) {
			r.Col(12, func(c *template.ColBuilder) {
				c.Text(acct, template.FontSize(11))
				c.Spacer(document.Mm(1))
				table(c, []string{"日付", "相手科目", "摘要", "借方", "貸方", "残高"}, rows,
					[]float64{10, 14, 34, 14, 14, 14}, []document.TextAlign{L, L, L, R, R, R})
				c.Spacer(document.Mm(5))
			})
		})
	}
	save(doc, "ledger.pdf")
}

// 试算平衡表 (合計残高試算表):每个科目的借方合计、贷方合计,以及净余额。
func trialBalance(font []byte, entries []Entry) {
	dr, cr := map[string]int64{}, map[string]int64{}
	for _, e := range entries {
		dr[e.Debit] += e.Amount
		cr[e.Credit] += e.Amount
	}
	var sumDr, sumCr, balDr, balCr int64
	var rows [][]string
	for _, a := range accounts {
		var bd, bc int64
		if d := dr[a] - cr[a]; d > 0 {
			bd = d
		} else {
			bc = -d
		}
		rows = append(rows, []string{yen(bd), yen(dr[a]), a, yen(cr[a]), yen(bc)})
		sumDr, sumCr, balDr, balCr = sumDr+dr[a], sumCr+cr[a], balDr+bd, balCr+bc
	}
	if sumDr != sumCr || balDr != balCr {
		log.Fatalf("out of balance: totals %d/%d balances %d/%d", sumDr, sumCr, balDr, balCr)
	}
	rows = append(rows, []string{yen(balDr), yen(sumDr), "合計", yen(sumCr), yen(balCr)})

	doc := newDoc(font, "合計残高試算表")
	doc.AddPage().AutoRow(func(r *template.RowBuilder) {
		r.Col(12, func(c *template.ColBuilder) {
			table(c, []string{"借方残高", "借方合計", "勘定科目", "貸方合計", "貸方残高"}, rows,
				[]float64{19, 19, 24, 19, 19}, []document.TextAlign{R, R, document.AlignCenter, R, R})
		})
	})
	save(doc, "trial_balance.pdf")
}

func main() {
	font, err := os.ReadFile("NotoSansJP-Regular.ttf")
	if err != nil {
		log.Fatal(err)
	}
	entries := []Entry{
		{"04/01", "普通預金", "資本金", "設立出資", 3000000},
		{"04/01", "現金", "普通預金", "小口現金引出", 100000},
	}
	// 生产环境里这是一次数据库查询。演示时生成一个月的分录。
	for d := 2; d <= 30; d++ {
		day := fmt.Sprintf("04/%02d", d)
		entries = append(entries,
			Entry{day, "仕入高", "買掛金", fmt.Sprintf("商品仕入 伝票 P-%03d", d), int64(40000 + d*1300)},
			Entry{day, "売掛金", "売上高", fmt.Sprintf("商品売上 伝票 S-%03d", d), int64(88000 + d*2100)},
		)
		if d%10 == 0 {
			entries = append(entries,
				Entry{day, "普通預金", "売掛金", "売掛金回収", 600000},
				Entry{day, "買掛金", "普通預金", "買掛金支払", 300000},
			)
		}
	}
	entries = append(entries, Entry{"04/30", "支払家賃", "普通預金", "5月分家賃", 180000})
	sort.SliceStable(entries, func(i, j int) bool { return entries[i].Date < entries[j].Date })

	journal(font, entries)
	ledger(font, entries)
	trialBalance(font, entries)
}

go run . 会写出 journal.pdf、ledger.pdf 和 trial_balance.pdf。下面是总账的第一页。

用 gpdf 生成的总账第一页:现金、银行存款和应收账款科目的带框线表格,包含日期、对方科目、摘要、借方、贷方和余额列

日记账:设置列宽和对齐

日记账是三者中最简单的。每笔分录变成一行,最后加一行合计。

table(c, []string{"日付", "借方科目", "借方金額", "貸方科目", "貸方金額", "摘要"}, rows,
	[]float64{10, 14, 14, 14, 14, 34}, []document.TextAlign{L, L, R, L, R, L})

这里指定了两件事。template.ColumnWidths 接收百分比形式的列宽,加起来是 100。template.ColumnAlign 为每一列接收一个对齐方式:金额右对齐,其余左对齐。ColumnAlign 对表头行同样生效,所以列标题会右对齐地落在数字上方。

千位分隔符由 yen 函数在值进入表格之前加上。gpdf 没有数字格式化功能。c.Table 接收的是 [][]string,每个单元格怎么格式化由调用方负责。零会变成空字符串,因为在总账里,有借方金额的行,贷方单元格应该留空。印满 0 的报表很难读。

合计行在借方和贷方下面放的是同一个 total。分录一借一贷时,这按定义就成立。如果扩展到复合分录,要把两边分开求和。

总账:过账与逐笔余额

总账就是把日记账过账到各个科目。如果一笔分录借记应收账款、贷记销售收入,它会在应收账款分类账的借方列出现一次,在销售收入分类账的贷方列出现一次。这就是 67 笔分录变成 134 行总账的原因。

代码对每个科目扫描所有分录。

switch acct {
case e.Debit:
	dr, counter = e.Amount, e.Credit
case e.Credit:
	cr, counter = e.Amount, e.Debit
default:
	continue
}

如果该科目在借方,金额进入借方列,贷方科目记为对方科目。如果在贷方,则反过来。否则跳过这笔分录。

这是一个科目 × 分录的循环,8 × 67 根本不算什么。到了 300 个科目、100,000 笔分录时,先把分录分桶放进 map[string][]Entry。到那个规模,汇总多半本来就在 SQL 里做了,Go 拿到的是已经按科目分好组的行。

余额列用 debitSide 决定方向。

if debitSide[acct] {
	bal += dr - cr
} else {
	bal += cr - dr
}

银行存款是借方余额科目,所以存入 (借方) 时增加,支付 (贷方) 时减少。应付账款是贷方余额科目,所以正好相反。漏掉这个分支,你会得到一份余额越来越负的应付账款分类账。数字在算术上是对的,财务团队照样会退回来,这让它成为比较难解释的 bug 之一。

顺便一提,借方在左、贷方在右并没有逻辑上的理由。这是复式记账在 15 世纪的意大利成形以来一直沿用的惯例,只能死记。这两个词本身也帮不上忙:"debit"和"credit"描述的是对方的立场,和大多数初学者的预期正好相反。把变量命名为 dr 和 cr,至少不会让代码再添一层混乱。

在布局这一侧,每个科目添加一个 page.AutoRow。

page.AutoRow(func(r *template.RowBuilder) {
	r.Col(12, func(c *template.ColBuilder) {
		c.Text(acct, template.FontSize(11))
		c.Spacer(document.Mm(1))
		table(c, /* ... */)
		c.Spacer(document.Mm(5))
	})
})

科目名称、表格、间距,叠八次。这段代码里没有任何地方计算一页在哪里结束。

试算平衡表:渲染之前先失败

试算平衡表按科目显示借方合计和贷方合计,并把差额放在它所落的那一侧。按惯例科目名称在中间,借方在左、贷方在右,所以各列依次是借方余额、借方合计、科目、贷方合计、贷方余额。

这张报表最重要的几行在画表格之前。

if sumDr != sumCr || balDr != balCr {
	log.Fatalf("out of balance: totals %d/%d balances %d/%d", sumDr, sumCr, balDr, balCr)
}

试算平衡表存在的意义就是发现过账错误。如果借方合计和贷方合计不一致,说明某处有一笔分录坏了。把一份不平衡的 PDF 排得漂漂亮亮没有意义。应该直接停下。

在示例数据中,合计是 11,269,600 日元,余额是 7,389,600 日元,两边一致。只有一借一贷的分录时,它们必然一致。一旦数据来自数据库,这三行就开始起作用了。在生产环境里,应返回 error 而不是调用 log.Fatalf,这样才能查出是哪个科目不对。

分页:什么都不用做

上面的代码没有任何分页逻辑。它不数行数,也不看剩余高度。

当表格在剩余空间里放不下时,gpdf 的布局引擎会拆分表体,并在下一页顶部重新绘制表头行。日记账的第 2 页以表头行开始,总账里应收账款表格的续页也是如此。doc.Header 里的报表标题和会计期间、doc.Footer 里的页码会出现在每一页上。机制的说明见如何让表格跨多页输出?。

页码是普通字符串里的两个占位符。

c.Text(document.PageNumberPlaceholder+" / "+document.TotalPagesPlaceholder, ...)

总页数在分页完成之后才填入。参见如何在 gpdf 页脚显示页码「第 X 页 共 Y 页」?。

实测数据

以下数字只针对日记账,使用按不同数量生成的销售分录。Apple M1,Go 1.25.2,gpdf v1.0.17。计时范围是从字体字节已在内存中,到 PDF 写入磁盘为止。

分录数页数耗时文件大小
6828.8 ms146 KB
1,0001768 ms1.9 MB
10,000170~0.7 s19 MB

68 笔分录 8.8 ms,和 gpdf 单页 13 µs 的基准数字相比显得很慢。这段时间大部分花在字体上:Noto Sans JP 是一个 9.6 MB 的 TTF,每次运行都要解析并做子集化。除此之外,每多一行的开销约为 70 µs,增长是线性的。对于每晚为整个公司生成月度报表的批处理来说,数据库查询会先于 PDF 生成成为瓶颈。

文件大小需要直说。每页约 115 KB。gpdf v1.0.17 会压缩字体流和图片流,但不压缩页面的绘图操作符,而每个单元格都带边框的报表有大量操作符。10,000 行 19 MB,作为邮件附件太大了。按月拆分,或者去掉格线改用 template.TableStripe 斑马纹,是比较实际的绕过办法。

我们放弃了什么

单元格内边距。 v1.0.17 的 c.Table 没有设置单元格内边距的选项。加上单元格边框后,文字会贴着线。示例里的 table 函数在每个单元格的对齐一侧加了一个空格。不好看,但能用。用斑马纹代替边框的话,这个问题不会出现。

页小计与过次页行。 纸质账簿传统上在每页末尾写一行"过次页",在下一页开头写一行"承前页"。要做到这一点,汇总代码必须知道哪些行落在哪一页。gpdf 在布局阶段才决定分页,所以 builder API 没法告诉你。你可以固定每页的行数并自己分页,但那意味着放弃自动分页。我们还没有找到好的做法。

法律要求。 这段代码生成的是看起来像账簿的 PDF。它们是否满足你所在法域的记账保存规定,是另一个问题。以日本为例,在会计系统中创建的账簿一般依据《电子账簿保存法》以数据形式保存,PDF 只是用于查看或提交的输出。中国的电子会计档案规定同样是需要另行确认的问题。请查阅国税厅的指引 (日文) 或咨询你的会计师。

FAQ

用 Go 输出会计 PDF 需要报表工具吗? 对于日记账、总账、试算平衡表这类以表格为主的报表,不需要。把 [][]string 传给 gpdf 的 c.Table,就能得到边框、列宽、右对齐、自动分页和重复的表头行。gpdf 采用 MIT 许可证,零外部依赖,所以也没有运行时许可证。

可以用横向页面吗? 可以。把选项改成 template.WithPageSize(document.A4.Landscape())。摘要较长的日记账用横向更好读。列宽是百分比,所以代码的其他部分不用改。

日文以外的文字能用吗? 能。替换字符串,并用 template.WithFont 注册一个覆盖你所用文字的字体。对于日文,Noto Sans JP、IPAex Gothic 和 Source Han Sans 的用法都一样。参见如何在 gpdf 中嵌入日文字体?。

负数金额可以用红字或括号显示吗? 括号或负号属于字符串格式化的问题,在 yen 里处理即可。c.Table 没有按单元格设置文字颜色的选项。要显示红字,请按 Go PDF 表格:列宽、斑马纹、分页头里说明的方式直接构造 document.Table。

可以输出用于长期归档的 PDF/A 吗? 可以。向 gpdf.NewDocument 传入 gpdf.WithPDFA()。PDF/A 要求嵌入字体,而 CJK 报表本来就嵌入了。

上手 gpdf

gpdf 是一个 Go 的 PDF 生成库。MIT 许可证,零外部依赖,原生支持 CJK。

go get github.com/gpdf-dev/gpdf

⭐ 在 GitHub 上 Star · 阅读文档

延伸阅读

如果你想出了实现过次页行的干净做法,请在 GitHub 上提 issue。