feat: 积分商城-供应商-对账-发票2

This commit is contained in:
2026-09-08 13:40:33 +08:00
parent fc5df9c048
commit 3abe4ce9c9
14 changed files with 542 additions and 136 deletions
+227 -73
View File
@@ -1,4 +1,5 @@
import { createHash } from 'node:crypto'
import { deflateSync } from 'node:zlib'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -104,6 +105,88 @@ const createGlyphMapper = font => {
return codePoint => fromFormat12(codePoint) || fromFormat4(codePoint)
}
// 保留使用的字形及复合字形依赖,避免嵌入整套中文字体超出文件接口大小限制。
const subsetFont = (font, usedGlyphs) => {
const tables = readTableDirectory(font)
for (const name of ['head', 'hhea', 'maxp', 'hmtx', 'loca', 'glyf']) {
if (!tables.has(name)) throw new Error(`字体缺少 ${name} 表,请配置中文 TrueType 字体`)
}
const head = tables.get('head').offset
const glyphCount = font.readUInt16BE(tables.get('maxp').offset + 4)
const longLoca = font.readInt16BE(head + 50) === 1
const loca = tables.get('loca').offset
const glyphOffset = id => longLoca ? font.readUInt32BE(loca + id * 4) : font.readUInt16BE(loca + id * 2) * 2
const selected = new Set([0, ...usedGlyphs])
for (const id of selected) {
const start = tables.get('glyf').offset + glyphOffset(id)
if (glyphOffset(id) === glyphOffset(id + 1) || font.readInt16BE(start) >= 0) continue
let pos = start + 10
let flags
do {
flags = font.readUInt16BE(pos)
selected.add(font.readUInt16BE(pos + 2))
pos += 4 + ((flags & 1) ? 4 : 2)
if (flags & 8) pos += 2
else if (flags & 64) pos += 4
else if (flags & 128) pos += 8
} while (flags & 32)
}
const chunks = []
const newLoca = Buffer.alloc((glyphCount + 1) * 4)
let length = 0
for (let id = 0; id < glyphCount; id += 1) {
newLoca.writeUInt32BE(length, id * 4)
if (!selected.has(id)) continue
const data = font.subarray(tables.get('glyf').offset + glyphOffset(id), tables.get('glyf').offset + glyphOffset(id + 1))
const padded = Buffer.alloc((data.length + 3) & ~3)
data.copy(padded)
chunks.push(padded)
length += padded.length
}
newLoca.writeUInt32BE(length, glyphCount * 4)
const kept = ['OS/2', 'cmap', 'cvt ', 'fpgm', 'gasp', 'glyf', 'head', 'hhea', 'hmtx', 'loca', 'maxp', 'name', 'post', 'prep'].filter(name => tables.has(name)).sort()
const checksum = bytes => {
let sum = 0
for (let i = 0; i < bytes.length; i += 4) sum = (sum + bytes.readUInt32BE(i)) >>> 0
return sum
}
const directory = Buffer.alloc(12 + kept.length * 16)
directory.writeUInt32BE(0x00010000, 0)
directory.writeUInt16BE(kept.length, 4)
const power = Math.floor(Math.log2(kept.length))
directory.writeUInt16BE(16 * 2 ** power, 6)
directory.writeUInt16BE(power, 8)
directory.writeUInt16BE(kept.length * 16 - 16 * 2 ** power, 10)
const result = [directory]
let offset = directory.length
let headOffset = 0
kept.forEach((name, index) => {
const table = tables.get(name)
let data = name === 'glyf' ? Buffer.concat(chunks) : name === 'loca' ? newLoca : Buffer.from(font.subarray(table.offset, table.offset + table.length))
if (name === 'head') {
data.writeUInt32BE(0, 8)
data.writeInt16BE(1, 50)
headOffset = offset
}
if (name === 'post') {
data = Buffer.from(data.subarray(0, 32))
data.writeUInt32BE(0x00030000, 0)
}
const padded = Buffer.alloc((data.length + 3) & ~3)
data.copy(padded)
const record = 12 + index * 16
directory.write(name, record, 4, 'ascii')
directory.writeUInt32BE(checksum(padded), record + 4)
directory.writeUInt32BE(offset, record + 8)
directory.writeUInt32BE(data.length, record + 12)
result.push(padded)
offset += padded.length
})
const output = Buffer.concat(result)
output.writeUInt32BE((0xb1b0afba - checksum(output)) >>> 0, headOffset + 8)
return output
}
const utf16Hex = value => {
const buffer = Buffer.from(value, 'utf16le')
buffer.swap16()
@@ -202,9 +285,18 @@ const normalizeInvoice = input => {
export const generateMockInvoicePdf = (invoiceInput, outputPath, _options = {}) => {
const invoice = normalizeInvoice(invoiceInput)
const commands = []
const encode = value => utf16Hex(String(value ?? ''))
let commands = []
const font = readFileSync(findFont(_options.fontPath))
const glyphFor = createGlyphMapper(font)
const characters = new Map()
const encode = value => Array.from(String(value ?? '')).map(character => {
if (!characters.has(character)) {
const glyph = glyphFor(character.codePointAt(0)) || (character === '¥' ? glyphFor('¥'.codePointAt(0)) : 0)
if (!glyph) throw new Error(`字体不支持字符“${character}”,请配置支持中文的 TTF 字体`)
characters.set(character, { cid: characters.size + 1, glyph })
}
return characters.get(character).cid.toString(16).padStart(4, '0')
}).join('')
const text = (value, x, y, size = 10, color = '0 0 0') => {
commands.push(`BT /F1 ${size} Tf ${color} rg 1 0 0 1 ${x} ${y} Tm <${encode(value)}> Tj ET`)
}
@@ -221,85 +313,147 @@ export const generateMockInvoicePdf = (invoiceInput, outputPath, _options = {})
}
}
const labelColor = '0.65 0.05 0.05'
const fitText = (value, x, y, width, size = 10, color = '0 0 0') => {
const content = String(value ?? '')
const length = Array.from(content).reduce((sum, character) => sum + (character.charCodeAt(0) < 128 ? 0.6 : 1), 0)
text(content, x, y, Math.min(size, width / Math.max(length, 1)), color)
}
const dateValue = typeof invoice.invoiceDate === 'number'
? new Date(invoice.invoiceDate).toISOString().slice(0, 10) : String(invoice.invoiceDate)
const invoiceDate = dateValue.replace(/^(\d{4})-(\d{2})-(\d{2}).*$/, '$1年$2月$3日')
const title = Number(invoice.invoiceType) === 9 ? '电子发票(增值税专用发票)' : '电子发票(普通发票)'
commands.push('1 1 1 rg 0 0 842 595 re f')
text('增值税电子普通发票(模拟)', 280, 548, 22, '0.65 0.05 0.05')
line(274, 540, 568, 540, 1.2, '0.65 0.05 0.05')
text('仅供系统联调测试,禁止报销', 302, 522, 11, '0.75 0.1 0.1')
text(title, (PAGE_WIDTH - title.length * 21) / 2, 546, 21, labelColor)
line(245, 531, 590, 531, 0.8, labelColor)
line(245, 527, 590, 527, 0.8, labelColor)
text('模拟发票', 52, 554, 13, labelColor)
text('仅供开发测试,禁止报销', 52, 534, 9, labelColor)
text('发票号码:', 625, 556, 9, labelColor)
fitText(invoice.invoiceNumber, 680, 556, 120, 9)
text('开票日期:', 625, 535, 9, labelColor)
fitText(invoiceDate, 680, 535, 120, 9)
text(`发票号码:${invoice.invoiceNumber}`, 610, 572, 9)
text(`发票代码:${invoice.documentInvoiceCode}`, 610, 555, 9)
text(`开票日期:${invoice.invoiceDate}`, 610, 538, 9)
text(`校验码:${invoice.checkCode}`, 610, 521, 9)
text(`结算单号:${invoice.settlementId || ''}`, 50, 521, 9)
rect(42, 100, 758, 388, labelColor)
line(42, 398, 800, 398, 0.7, labelColor)
line(42, 217, 800, 217, 0.7, labelColor)
line(42, 183, 800, 183, 0.7, labelColor)
line(70, 398, 70, 488, 0.7, labelColor)
line(421, 398, 421, 488, 0.7, labelColor)
line(449, 398, 449, 488, 0.7, labelColor)
;['购', '买', '方', '信', '息'].forEach((value, index) => text(value, 51, 474 - index * 15, 10, labelColor))
;['销', '售', '方', '信', '息'].forEach((value, index) => text(value, 430, 474 - index * 15, 10, labelColor))
text('名称:', 80, 464, 10, labelColor)
fitText(invoice.invoiceTitle, 111, 464, 300, 10)
text('统一社会信用代码/纳税人识别号:', 80, 422, 8, labelColor)
fitText(invoice.taxIdNumber, 242, 422, 169, 10)
text('名称:', 459, 464, 10, labelColor)
fitText(invoice.sellerName, 490, 464, 300, 10)
text('统一社会信用代码/纳税人识别号:', 459, 422, 8, labelColor)
fitText(invoice.sellerTaxId, 621, 422, 169, 10)
rect(42, 188, 758, 320)
line(42, 414, 800, 414)
line(42, 320, 800, 320)
line(42, 280, 800, 280)
line(42, 226, 800, 226)
line(120, 414, 120, 508)
line(120, 320, 120, 414)
line(475, 414, 475, 508)
line(475, 320, 475, 414)
text('购买方', 68, 457, 12, '0.55 0.05 0.05')
text('销售方', 68, 365, 12, '0.55 0.05 0.05')
text('名称:', 132, 488, 9)
wrappedText(invoice.invoiceTitle, 180, 488, 26, 9)
text('纳税人识别号:', 132, 464, 9)
text(invoice.taxIdNumber, 235, 464, 9)
text('地址、电话:', 487, 488, 9)
wrappedText(`${invoice.invoiceAddress || ''} ${invoice.invoiceContact || ''}`, 574, 488, 20, 8)
text('开户行及账号:', 487, 448, 9)
wrappedText(`${invoice.bankName || ''} ${invoice.bankAccount || ''}`, 574, 448, 20, 8)
text('名称:', 132, 395, 9)
text(invoice.sellerName, 180, 395, 9)
text('纳税人识别号:', 132, 371, 9)
text(invoice.sellerTaxId, 235, 371, 9)
text('地址、电话:', 487, 395, 9)
wrappedText(`${invoice.sellerAddress} ${invoice.sellerContact}`, 574, 395, 20, 8)
text('开户行及账号:', 487, 350, 9)
wrappedText(`${invoice.sellerBankName || '模拟银行'} ${invoice.sellerBankAccount || '0000000000000000'}`, 574, 350, 20, 8)
const columns = [42, 290, 382, 440, 495, 580, 655, 720, 800]
columns.slice(1, -1).forEach(x => line(x, 226, x, 320))
;['货物或应税劳务、服务名称', '规格型号', '单位', '数量', '单价', '金额', '税率', '税额'].forEach((label, index) => {
text(label, columns[index] + 7, 300, index === 0 ? 9 : 8)
const columns = [42, 240, 322, 385, 438, 510, 605, 692, 800]
;['项目名称', '规格型号', '单位', '数量', '单价', '金额', '税率/征收率', '税额'].forEach((label, index) => {
const x = (columns[index] + columns[index + 1] - label.length * 9) / 2
text(label, x, 382, 9, labelColor)
})
wrappedText(`*商品*${invoice.invoiceContent}`, 50, 263, 28, 9)
text(invoice.invoiceType || '电子发票', 300, 263, 8)
text('项', 401, 263, 9)
text(invoice.orderNum, 458, 263, 9)
text(money(invoice.naked / invoice.orderNum), 503, 263, 8)
text(money(invoice.naked), 590, 263, 8)
text(`${(invoice.taxRate * 100).toFixed(0)}%`, 674, 263, 8)
text(money(invoice.tax), 730, 263, 8)
wrappedText(`*商品*${String(invoice.invoiceContent).length > 50 ? '订单商品汇总(详见后附明细)' : invoice.invoiceContent}`, 50, 360, 20, 9, 14)
text('项', 344, 360, 9)
fitText(invoice.orderNum, 398, 360, 32, 9)
fitText(money(invoice.naked / invoice.orderNum), 445, 360, 60, 9)
fitText(money(invoice.naked), 526, 360, 73, 9)
fitText(`${Number((invoice.taxRate * 100).toFixed(4))}%`, 627, 360, 60, 9)
fitText(money(invoice.tax), 722, 360, 70, 9)
text('合 计', 92, 230, 10, labelColor)
fitText(`¥${money(invoice.naked)}`, 526, 230, 73, 10)
fitText(`¥${money(invoice.tax)}`, 722, 230, 70, 10)
text('合计', 240, 203, 10)
text(`¥${money(invoice.naked)}`, 590, 203, 10)
text(`¥${money(invoice.tax)}`, 730, 203, 10)
text(`价税合计(大写):${chineseAmount(invoice.total)}`, 52, 164, 11)
text(`(小写):¥${money(invoice.total)}`, 600, 164, 11)
text(`订单号:${invoice.orderIds || ''}`, 52, 132, 8)
wrappedText(`备注:${invoice.remark || '模拟开票,仅用于开发与联调测试'}`, 52, 111, 70, 8)
text('收款人:模拟收款员', 52, 65, 9)
text('复核:模拟复核员', 285, 65, 9)
text('开票人:模拟开票员', 510, 65, 9)
text('销售方(章)', 682, 65, 9, '0.7 0.05 0.05')
line(235, 183, 235, 217, 0.7, labelColor)
text('价税合计(大写)', 88, 196, 10, labelColor)
fitText(chineseAmount(invoice.total), 245, 196, 340, 11)
text('(小写)', 603, 196, 10, labelColor)
fitText(`¥${money(invoice.total)}`, 657, 196, 133, 11)
commands.push('q 0.92 0.92 0.92 rg BT /F1 34 Tf 0.866 0.5 -0.5 0.866 235 170 Tm')
commands.push(`<${encode('模拟发票 禁止报销')}> Tj ET Q`)
commands.push('0.75 0.08 0.08 RG 2 w 798 88 m 798 111.196 779.196 130 756 130 c 732.804 130 714 111.196 714 88 c 714 64.804 732.804 46 756 46 c 779.196 46 798 64.804 798 88 c S')
line(70, 100, 70, 183, 0.7, labelColor)
text('备', 51, 153, 10, labelColor)
text('注', 51, 130, 10, labelColor)
fitText(`结算单号:${invoice.settlementId || ''}`, 80, 165, 710, 9)
fitText(`订单号:${String(invoice.orderIds || '').length > 90 ? '详见后附订单商品明细' : invoice.orderIds || ''}`, 80, 147, 710, 9)
wrappedText(String(invoice.remark || '模拟开票,仅用于开发与联调测试'), 80, 129, 78, 9, 12)
text('开票人:', 90, 73, 10, labelColor)
fitText(invoice.biller || '模拟开票员', 140, 73, 250, 10)
const content = Buffer.from(commands.join('\n'), 'ascii')
const pages = [Buffer.from(commands.join('\n'), 'ascii')]
const rows = (Array.isArray(invoice.orderProducts) ? invoice.orderProducts : []).flatMap(product => {
const name = Array.from(String(product.name || product.sku || '商品'))
const lines = []
for (let i = 0; i < name.length; i += 28) {
lines.push({ ...product, name: name.slice(i, i + 28).join(''), continued: i > 0 })
}
return lines
})
for (let offset = 0; offset < rows.length; offset += 18) {
commands = []
text('订单商品明细(模拟发票附件)', 270, 550, 18)
text(`结算单号:${invoice.settlementId || ''}`, 42, 522, 10)
text(`发票号码:${invoice.invoiceNumber}`, 440, 522, 10)
text('订单号', 42, 485, 10)
text('商品名称', 285, 485, 10)
text('数量', 610, 485, 10)
text('单价(元)', 665, 485, 10)
line(42, 476, 800, 476)
rows.slice(offset, offset + 18).forEach((product, index) => {
const y = 453 - index * 21
text(product.orderId, 42, y, 8)
text(product.name, 285, y, 9)
if (!product.continued) {
text(product.number, 610, y, 9)
text(money(product.price), 665, y, 9)
}
})
text(`第 ${pages.length + 1} 页 · 仅供开发测试,禁止报销`, 290, 35, 9)
pages.push(Buffer.from(commands.join('\n'), 'ascii'))
}
const tables = readTableDirectory(font)
const head = tables.get('head').offset
const hhea = tables.get('hhea').offset
const units = font.readUInt16BE(head + 18)
const scale = value => Math.round(value * 1000 / units)
const metricsCount = font.readUInt16BE(hhea + 34)
const cidMap = Buffer.alloc((characters.size + 1) * 2)
const widths = []
const unicode = []
for (const [character, { cid, glyph }] of characters) {
cidMap.writeUInt16BE(glyph, cid * 2)
widths.push(scale(font.readUInt16BE(tables.get('hmtx').offset + Math.min(glyph, metricsCount - 1) * 4)))
unicode.push(`<${cid.toString(16).padStart(4, '0')}> <${utf16Hex(character)}>`)
}
const cmap = ['/CIDInit /ProcSet findresource begin', '12 dict begin', 'begincmap', '/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def', '/CMapName /InvoiceUnicode def', '/CMapType 2 def', '1 begincodespacerange', '<0000> <FFFF>', 'endcodespacerange']
for (let i = 0; i < unicode.length; i += 100) {
const batch = unicode.slice(i, i + 100)
cmap.push(`${batch.length} beginbfchar`, ...batch, 'endbfchar')
}
cmap.push('endcmap', 'CMapName currentdict /CMap defineresource pop', 'end', 'end')
const embedded = subsetFont(font, Array.from(characters.values(), item => item.glyph))
const writer = createPdfWriter()
writer.add('<< /Type /Catalog /Pages 2 0 R >>')
writer.add('<< /Type /Pages /Kids [3 0 R] /Count 1 >>')
const pageIds = [3, ...pages.slice(1).map((_, index) => 11 + index * 2)]
writer.add(`<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pages.length} >>`)
writer.add(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PAGE_WIDTH} ${PAGE_HEIGHT}] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>`)
writer.add(streamObject('', content))
writer.add('<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [6 0 R] >>')
writer.add('<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> /DW 1000 >>')
writer.add(streamObject('', pages[0]))
writer.add('<< /Type /Font /Subtype /Type0 /BaseFont /InvoiceFont /Encoding /Identity-H /DescendantFonts [6 0 R] /ToUnicode 10 0 R >>')
writer.add(`<< /Type /Font /Subtype /CIDFontType2 /BaseFont /InvoiceFont /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor 7 0 R /CIDToGIDMap 9 0 R /DW 1000 /W [1 [${widths.join(' ')}]] >>`)
const bbox = [36, 38, 40, 42].map(offset => scale(font.readInt16BE(head + offset))).join(' ')
writer.add(`<< /Type /FontDescriptor /FontName /InvoiceFont /Flags 4 /FontBBox [${bbox}] /ItalicAngle 0 /Ascent ${scale(font.readInt16BE(hhea + 4))} /Descent ${scale(font.readInt16BE(hhea + 6))} /CapHeight ${scale(font.readInt16BE(hhea + 4))} /StemV 80 /FontFile2 8 0 R >>`)
writer.add(streamObject(`/Filter /FlateDecode /Length1 ${embedded.length}`, deflateSync(embedded)))
writer.add(streamObject('', cidMap))
writer.add(streamObject('', Buffer.from(cmap.join('\n'), 'ascii')))
pages.slice(1).forEach((content, index) => {
const pageId = 11 + index * 2
writer.add(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PAGE_WIDTH} ${PAGE_HEIGHT}] /Resources << /Font << /F1 5 0 R >> >> /Contents ${pageId + 1} 0 R >>`)
writer.add(streamObject('', content))
})
writer.save(outputPath)
return outputPath
}
@@ -325,7 +479,7 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me
? JSON.parse(readFileSync(path.resolve(options.input), 'utf8'))
: JSON.parse(options.data)
const defaultName = `mock-invoice-${String(input.settlementId || Date.now()).replace(/[^a-zA-Z0-9_-]/g, '_')}.pdf`
const outputPath = path.resolve(options.output || path.join('output', 'pdf', defaultName))
const outputPath = path.resolve(options.output || path.join('temp', 'pdf', defaultName))
generateMockInvoicePdf(input, outputPath, { fontPath: options.font })
console.log(outputPath)
} catch (error) {
@@ -0,0 +1,39 @@
{
"supplierId": "99999999",
"settlementId": "TEST202609080001",
"invoiceNumber": "20260908000000000001",
"documentInvoiceCode": "TEST20260908",
"invoiceDate": "2026-09-08",
"invoiceType": 10,
"invoiceTitle": "测试工会(开发测试)",
"taxIdNumber": "TESTBUYER2026090801",
"invoiceAddress": "湖北省武汉市测试路1号",
"invoiceContact": "13800000000",
"sellerName": "积分商城测试供应商",
"sellerTaxId": "TESTSELLER20260901",
"invoiceContent": "办公用品:笔记本、文件夹",
"orderIds": "TEST_ORD_20260908_001,TEST_ORD_20260908_002",
"orderNum": 2,
"orderTotalPrice": 339,
"invoiceNakedPrice": 300,
"invoiceTaxPrice": 39,
"taxRate": 0.13,
"biller": "测试开票员",
"remark": "测试数据:价税合计339.00元,其中不含税金额300.00元、税额39.00元。仅供开发测试,禁止报销。",
"orderProducts": [
{
"orderId": "TEST_ORD_20260908_001",
"sku": "TEST-NOTEBOOK",
"name": "办公笔记本",
"number": 10,
"price": 11.3
},
{
"orderId": "TEST_ORD_20260908_002",
"sku": "TEST-FOLDER",
"name": "办公文件夹",
"number": 20,
"price": 11.3
}
]
}
@@ -0,0 +1,20 @@
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { generateMockInvoicePdf } from '../mock-invoice-pdf.js'
const directory = path.dirname(fileURLToPath(import.meta.url))
const inputPath = path.join(directory, 'invoice-template-example.json')
const outputPath = process.argv[2]
? path.resolve(process.argv[2])
: path.resolve(directory, '../output/pdf/test-invoice-template.pdf')
try {
const invoice = JSON.parse(readFileSync(inputPath, 'utf8'))
generateMockInvoicePdf(invoice, outputPath, { fontPath: process.argv[3] })
console.log(`发票已生成:${outputPath}`)
console.log(`测试金额:不含税 ${invoice.invoiceNakedPrice} 元,税额 ${invoice.invoiceTaxPrice} 元,价税合计 ${invoice.orderTotalPrice} 元`)
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
}