feat: 积分商城-供应商-对账

This commit is contained in:
2026-09-03 18:06:07 +08:00
parent 750c36116d
commit 5c8fbddb2e
9 changed files with 1195 additions and 16 deletions
+359
View File
@@ -0,0 +1,359 @@
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const PAGE_WIDTH = 842
const PAGE_HEIGHT = 595
const findFont = requestedPath => {
const candidates = [
requestedPath,
process.env.INVOICE_PDF_FONT_PATH,
'C:/Windows/Fonts/simhei.ttf',
'C:/Windows/Fonts/msyh.ttf',
'/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'
].filter(Boolean)
const fontPath = candidates.find(candidate => existsSync(candidate))
if (!fontPath) {
throw new Error('未找到可用字体,请通过 INVOICE_PDF_FONT_PATH 指定支持中文的 TTF 字体')
}
if (path.extname(fontPath).toLowerCase() !== '.ttf') {
throw new Error('模拟发票工具仅支持 TTF 字体')
}
return fontPath
}
const readTableDirectory = font => {
const tableCount = font.readUInt16BE(4)
const tables = new Map()
for (let index = 0; index < tableCount; index += 1) {
const offset = 12 + index * 16
tables.set(font.toString('ascii', offset, offset + 4), {
offset: font.readUInt32BE(offset + 8),
length: font.readUInt32BE(offset + 12)
})
}
return tables
}
const createGlyphMapper = font => {
const cmapTable = readTableDirectory(font).get('cmap')
if (!cmapTable) throw new Error('字体缺少 cmap 字符映射表')
const base = cmapTable.offset
const count = font.readUInt16BE(base + 2)
let format12Offset = null
let format4Offset = null
for (let index = 0; index < count; index += 1) {
const record = base + 4 + index * 8
const platformId = font.readUInt16BE(record)
const encodingId = font.readUInt16BE(record + 2)
const subtableOffset = base + font.readUInt32BE(record + 4)
const format = font.readUInt16BE(subtableOffset)
if (format === 12 && (platformId === 0 || (platformId === 3 && encodingId === 10))) {
format12Offset = subtableOffset
}
if (format === 4 && (platformId === 0 || platformId === 3)) {
format4Offset = subtableOffset
}
}
const fromFormat12 = codePoint => {
if (format12Offset === null) return 0
const groupCount = font.readUInt32BE(format12Offset + 12)
let low = 0
let high = groupCount - 1
while (low <= high) {
const middle = (low + high) >> 1
const group = format12Offset + 16 + middle * 12
const start = font.readUInt32BE(group)
const end = font.readUInt32BE(group + 4)
if (codePoint < start) high = middle - 1
else if (codePoint > end) low = middle + 1
else return font.readUInt32BE(group + 8) + codePoint - start
}
return 0
}
const fromFormat4 = codePoint => {
if (format4Offset === null || codePoint > 0xffff) return 0
const segmentCount = font.readUInt16BE(format4Offset + 6) / 2
const endCodes = format4Offset + 14
const startCodes = endCodes + segmentCount * 2 + 2
const deltas = startCodes + segmentCount * 2
const rangeOffsets = deltas + segmentCount * 2
for (let index = 0; index < segmentCount; index += 1) {
const end = font.readUInt16BE(endCodes + index * 2)
if (codePoint > end) continue
const start = font.readUInt16BE(startCodes + index * 2)
if (codePoint < start) return 0
const delta = font.readInt16BE(deltas + index * 2)
const rangeOffsetPosition = rangeOffsets + index * 2
const rangeOffset = font.readUInt16BE(rangeOffsetPosition)
if (rangeOffset === 0) return (codePoint + delta) & 0xffff
const glyphPosition = rangeOffsetPosition + rangeOffset + (codePoint - start) * 2
if (glyphPosition + 2 > font.length) return 0
const glyph = font.readUInt16BE(glyphPosition)
return glyph === 0 ? 0 : (glyph + delta) & 0xffff
}
return 0
}
return codePoint => fromFormat12(codePoint) || fromFormat4(codePoint)
}
const utf16Hex = value => {
const buffer = Buffer.from(value, 'utf16le')
buffer.swap16()
return buffer.toString('hex').toUpperCase()
}
const createPdfWriter = () => {
const objects = []
return {
add(body) {
objects.push(Buffer.isBuffer(body) ? body : Buffer.from(body, 'binary'))
return objects.length
},
save(outputPath) {
const header = Buffer.from('%PDF-1.7\n%\xFF\xFF\xFF\xFF\n', 'binary')
const chunks = [header]
const offsets = [0]
let cursor = header.length
objects.forEach((body, index) => {
offsets.push(cursor)
const prefix = Buffer.from(`${index + 1} 0 obj\n`, 'ascii')
const suffix = Buffer.from('\nendobj\n', 'ascii')
chunks.push(prefix, body, suffix)
cursor += prefix.length + body.length + suffix.length
})
const xrefOffset = cursor
const xref = [`xref\n0 ${objects.length + 1}\n`, '0000000000 65535 f \n']
for (let index = 1; index <= objects.length; index += 1) {
xref.push(`${String(offsets[index]).padStart(10, '0')} 00000 n \n`)
}
xref.push(`trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`)
chunks.push(Buffer.from(xref.join(''), 'ascii'))
mkdirSync(path.dirname(outputPath), { recursive: true })
writeFileSync(outputPath, Buffer.concat(chunks))
}
}
}
const streamObject = (dictionary, data) => Buffer.concat([
Buffer.from(`<< ${dictionary} /Length ${data.length} >>\nstream\n`, 'ascii'),
data,
Buffer.from('\nendstream', 'ascii')
])
const money = value => Number(value || 0).toFixed(2)
const chineseAmount = value => {
const digits = '零壹贰叁肆伍陆柒捌玖'
const units = ['', '拾', '佰', '仟', '万', '拾', '佰', '仟', '亿', '拾', '佰', '仟']
const amount = Math.max(Number(value || 0), 0)
const integer = Math.floor(amount)
const chars = String(integer).split('').reverse()
let result = ''
let pendingZero = false
chars.forEach((char, index) => {
const digit = Number(char)
if (digit === 0) {
pendingZero = result.length > 0
if (index === 4 || index === 8) result = units[index] + result
} else {
result = `${digits[digit]}${units[index]}${pendingZero ? '零' : ''}${result}`
pendingZero = false
}
})
const cents = Math.round((amount - integer) * 100)
const jiao = Math.floor(cents / 10)
const fen = cents % 10
return `${result || '零'}元${jiao ? `${digits[jiao]}角` : ''}${fen ? `${digits[fen]}分` : '整'}`
}
const normalizeInvoice = input => {
const digest = createHash('sha256').update(JSON.stringify(input)).digest('hex').toUpperCase()
const total = Number(input.orderTotalPrice || 0)
const taxRate = Number(input.taxRate ?? 0.13)
const naked = Number(input.invoiceNakedPrice ?? total / (1 + taxRate))
const tax = Number(input.invoiceTaxPrice ?? total - naked)
return {
...input,
invoiceDate: input.invoiceDate || new Date().toISOString().slice(0, 10),
invoiceNumber: input.invoiceNumber || digest.slice(0, 20),
checkCode: input.checkCode || digest.slice(20, 40),
sellerName: input.sellerName || '模拟积分商城供应商',
sellerTaxId: input.sellerTaxId || input.supplierId || 'SIMULATED-SUPPLIER',
sellerAddress: input.sellerAddress || '模拟供应商注册地址',
sellerContact: input.sellerContact || '400-000-0000',
invoiceContent: input.invoiceContent || '积分商城商品',
orderNum: Number(input.orderNum || 1),
total,
naked,
tax,
taxRate
}
}
export const generateMockInvoicePdf = (invoiceInput, outputPath, options = {}) => {
const invoice = normalizeInvoice(invoiceInput)
const fontPath = findFont(options.fontPath)
const font = readFileSync(fontPath)
const glyphFor = createGlyphMapper(font)
const glyphUnicode = new Map()
const commands = []
const encode = value => Array.from(String(value ?? '')).map(character => {
const glyph = glyphFor(character.codePointAt(0))
if (glyph && !glyphUnicode.has(glyph)) glyphUnicode.set(glyph, character)
return glyph.toString(16).padStart(4, '0')
}).join('').toUpperCase()
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`)
}
const line = (x1, y1, x2, y2, width = 0.7, color = '0.45 0.12 0.12') => {
commands.push(`${color} RG ${width} w ${x1} ${y1} m ${x2} ${y2} l S`)
}
const rect = (x, y, width, height, color = '0.45 0.12 0.12') => {
commands.push(`${color} RG 0.7 w ${x} ${y} ${width} ${height} re S`)
}
const wrappedText = (value, x, y, maxChars, size = 9, lineHeight = 13) => {
const characters = Array.from(String(value || ''))
for (let index = 0; index < characters.length; index += maxChars) {
text(characters.slice(index, index + maxChars).join(''), x, y - index / maxChars * lineHeight, size)
}
}
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(`发票号码:${invoice.invoiceNumber}`, 610, 555, 9)
text(`开票日期:${invoice.invoiceDate}`, 610, 538, 9)
text(`校验码:${invoice.checkCode}`, 610, 521, 9)
text(`结算单号:${invoice.settlementId || ''}`, 50, 521, 9)
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.invoiceCode, 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)
})
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)
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')
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')
const content = Buffer.from(commands.join('\n'), 'ascii')
const toUnicodeEntries = [...glyphUnicode.entries()].map(([glyph, character]) =>
`<${glyph.toString(16).padStart(4, '0').toUpperCase()}> <${utf16Hex(character)}>`)
const toUnicodeBlocks = []
for (let index = 0; index < toUnicodeEntries.length; index += 100) {
const entries = toUnicodeEntries.slice(index, index + 100)
toUnicodeBlocks.push(`${entries.length} beginbfchar`, ...entries, 'endbfchar')
}
const toUnicode = Buffer.from([
'/CIDInit /ProcSet findresource begin', '12 dict begin', 'begincmap',
'/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def',
'/CMapName /MockInvoiceUnicode def', '/CMapType 2 def',
'1 begincodespacerange', '<0000> <FFFF>', 'endcodespacerange',
...toUnicodeBlocks,
'endcmap', 'CMapName currentdict /CMap defineresource pop', 'end', 'end'
].join('\n'), 'ascii')
const writer = createPdfWriter()
writer.add('<< /Type /Catalog /Pages 2 0 R >>')
writer.add('<< /Type /Pages /Kids [3 0 R] /Count 1 >>')
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 /MockInvoiceFont /Encoding /Identity-H /DescendantFonts [6 0 R] /ToUnicode 9 0 R >>')
writer.add('<< /Type /Font /Subtype /CIDFontType2 /BaseFont /MockInvoiceFont /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor 7 0 R /DW 1000 /CIDToGIDMap /Identity >>')
writer.add('<< /Type /FontDescriptor /FontName /MockInvoiceFont /Flags 4 /FontBBox [-1000 -1000 3000 3000] /ItalicAngle 0 /Ascent 1000 /Descent -300 /CapHeight 800 /StemV 80 /FontFile2 8 0 R >>')
writer.add(streamObject(`/Length1 ${font.length}`, font))
writer.add(streamObject('', toUnicode))
writer.save(outputPath)
return outputPath
}
const parseArguments = argv => {
const options = {}
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === '--input') options.input = argv[++index]
else if (argv[index] === '--data') options.data = argv[++index]
else if (argv[index] === '--output') options.output = argv[++index]
else if (argv[index] === '--font') options.font = argv[++index]
}
return options
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
const options = parseArguments(process.argv.slice(2))
if (!options.input && !options.data) {
throw new Error('请使用 --input <json文件> 或 --data <json字符串> 传入开票信息')
}
const input = options.input
? 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))
generateMockInvoicePdf(input, outputPath, { fontPath: options.font })
console.log(outputPath)
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
}
}