「見積書や請求書を Excel のテンプレートで作っていて、転記ミスや計算ミスが心配」「明細が多いと、2ページ目に表の見出しが無くて読みにくい」。帳票をシステムで自動作成したいという相談はとても多く、PDF管理システムでも定番の機能です。今回は、宛先・明細・税率などのデータ(JSON)から、日本語の見積書・請求書PDFを一から作り、そのままシステムに保存します。
前回の第9回:PDFフォーム(AcroForm)に入力するでは、既存のPDFの入力欄に値を入れました。今回は既存のPDFを使わず、白紙から帳票を組み立てます。
「請求書のPDFを作るだけなら、Excel から書き出すのと何が違うの?」
結論から言うと、システムで作る価値は「金額の計算と帳票の形を、毎回同じルールで確実に作れること」です。特に消費税の計算は、「税率ごとに合計してから、端数を1回だけ処理する」といったルールを守る必要があり、手作業では間違いが起きやすい部分です。今回は計算部分を単体テストで確かめられる形に分け、PDFのレイアウト(改ページ・合計欄の配置)とあわせて実装します。
帳票生成の全体像
今回作るもの
| 部分 | 内容 | ファイル |
|---|---|---|
| データの形と金額計算 | 明細ごとの金額、税率ごとの合計と消費税、総額 | shared/utils/invoice.ts(画面とサーバーで共通) |
| 単体テスト | 端数処理・税率別の集計・小数の数量・明細30行 | test/invoice-calc.test.ts |
| PDFのレイアウト | 見出し・宛先・発行元・合計金額の枠・明細表・合計欄・ページ番号 | server/utils/pdf/invoice.ts |
| API | 入力の検証 → PDF生成 → 新しいファイルとして保存 | server/api/documents/invoice.post.ts |
| 画面 | 入力フォーム、入力中の合計表示 | app/pages/documents/invoice.vue |
生成したPDFは第7回の版管理に乗せ、「新しいファイルの第1版(帳票生成)」として保存します。以後は、第6回のプレビュー、第8回の書き込み(承認印など)がそのまま使えます。
発行元の情報は画面から受け取らない
自社名・住所・登録番号・振込先は、画面から送らせずに、サーバーの設定(runtimeConfig.invoiceIssuer、環境変数 NUXT_INVOICE_ISSUER_NAME など)から入れます。画面から送れる作りにすると、利用者が振込先を書き換えた請求書を作れてしまうからです。
請求書の記載事項と消費税の端数処理
帳票の項目を決める前に、請求書に何を書く必要があるかを確認しておきます。国税庁の「インボイス制度について」では、インボイス(適格請求書)の記載事項として次の6つが挙げられています。
📰 出典:国税庁「インボイス制度について」(インボイスの記載事項について)
| 記載事項(国税庁の説明による) | このサンプルでの項目 |
|---|---|
| ① インボイスの交付先である相手方の氏名または名称 | 宛先 |
| ② 売手(自社)の氏名又は名称及び登録番号 | 発行元の名称、登録番号(サーバーの設定) |
| ③ 取引年月日 | 発行日(※下の注意を参照) |
| ④ 取引内容(軽減税率の対象品目である旨) | 明細の品目。8%の品目に「※」を付け、欄外に説明 |
| ⑤ 10%・8%それぞれの対象となる対価の総額及び適用税率 | 合計欄の「10%対象」「8%対象」 |
| ⑥ 10%・8%それぞれの消費税額等 | 合計欄の「消費税(10%)」「消費税(8%)」 |
消費税の1円未満の端数処理については、国税庁のチェックシートで「1インボイス当たり、税率ごとにそれぞれ1回」とされ、商品・明細行ごとの端数処理は誤りの例として示されています。サンプルもこのルールで計算します。
ただし、この表は技術的な実装の参考として整理したもので、税務上の判断を示すものではありません。たとえば「取引年月日」は、発行日ではなく実際の取引日(納品日や作業月)を書く必要がある場合があります。自社の請求書に何をどう記載するか、端数を切り捨て・四捨五入・切り上げのどれにするか、登録番号の扱いなどは、必ず経理部門や税理士などの専門家に確認してください。
金額計算:税率ごとに合計してから1回だけ端数処理
shared/utils/invoice.ts(抜粋)
/** 選べる消費税率(%)。税率は法改正で変わるため、計算式ではなくここで管理する(執筆時点:10%・8%) */
export const TAX_RATES = [10, 8] as const
export type TaxRate = (typeof TAX_RATES)[number]
/** 1円未満の端数処理(どれを使うかは経理部門と決める) */
export const ROUNDING_MODES = { floor: '切り捨て', round: '四捨五入', ceil: '切り上げ' } as const
/** 0以上の整数 n / d を、指定の方法で整数に丸める(小数の誤差を避けるため整数で計算する) */
function divide(n: number, d: number, mode: RoundingMode): number {
if (mode === 'floor') return Math.floor(n / d)
if (mode === 'ceil') return Math.ceil(n / d)
return Math.floor((2 * n + d) / (2 * d)) // 四捨五入
}
export function calcInvoice(items: InvoiceItem[], rounding: RoundingMode): InvoiceTotals {
// 数量は100倍して整数にしてから掛ける(0.1 × 3 などの誤差を避ける)
const lineAmounts = items.map((i) => divide(i.unitPrice * Math.round(i.quantity * 100), 100, rounding))
const byRate = TAX_RATES.map((rate) => {
const subtotal = items.reduce((sum, item, idx) => (item.taxRate === rate ? sum + lineAmounts[idx]! : sum), 0)
return { rate, subtotal, tax: divide(subtotal * rate, 100, rounding) }
}).filter((r) => r.subtotal > 0)
const subtotal = byRate.reduce((s, r) => s + r.subtotal, 0)
const tax = byRate.reduce((s, r) => s + r.tax, 0)
return { lineAmounts, byRate, subtotal, tax, total: subtotal + tax }
}
- 金額は円の整数で扱う:JavaScript の数値は小数の計算で誤差が出ます(
0.1 * 3が0.30000000000000004になるなど)。数量は小数第2位まで受け付け、100倍して整数にしてから計算します。 - 消費税は税率ごとに1回:明細ごとに消費税を計算して足すのではなく、税率ごとに税抜き金額を合計してから税率を掛け、端数を1回だけ処理します。
- 税率は定数にまとめる:税率は法改正で変わることがあります。計算式の中に
0.1と書かず、選べる税率をTAX_RATESにまとめておくと、変更時に直す場所が1か所で済みます。 - 画面とサーバーで同じ計算:
shared/utils/に置いた関数は、Nuxt 4 では画面とサーバーの両方で自動インポートされます(Nuxt 公式ドキュメント shared/)。入力中の合計表示とPDFの金額が食い違うことを防げます。サーバーは画面から送られた金額を信用せず、明細から計算し直します。
このサンプルは税抜き価格(外税)での計算のみです。税込み価格(内税)の明細や、値引き行(マイナスの金額)には対応していません。
計算を単体テストで確かめる
計算部分はPDFと切り離した関数なので、Node.js 標準のテストランナー(node --test)だけでテストできます。Node.js 24 は、型の注釈を取り除くだけで動く TypeScript ファイルを既定でそのまま実行できる(型チェックはしない)ため、追加のテスト用ライブラリは入れていません。
📰 出典:Node.js ドキュメント Modules: TypeScript(Type stripping)
test/invoice-calc.test.ts(抜粋)
test('消費税は税率ごとに合計してから1回だけ端数処理する', () => {
// 明細ごとに切り捨てると 3 + 3 + 3 = 9 円だが、合計 99 円の 10% を切り捨てるので 9 円
// 明細ごとに四捨五入すると 3 × 3 = 9 円だが、合計 99 円の 10% を四捨五入して 10 円
const items = [item(33, 1, 10), item(33, 1, 10), item(33, 1, 10)]
assert.equal(calcInvoice(items, 'floor').tax, 9)
assert.equal(calcInvoice(items, 'round').tax, 10)
assert.equal(calcInvoice(items, 'ceil').tax, 10)
})
test('10% と 8% を分けて集計する', () => {
const t = calcInvoice([item(1000, 3, 10), item(1080, 2, 8), item(505, 1, 8)], 'floor')
assert.deepEqual(t.byRate, [
{ rate: 10, subtotal: 3000, tax: 300 },
{ rate: 8, subtotal: 2665, tax: 213 }, // 2665 × 8% = 213.2 → 213
])
assert.equal(t.total, 6178)
})
npm test # node --test "test/**/*.test.ts"
PDFのレイアウト:改ページと合計欄
server/utils/pdf/invoice.ts(抜粋)
export async function renderInvoicePdf(input: InvoiceInput, issuer: Issuer) {
const totals = calcInvoice(input.items, input.rounding)
const doc = await PDFDocument.create()
const font = await embedJapaneseFont(doc)
// …(フォントに無い文字があれば 400。見出し・宛先・発行元・合計金額の枠を描く)
drawHeader()
input.items.forEach((item, idx) => {
// 下の余白(ページ番号の分)に入らなければ改ページして見出しから描く
if (y - ROW_H < MARGIN + 20) {
newPage()
drawHeader()
}
const reduced = item.taxRate === REDUCED_TAX_RATE
cell(COLUMNS[0], `${reduced ? '※' : ''}${item.name}`)
cell(COLUMNS[1], String(item.quantity))
// …(単位・単価・税率・金額の列)
y -= ROW_H
hline(page, y)
})
// 合計欄(小計・税率ごとの対象額と消費税・合計)と備考。入りきらなければ次のページへ
const needed = summary.length * 16 + 16 + noteLines.length * 14
if (y - needed < MARGIN + 20) newPage()
// …(合計欄と備考を描く)
// すべてのページにページ番号(総ページ数は最後まで描かないと分からないので、最後に入れる)
const pages = doc.getPages()
pages.forEach((p, i) => {
const label = `${i + 1} / ${pages.length}`
text(p, label, (PAGE_W - font.widthOfTextAtSize(label, 8)) / 2, MARGIN - 20, 8, GRAY)
})
return { data: await doc.save(), pageCount: pages.length, totals }
}
pdf-lib には表や改ページの機能はありません。「今どの高さまで描いたか」を変数 y で持ち、1行描くたびに行の高さだけ下げ、下の余白に入りそうになったら新しいページを追加します。
- 2ページ目以降にも表の見出し:改ページしたら「請求書 No.○○(続き)」と表の見出しを描いてから明細を続けます。
- 合計欄は分割しない:合計欄と備考が入りきらない場合は、明細の途中で切らずに合計欄ごと次のページに送ります。
- 長い品目名は「…」で省略:
widthOfTextAtSizeで文字の幅を測り、列に収まらなければ末尾を省略します。折り返して行の高さを変える方法もありますが、改ページの計算が複雑になるため、このサンプルでは省略を選びました。 - フォントは第8回と同じ:日本語フォント(IPAexゴシック)をサブセットで埋め込みます。明細30行・2ページの請求書で約4万バイトでした。
📰 出典:pdf-lib API ドキュメント PDFPage(drawText / drawRectangle / drawLine)
API と画面
server/api/documents/invoice.post.ts(抜粋)
export default defineEventHandler(async (event) => {
const user = await requireRole(event, 'editor')
const body = await readValidatedBody(event, (b) => invoiceBodySchema.safeParse(b))
if (!body.success) {
throw createError({ statusCode: 400, statusMessage: '帳票の入力内容が正しくありません' })
}
const input = body.data
if (input.dueDate && input.dueDate < input.issueDate) {
throw createError({ statusCode: 400, statusMessage: '期限は発行日以降の日付にしてください' })
}
const issuer = useRuntimeConfig(event).invoiceIssuer
const { data, pageCount, totals } = await renderInvoicePdf(input, issuer)
assertResultSize(data)
const title = DOCUMENT_KINDS[input.kind]
const originalName = sanitizeFileName(`${title}_${input.documentNo}_${input.recipient.name}.pdf`)
const { id } = await createFileWithFirstVersion({
originalName, data, pageCount, isEncrypted: false, operation: 'generate',
note: `生成:${title} No.${input.documentNo}(明細${input.items.length}行・合計 ${formatYen(totals.total)}円)`,
userId: user.id,
tagNames: [input.kind === 'invoice' ? '請求書' : '見積書'],
})
setResponseStatus(event, 201)
return { id, originalName, pageCount, total: totals.total, tax: totals.tax }
})
入力の検証(server/utils/invoice-input.ts)では、文書番号は英数字と -・_ のみ、日付は YYYY-MM-DD、数量は小数第2位まで、単価は0以上の整数、税率は TAX_RATES のどれか(zod 4 の z.literal(TAX_RATES))、明細は300行までに制限しています。保存したファイルには「請求書」「見積書」のタグを付けるので、第3回の検索でそのまま絞り込めます。
画面(app/pages/documents/invoice.vue)は、宛先や明細を入力する表と、入力中の合計(税率ごとの消費税を含む)を表示します。合計の表示には、サーバーと同じ calcInvoice を使っています。「PDFを作成する」を押すと、作成されたファイルの詳細画面に移動し、第6回のビューアでそのまま確認できます。ヘッダーのナビゲーションには、editor 以上にだけ「帳票作成」を表示しました。
動作確認の方法
npm test
npm run build
# 発行元の情報は環境変数で渡す(.env.example を参照)
node .output/server/index.mjs
| 操作 | 結果 |
|---|---|
npm test(4件) | すべて成功 |
| 明細30行(10%と8%の混在、数量 2.5 などを含む)の請求書 | 2ページ。合計・税額が、別に計算した期待値(合計 456,485円、消費税 40,980円)と一致 |
| 明細100行 | 4ページ。2ページ目以降にも表の見出し、最後のページに合計欄 |
| 明細29行(1ページ目が明細で埋まる) | 合計欄が2ページ目に送られ、途中で切れない |
| 見積書・四捨五入・敬称「様」 | 表題「御見積書」、「有効期限」の表示、四捨五入で計算した合計が期待値と一致 |
| 長い品目名 | 列の幅で「…」に省略 |
税率5%/数量が小数第3位/期限が発行日より前/文書番号に //301行/マイナスの単価 | いずれも 400 |
| 品目に絵文字 | 400「フォントに無い文字が含まれています」 |
| viewer で作成 | 403 |
| 画面:3行入力 → 作成 | 入力中の合計(25,301円)とPDFの合計が一致。詳細画面に移動し、履歴に「帳票生成」 |
出来上がったPDFは pdf.js と MuPDF で表示・テキスト抽出を確認しました。印刷したときの見え方や、Acrobat Reader での表示は確認していません。
つまずきやすい点・注意点
- レイアウトの調整に時間がかかる:座標を数字で指定して描くため、「宛先をもう少し大きく」「ロゴを入れたい」といった調整のたびにコードを直し、PDFを作って確かめる作業が発生します。
- 文書番号の重複:このサンプルは文書番号を入力に任せており、重複の確認や自動採番はしていません。本番では DB で連番を管理し、同じ番号の請求書を二重に発行しないようにします。
- 発行後の訂正:一度取引先に送った請求書を訂正する場合の扱い(再発行・訂正版の発行など)は、経理のルールに合わせて決める必要があります。生成したPDFも第7回の版管理に乗りますが、「どの版を送ったか」の記録は別途必要です。
- 税率と端数処理の変更:税率の追加・変更、端数処理の方法の変更があったときに、過去の帳票の再発行をどう扱うかも決めておきます。
発注者向けメモ:帳票は「項目」と「計算ルール」を先に決める
帳票の自動作成は、画面から見ると単純な機能ですが、記載項目・計算ルール・レイアウトが決まっていないと、開発の途中で何度も作り直しが発生します。開発会社に頼む前に、経理部門と次の点を確定させておくと、見積もりの精度が上がり、手戻りも減ります。
| 決めること | 例 |
|---|---|
| 記載項目 | 宛先、登録番号、取引年月日(発行日と違う場合)、振込先、備考、担当者名、社印の有無 |
| 計算ルール | 外税か内税か、端数処理の方法、値引き・送料の扱い、軽減税率の品目の表示方法 |
| 改ページの仕様 | 2ページ目以降に何を表示するか、合計欄の位置 |
| 番号の管理 | 文書番号の形式、採番のタイミング、欠番・取消の扱い |
| 発行後の運用 | 送付方法(メール・郵送・電子取引)、訂正時の扱い、保存期間 |
- 今使っている帳票の実物を渡す:Excel のテンプレートや過去の請求書(金額は伏せてかまいません)を渡すと、項目とレイアウトの認識合わせが早く進みます。
- 計算の期待値を用意する:「この明細ならこの合計になるはず」という例を経理部門から数件もらい、テストに使ってもらいます。
- 税務の確認は専門家に:記載事項・端数処理・保存の方法は、経理部門や税理士に確認します。開発会社は帳票を作る専門家ですが、税務判断の責任は負えないのが一般的です。
打ち合わせでは、次のように聞いてみてください。
- 「消費税の計算は、税率ごとに合計してから端数処理していますか?その確認はテストで行っていますか?」
- 「税率や端数処理の方法が変わったとき、どこを直せば対応できますか?」
- 「明細が多いときの改ページは、どういう仕様になりますか?」
- 「文書番号の重複や、二重発行を防ぐ仕組みはありますか?」
- 「レイアウトの修正は、何回まで見積もりに含まれていますか?」
まとめと次回予告
第10回では、データ(JSON)から見積書・請求書のPDFを生成し、システムに保存する機能を作りました。
- 金額計算は
shared/の関数にまとめ、画面の合計表示とPDF生成で同じ計算を使う。単体テストで確かめる - 消費税は税率ごとに合計してから端数を1回だけ処理する(国税庁のチェックシートで確認)
- pdf-lib には表や改ページの機能がないため、描いた高さを管理して改ページし、見出しと合計欄を配置する
- 記載事項・端数処理などの税務上の判断は、経理部門や税理士に確認する
次回はいよいよ最終回、「本番運用へ:S3への切り替えとデプロイ」です。PDFの保存先を開発用のローカルディスクから Amazon S3 に切り替え、署名付きURLでのダウンロード、環境変数の整理、本番サーバーでの起動方法をまとめます。連載全体の振り返りもお届けします。
この連載の記事一覧
この記事は連載「Nuxtで作るPDF管理システム」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Nuxtで作るPDF管理システム 第0回】全体像とNuxt 4プロジェクトの土台づくり
- 【Nuxtで作るPDF管理システム 第1回】PDFをアップロードして安全に保存する
- 【Nuxtで作るPDF管理システム 第2回】ファイル情報をDBに持ち、一覧を表示する
- 【Nuxtで作るPDF管理システム 第3回】検索・絞り込み・ページングをつける
- 【Nuxtで作るPDF管理システム 第4回】ログイン機能を入れる
- 【Nuxtで作るPDF管理システム 第5回】権限管理と「認可つきダウンロード」
- 【Nuxtで作るPDF管理システム 第6回】ブラウザでPDFをプレビューする
- 【Nuxtで作るPDF管理システム 第7回】PDFの結合と分割
- 【Nuxtで作るPDF管理システム 第8回】注釈・スタンプ・署名画像を貼る
- 【Nuxtで作るPDF管理システム 第9回】PDFフォーム(AcroForm)に入力する
- 【Nuxtで作るPDF管理システム 第10回】見積書・請求書をデータから生成する(この記事)
- 【Nuxtで作るPDF管理システム 第11回】本番運用へ:S3への切り替えとデプロイ










コメント
コメント一覧 (1件)
[…] 前回の第10回:見積書・請求書をデータから生成するでは、データから帳票PDFを作りました。これで機能はそろったので、今回は「本番で安全に動かす」ための仕上げです。記事の最後に、連載全体の振り返りと、本番運用までに残っている作業の一覧を載せています。 […]