feat: 积分商城-供应商-对账-发票2
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user