MENU

問い合わせ


    【Nuxtで作るPDF管理システム 第10回】見積書・請求書をデータから生成する

    「見積書や請求書を 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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次