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
+26 -9
View File
@@ -1,8 +1,9 @@
<template> <template>
<section class="page-card"> <section class="page-card">
<div class="page-title"><div><h2>订单列表</h2><p>查询订单并对待发货订单确认发货。</p></div></div> <div class="page-title"><div><h2>订单列表</h2><p>查询订单并对待发货订单确认发货。</p></div></div>
<el-form :inline="true" class="query-form"><el-form-item label="供应商"><el-select v-model="query.supplierId" placeholder="请选择供应商" filterable clearable><el-option v-for="supplier in suppliers" :key="supplier.supplierId" :label="`${supplier.supplierName}(${supplier.supplierId})`" :value="supplier.supplierId" /></el-select></el-form-item><el-form-item label="订单号"><el-input v-model="query.orderId" placeholder="订单编号或子订单号" clearable /></el-form-item><el-form-item label="工号"><el-input v-model="query.userId" placeholder="请输入工号" clearable /></el-form-item><el-form-item label="订单状态"><el-select v-model="query.orderState" placeholder="全部" clearable><el-option v-for="status in orderStates" :key="status.value" :label="status.label" :value="status.value" /></el-select></el-form-item><el-form-item><el-button type="primary" :loading="loading" @click="loadOrders">查询</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form> <el-form :inline="true" class="query-form"><el-form-item label="供应商"><el-select v-model="query.supplierId" placeholder="请选择供应商" filterable clearable><el-option v-for="supplier in suppliers" :key="supplier.supplierId" :label="`${supplier.supplierName}(${supplier.supplierId})`" :value="supplier.supplierId" /></el-select></el-form-item><el-form-item label="订单号"><el-input v-model="query.orderId" placeholder="订单编号或子订单号" clearable /></el-form-item><el-form-item label="工号"><el-input v-model="query.userId" placeholder="请输入工号" clearable /></el-form-item><el-form-item label="订单状态"><el-select v-model="query.orderState" placeholder="全部" clearable style="width: 160px"><el-option v-for="status in orderStates" :key="status.value" :label="status.label" :value="status.value" /></el-select></el-form-item><el-form-item><el-button type="primary" :loading="loading" @click="searchOrders">查询</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form>
<el-table v-loading="loading" :data="orders" border><el-table-column prop="orderNumberId" label="订单编号" min-width="160" /><el-table-column prop="orderId" label="子订单号" min-width="180" /><el-table-column prop="userId" label="工号" min-width="130" /><el-table-column label="订单状态" width="110"><template #default="{ row }">{{ stateText(row.orderState) }}</template></el-table-column><el-table-column prop="createTime" label="下单时间" min-width="170" /><el-table-column label="商品" min-width="180"><template #default="{ row }">{{ productNames(row) }}</template></el-table-column><el-table-column label="操作" width="120" fixed="right"><template #default="{ row }"><el-button v-if="row.orderState === 1" link type="primary" @click="openShipDialog(row)">确认发货</el-button><span v-else>-</span></template></el-table-column></el-table> <el-table v-loading="loading" :data="orders" border><el-table-column prop="orderNumberId" label="订单编号" min-width="160" /><el-table-column prop="orderId" label="子订单号" min-width="180" /><el-table-column prop="userId" label="工号" min-width="130" /><el-table-column label="订单状态" width="110"><template #default="{ row }">{{ stateText(row.orderState) }}</template></el-table-column><el-table-column prop="createTime" label="下单时间" min-width="170" /><el-table-column label="商品" min-width="180"><template #default="{ row }">{{ productNames(row) }}</template></el-table-column><el-table-column label="操作" width="240" fixed="right"><template #default="{ row }"><el-button v-if="row.orderState === 1" link type="primary" @click="openShipDialog(row)">确认发货</el-button><span v-else>-</span><el-button v-if="row.orderState === 1" link type="warning" :loading="repushingOrderId === row.orderId" :disabled="Boolean(repushingOrderId)" @click="repushSplit(row)">重新推送拆单</el-button></template></el-table-column></el-table>
<el-pagination v-model:current-page="query.pageNumber" :page-size="query.pageSize" :total="totalCount" layout="total, prev, pager, next, jumper" style="margin-top: 16px; justify-content: flex-end" @current-change="loadOrders" />
<el-empty v-if="!loading && queried && !orders.length" description="暂无订单" /> <el-empty v-if="!loading && queried && !orders.length" description="暂无订单" />
<el-dialog v-model="shipDialogVisible" title="确认发货" width="460px"><el-form label-width="90px"><el-form-item label="快递公司"><el-input v-model="shipForm.crrgBsnNm" placeholder="请输入快递公司" /></el-form-item><el-form-item label="快递单号"><el-input v-model="shipForm.logisticsOrderId" placeholder="请输入快递单号" /></el-form-item></el-form><template #footer><el-button @click="shipDialogVisible = false">取消</el-button><el-button type="primary" :loading="submitting" @click="submitShip">确认发货</el-button></template></el-dialog> <el-dialog v-model="shipDialogVisible" title="确认发货" width="460px"><el-form label-width="90px"><el-form-item label="快递公司"><el-input v-model="shipForm.crrgBsnNm" placeholder="请输入快递公司" /></el-form-item><el-form-item label="快递单号"><el-input v-model="shipForm.logisticsOrderId" placeholder="请输入快递单号" /></el-form-item></el-form><template #footer><el-button @click="shipDialogVisible = false">取消</el-button><el-button type="primary" :loading="submitting" @click="submitShip">确认发货</el-button></template></el-dialog>
</section> </section>
@@ -14,17 +15,33 @@ import { ElMessage } from 'element-plus'
import { postAdmin } from '@/api/admin' import { postAdmin } from '@/api/admin'
type OrderProduct = { name?: string } type OrderProduct = { name?: string }
type OrderItem = { orderId: string; orderNumberId?: string; userId?: string; orderState: number; createTime?: string; products?: OrderProduct[] } type OrderItem = { orderId: string; supplierId: string; orderNumberId?: string; userId?: string; orderState: number; createTime?: string; products?: OrderProduct[] }
type SupplierItem = { supplierId: string; supplierName: string } type SupplierItem = { supplierId: string; supplierName: string }
const orderStates = [{ label: '待付款', value: 0 }, { label: '待发货', value: 1 }, { label: '已发货', value: 2 }, { label: '已取消', value: 3 }, { label: '已签收', value: 4 }, { label: '已完成', value: 5 }, { label: '换货', value: 6 }, { label: '已退款', value: 7 }] const orderStates = [{ label: '待付款', value: 0 }, { label: '待发货', value: 1 }, { label: '已发货', value: 2 }, { label: '已取消', value: 3 }, { label: '已签收', value: 4 }, { label: '已完成', value: 5 }, { label: '换货', value: 6 }, { label: '已退款', value: 7 }]
const query = reactive<{ supplierId: string; orderId: string; userId: string; orderState?: number }>({ supplierId: '', orderId: '', userId: '' }) const query = reactive<{ supplierId: string; orderId: string; userId: string; orderState?: number; pageNumber: number; pageSize: number }>({ supplierId: '', orderId: '', userId: '', pageNumber: 1, pageSize: 10 })
const suppliers = ref<SupplierItem[]>([]); const orders = ref<OrderItem[]>([]); const loading = ref(false); const queried = ref(false); const submitting = ref(false); const shipDialogVisible = ref(false); const currentOrder = ref<OrderItem>(); const shipForm = ref({ crrgBsnNm: '', logisticsOrderId: '' }) const suppliers = ref<SupplierItem[]>([]); const orders = ref<OrderItem[]>([]); const loading = ref(false); const queried = ref(false); const submitting = ref(false); const shipDialogVisible = ref(false); const currentOrder = ref<OrderItem>(); const shipForm = ref({ crrgBsnNm: '', logisticsOrderId: '' })
const totalCount = ref(0)
const stateText = (state: number) => orderStates.find(item => item.value === state)?.label || '未知' const stateText = (state: number) => orderStates.find(item => item.value === state)?.label || '未知'
const loadSuppliers = async () => { try { const data = await postAdmin<{ suppliers?: SupplierItem[] }>('merchant-supplier-list', {}); suppliers.value = data.suppliers || [] } catch (error) { ElMessage.error(error instanceof Error ? error.message : '供应商加载失败') } } const loadSuppliers = async () => { try { const data = await postAdmin<{ suppliers?: SupplierItem[] }>('merchant-supplier-list', {}); suppliers.value = data.suppliers || [] } catch (error) { ElMessage.error(error instanceof Error ? error.message : '供应商加载失败') } }
const loadOrders = async () => { if (!query.supplierId) return ElMessage.warning('请选择供应商'); loading.value = true; try { const data = await postAdmin<{ orderItems?: OrderItem[] }>('merchant-order-list', query); orders.value = data.orderItems || []; queried.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '订单查询失败') } finally { loading.value = false } } const loadOrders = async () => { loading.value = true; try { const data = await postAdmin<{ orderItems?: OrderItem[]; totalCount?: number }>('merchant-order-list', query); orders.value = data.orderItems || []; totalCount.value = data.totalCount || 0; queried.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '订单查询失败') } finally { loading.value = false } }
const reset = () => { query.supplierId = ''; query.orderId = ''; query.userId = ''; query.orderState = undefined; orders.value = []; queried.value = false } const searchOrders = () => { query.pageNumber = 1; return loadOrders() }
const reset = () => { query.supplierId = ''; query.orderId = ''; query.userId = ''; query.orderState = undefined; searchOrders() }
const productNames = (order: OrderItem) => order.products?.map(item => item.name).filter(Boolean).join('、') || '-' const productNames = (order: OrderItem) => order.products?.map(item => item.name).filter(Boolean).join('、') || '-'
const openShipDialog = (order: OrderItem) => { currentOrder.value = order; shipForm.value = { crrgBsnNm: '', logisticsOrderId: '' }; shipDialogVisible.value = true } const openShipDialog = (order: OrderItem) => { currentOrder.value = order; shipForm.value = { crrgBsnNm: '韵达快递', logisticsOrderId: `YT${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}` }; shipDialogVisible.value = true }
const submitShip = async () => { if (!currentOrder.value || !shipForm.value.crrgBsnNm || !shipForm.value.logisticsOrderId) return ElMessage.warning('请填写完整发货信息'); submitting.value = true; try { await postAdmin('merchant-order-ship', { supplierId: query.supplierId, orderId: currentOrder.value.orderId, userId: currentOrder.value.userId, ...shipForm.value }); ElMessage.success('发货成功'); shipDialogVisible.value = false; await loadOrders() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '发货失败') } finally { submitting.value = false } } const submitShip = async () => { if (!currentOrder.value || !shipForm.value.crrgBsnNm || !shipForm.value.logisticsOrderId) return ElMessage.warning('请填写完整发货信息'); submitting.value = true; try { await postAdmin('merchant-order-ship', { supplierId: currentOrder.value.supplierId, orderId: currentOrder.value.orderId, userId: currentOrder.value.userId, ...shipForm.value }); ElMessage.success('发货成功'); shipDialogVisible.value = false; await loadOrders() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '发货失败') } finally { submitting.value = false } }
onMounted(loadSuppliers) const repushingOrderId = ref('')
const repushSplit = async (order: OrderItem) => {
if (repushingOrderId.value) return
repushingOrderId.value = order.orderId
try {
await postAdmin('merchant-order-repush-split', { supplierId: order.supplierId, userId: order.userId, orderId: order.orderId })
ElMessage.success('重新推送拆单成功')
await loadOrders()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '重新推送拆单失败')
} finally {
repushingOrderId.value = ''
}
}
onMounted(() => { loadSuppliers(); loadOrders() })
</script> </script>
@@ -361,7 +361,7 @@ export default {
this.currentOrder = item this.currentOrder = item
this.shipForm = { this.shipForm = {
crrgBsnNm: item.crrgBsnNm || '韵达快递', crrgBsnNm: item.crrgBsnNm || '韵达快递',
logisticsOrderId: item.logisticsOrderId || 'YT20260903142053001' logisticsOrderId: item.logisticsOrderId || `YT${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`
} }
this.showShipPopup = true this.showShipPopup = true
}, },
@@ -375,7 +375,7 @@ export default {
userId: item.userId, userId: item.userId,
orderId: item.orderId, orderId: item.orderId,
crrgBsnNm: item.crrgBsnNm || '韵达快递', crrgBsnNm: item.crrgBsnNm || '韵达快递',
logisticsOrderId: item.logisticsOrderId || 'YT20260903142053001' logisticsOrderId: item.logisticsOrderId || `YT${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`
}))) })))
Toast('批量发货成功') Toast('批量发货成功')
await this.loadOrders() await this.loadOrders()
@@ -30,6 +30,13 @@ export const merchantSupplierListController = withErrorBoundary(async (request:
return toHttpResponse(await merchantService.listSuppliers()) return toHttpResponse(await merchantService.listSuppliers())
}) })
export const merchantOrderRepushSplitController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(await merchantService.repushSplitOrder(await readJson<Pick<MerchantShipInput, 'supplierId' | 'userId' | 'orderId'>>(request)))
})
export const merchantOrderShipController = withErrorBoundary(async (request: Request, _context: Context) => { export const merchantOrderShipController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST']) const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse if (methodResponse) return methodResponse
@@ -45,7 +45,7 @@ export const supplierSessionController = withErrorBoundary(async (request: Reque
const body = await readJson<SupplierSessionRequest>(request) const body = await readJson<SupplierSessionRequest>(request)
const keyword = String(body.keyword || '') const keyword = String(body.keyword || '')
return toHttpResponse(supplierService.decryptSupplierSession(keyword)) return toHttpResponse(await supplierService.decryptSupplierSession(keyword))
}) })
export const supplierTokenController = withErrorBoundary(async (request: Request, _context: Context) => { export const supplierTokenController = withErrorBoundary(async (request: Request, _context: Context) => {
+29 -21
View File
@@ -171,32 +171,40 @@ export const orderDao = {
}) })
}, },
findMerchantSubOrders(supplierId: string, orderState?: number, orderId?: string, userId?: string) { async findMerchantSubOrders(supplierId: string, orderState?: number, orderId?: string, userId?: string, excludedOrderIds: string[] = [], pagination?: { pageNumber: number; pageSize: number }) {
return prisma.supplierOrderSub.findMany({ const where: Prisma.SupplierOrderSubWhereInput = {
where: { ...(supplierId ? { supplierId } : {}),
supplierId, deleted: 0,
deleted: 0, ...(Number.isInteger(orderState) ? { orderState } : {}),
...(Number.isInteger(orderState) ? { orderState } : {}), ...(orderId ? { OR: [{ orderId: { contains: orderId } }, { orderNumberId: { contains: orderId } }] } : {}),
...(orderId ? { OR: [{ orderId: { contains: orderId } }, { orderNumberId: { contains: orderId } }] } : {}), ...(userId ? { userId: { contains: userId } } : {}),
...(userId ? { userId: { contains: userId } } : {}) ...(excludedOrderIds.length ? { orderId: { notIn: excludedOrderIds } } : {})
}, }
include: { const [orders, totalCount] = await prisma.$transaction([
products: { prisma.supplierOrderSub.findMany({
where: { deleted: 0 }, where,
orderBy: { createAt: 'asc' } include: {
} products: {
}, where: { deleted: 0 },
orderBy: [ orderBy: { createAt: 'asc' }
{ createTime: 'desc' }, }
{ createAt: 'desc' } },
] orderBy: [
}) { createTime: 'desc' },
{ createAt: 'desc' },
{ orderId: 'desc' }
],
...(pagination ? { skip: (pagination.pageNumber - 1) * pagination.pageSize, take: pagination.pageSize } : {})
}),
prisma.supplierOrderSub.count({ where })
])
return { orders, totalCount }
}, },
findMerchantAfterSales(supplierId: string, serviceStates?: number[]) { findMerchantAfterSales(supplierId: string, serviceStates?: number[]) {
return prisma.supplierAfterSale.findMany({ return prisma.supplierAfterSale.findMany({
where: { where: {
supplierId, ...(supplierId ? { supplierId } : {}),
deleted: 0, deleted: 0,
...(serviceStates?.length ? { serviceState: { in: serviceStates } } : {}) ...(serviceStates?.length ? { serviceState: { in: serviceStates } } : {})
}, },
@@ -0,0 +1 @@
export { merchantOrderRepushSplitController as default } from '../controllers/merchant.controller.js'
+2
View File
@@ -53,6 +53,7 @@ import {
merchantAfterSaleShipController, merchantAfterSaleShipController,
merchantOrderListController, merchantOrderListController,
merchantOrderShipController, merchantOrderShipController,
merchantOrderRepushSplitController,
merchantSupplierListController merchantSupplierListController
} from './controllers/merchant.controller.js' } from './controllers/merchant.controller.js'
import { import {
@@ -93,6 +94,7 @@ const handlers: Record<string, FunctionHandler> = {
'merchant-after-sale-ship': merchantAfterSaleShipController, 'merchant-after-sale-ship': merchantAfterSaleShipController,
'merchant-order-list': merchantOrderListController, 'merchant-order-list': merchantOrderListController,
'merchant-order-ship': merchantOrderShipController, 'merchant-order-ship': merchantOrderShipController,
'merchant-order-repush-split': merchantOrderRepushSplitController,
'merchant-supplier-list': merchantSupplierListController, 'merchant-supplier-list': merchantSupplierListController,
'order-cancel-main': orderCancelMainController, 'order-cancel-main': orderCancelMainController,
'order-cancel-paid-main': orderCancelPaidMainController, 'order-cancel-paid-main': orderCancelPaidMainController,
@@ -2,6 +2,7 @@ import {
AFTER_SALE_LOGISTICS_STATE, AFTER_SALE_LOGISTICS_STATE,
AFTER_SALE_SERVICE_STATE, AFTER_SALE_SERVICE_STATE,
ORDER_EVENT_TYPE, ORDER_EVENT_TYPE,
ORDER_SPLIT_STATE,
ORDER_STATE ORDER_STATE
} from '../constants/order.js' } from '../constants/order.js'
import { orderDao } from '../dao/order.dao.js' import { orderDao } from '../dao/order.dao.js'
@@ -9,11 +10,14 @@ import { supplierDao } from '../dao/supplier.dao.js'
import { afterSaleService, type AfterSaleAuditInput, type AfterSaleLogisticsInput } from './after-sale.service.js' import { afterSaleService, type AfterSaleAuditInput, type AfterSaleLogisticsInput } from './after-sale.service.js'
import { orderActionService } from './order-action.service.js' import { orderActionService } from './order-action.service.js'
import { logger } from '../utils/logger.js' import { logger } from '../utils/logger.js'
import { bridgeFetchWithAuth } from '../utils/bridge-auth.js'
import { serviceFail, serviceOk } from '../types/service-result.js' import { serviceFail, serviceOk } from '../types/service-result.js'
export type MerchantOrderListInput = { export type MerchantOrderListInput = {
supplierId?: string supplierId?: string
orderState?: number orderState?: number
pageNumber?: number
pageSize?: number
orderId?: string orderId?: string
userId?: string userId?: string
} }
@@ -108,19 +112,18 @@ const isOrderSigned = (orderState?: number | null) => signedOrderStates.has(Numb
export const merchantService = { export const merchantService = {
async listOrders(input: MerchantOrderListInput) { async listOrders(input: MerchantOrderListInput) {
const supplierId = String(input.supplierId || '').trim() const supplierId = String(input.supplierId || '').trim()
const orderState = input.orderState === undefined ? undefined : Number(input.orderState) const orderState = input.orderState == null || String(input.orderState) === '' ? undefined : Number(input.orderState)
const orderId = String(input.orderId || '').trim() const orderId = String(input.orderId || '').trim()
const userId = String(input.userId || '').trim() const userId = String(input.userId || '').trim()
logger.info('merchant order list query', { supplierId, orderState }) logger.info('merchant order list query', { supplierId, orderState })
if (!supplierId) { const pageNumber = Number.isInteger(Number(input.pageNumber)) && Number(input.pageNumber) > 0 ? Number(input.pageNumber) : 1
return serviceFail('供应商不能为空') const pageSize = Number.isInteger(Number(input.pageSize)) && Number(input.pageSize) > 0 ? Number(input.pageSize) : 10
} const pagination = input.pageNumber !== undefined || input.pageSize !== undefined ? { pageNumber, pageSize } : undefined
const orders = await orderDao.findMerchantSubOrders(supplierId, orderState, orderId, userId)
const afterSales = await orderDao.findMerchantAfterSales(supplierId, pendingShipExcludeAfterSaleStates) const afterSales = await orderDao.findMerchantAfterSales(supplierId, pendingShipExcludeAfterSaleStates)
const afterSaleSubOrderIds = new Set(afterSales.map(item => item.subOrderId).filter(Boolean)) const afterSaleSubOrderIds = afterSales.map(item => item.subOrderId).filter((id): id is string => Boolean(id))
const orderItems = orders.filter(order => !afterSaleSubOrderIds.has(order.orderId)).map(order => ({ const { orders, totalCount } = await orderDao.findMerchantSubOrders(supplierId, orderState, orderId, userId, afterSaleSubOrderIds, pagination)
const orderItems = orders.map(order => ({
orderId: order.orderId, orderId: order.orderId,
mainOrderId: order.mainOrderId, mainOrderId: order.mainOrderId,
userId: order.userId || '', userId: order.userId || '',
@@ -141,7 +144,7 @@ export const merchantService = {
})) }))
})) }))
return serviceOk({ orderItems }) return serviceOk({ orderItems, totalCount, pageNumber, pageSize })
}, },
async listSuppliers() { async listSuppliers() {
@@ -149,6 +152,57 @@ export const merchantService = {
return serviceOk({ suppliers }) return serviceOk({ suppliers })
}, },
async repushSplitOrder(input: Pick<MerchantShipInput, 'supplierId' | 'userId' | 'orderId'>) {
const supplierId = String(input.supplierId || '').trim()
const userId = String(input.userId || '').trim()
const orderId = String(input.orderId || '').trim()
if (!supplierId || !userId || !orderId) {
return serviceFail('供应商、用户或子单信息不能为空')
}
const subOrder = await orderDao.findSubOrderWithProducts(supplierId, userId, orderId)
if (!subOrder) {
return serviceFail('子单不存在', 404, 'SUB_ORDER_NOT_FOUND')
}
const mainOrder = await orderDao.findMainOrder(supplierId, userId, subOrder.mainOrderId)
if (!mainOrder) {
return serviceFail('主单不存在', 404, 'MAIN_ORDER_NOT_FOUND')
}
if (mainOrder.orderState !== ORDER_STATE.FINISH_PAY || subOrder.orderState !== ORDER_STATE.FINISH_PAY) {
return serviceFail('仅支持已支付且待发货的订单重新推送拆单')
}
if (!subOrder.products.length) {
return serviceFail('子单缺少商品信息,无法推送拆单')
}
try {
const { response, payload } = await bridgeFetchWithAuth<{ resultCode?: number; message?: string }>(
supplierId,
'/pointOrder/pushOrderInfoMsg',
{
orderType: ORDER_EVENT_TYPE.SPLIT_SUB_ORDER,
orderId: subOrder.orderId,
mainOrderId: mainOrder.orderId,
userId: mainOrder.userId,
time: Date.now()
}
)
if (!response.ok || payload.resultCode !== 0) {
return serviceFail(payload.message || '重新推送拆单失败', 502, 'BRIDGE_SPLIT_ORDER_FAILED')
}
} catch (error) {
return serviceFail(error instanceof Error ? error.message : '重新推送拆单失败', 502, 'BRIDGE_SPLIT_ORDER_FAILED')
}
await orderDao.transaction(async tx => {
await tx.supplierOrderMain.update({
where: { orderId: mainOrder.orderId },
data: { isSplit: ORDER_SPLIT_STATE.SPLIT, splitTime: mainOrder.splitTime || new Date(), updateTime: new Date() }
})
})
return serviceOk({ orderId, mainOrderId: mainOrder.orderId })
},
shipOrder(input: MerchantShipInput) { shipOrder(input: MerchantShipInput) {
return orderActionService.shipOrder({ return orderActionService.shipOrder({
supplierId: String(input.supplierId || '').trim(), supplierId: String(input.supplierId || '').trim(),
@@ -31,7 +31,7 @@ export const supplierFileService = {
const now = new Date() const now = new Date()
const pad = (value: number) => String(value).padStart(2, '0') const pad = (value: number) => String(value).padStart(2, '0')
const datePath = `${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())}` const datePath = `${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())}`
const objectName = `invoices/${datePath}/${supplierId}/${fileName}` const objectName = `supplied/invoices/${datePath}/${supplierId}/${fileName}`
const stat = statSync(filePath) const stat = statSync(filePath)
await client.fPutObject(bucketName, objectName, filePath, { await client.fPutObject(bucketName, objectName, filePath, {
'Content-Type': 'application/pdf' 'Content-Type': 'application/pdf'
@@ -44,8 +44,8 @@ export const supplierFileService = {
return { fileId, objectName } return { fileId, objectName }
}, },
async read(fileId: string) { async read(fileId: string, supplierId: string) {
const file = await prisma.supplierFile.findFirst({ where: { fileId, deleted: 0 } }) const file = await prisma.supplierFile.findFirst({ where: { fileId, supplierId, deleted: 0 } })
if (!file) return null if (!file) return null
const stream = await client.getObject(file.bucketName, file.objectName) const stream = await client.getObject(file.bucketName, file.objectName)
return streamToBuffer(stream) return streamToBuffer(stream)
@@ -1,5 +1,5 @@
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs' import { existsSync, readFileSync, unlinkSync } from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { supplierDao } from '../dao/supplier.dao.js' import { supplierDao } from '../dao/supplier.dao.js'
import { orderDao } from '../dao/order.dao.js' import { orderDao } from '../dao/order.dao.js'
@@ -72,6 +72,7 @@ export type SupplierFileBase64QueryRequest = {
} }
export type SupplierInvoiceQueryRequest = { export type SupplierInvoiceQueryRequest = {
retry?: boolean
supplierId?: string supplierId?: string
token?: string token?: string
settlementId?: string settlementId?: string
@@ -90,8 +91,8 @@ const parseBody = <T extends SupplierOpenEncryptedRequest>(body: T) => {
const encryptedData = body.AplParmObjct || body.ApiParamObject const encryptedData = body.AplParmObjct || body.ApiParamObject
if (!encryptedData) return body if (!encryptedData) return body
const keyHex = process.env.SM4CBC_KEY_HEX || '' const keyHex = process.env.SM4CBC_KEY_HEX || process.env.SUPPLIER_OPEN_SM4_KEY_HEX || ''
const ivHex = process.env.SM4CBC_IV_HEX || '' const ivHex = process.env.SM4CBC_IV_HEX || process.env.SUPPLIER_OPEN_SM4_IV_HEX || ''
if (!keyHex || !ivHex) { if (!keyHex || !ivHex) {
throw new Error('供应商开放接口SM4配置缺失') throw new Error('供应商开放接口SM4配置缺失')
} }
@@ -115,7 +116,7 @@ const numericId = (seed: string, length: number) => BigInt(`0x${createHash('sha2
.padStart(length, '0') .padStart(length, '0')
.slice(0, length) .slice(0, length)
const invoiceOutputRoot = () => path.resolve(process.env.INVOICE_PDF_OUTPUT_DIR || path.join(process.cwd(), 'output', 'pdf', 'invoices')) const invoiceOutputRoot = () => path.resolve(process.env.INVOICE_PDF_OUTPUT_DIR || path.join(process.cwd(), 'temp', 'pdf', 'invoices'))
const storedRequest = (value: unknown) => value && typeof value === 'object' && !Array.isArray(value) const storedRequest = (value: unknown) => value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown> ? value as Record<string, unknown>
@@ -170,7 +171,7 @@ const productImageSource = (product?: Awaited<ReturnType<typeof orderDao.findOpe
export const supplierOpenOrderService = { export const supplierOpenOrderService = {
parseBody, parseBody,
async applyForInvoices(body: SupplierInvoiceApplyRequest) { async applyForInvoices(body: SupplierInvoiceApplyRequest, notifyPlatform = true) {
const supplierId = String(body.supplierId || '').trim() const supplierId = String(body.supplierId || '').trim()
const token = String(body.token || '').trim() const token = String(body.token || '').trim()
const settlementId = String(body.settlementId || '').trim() const settlementId = String(body.settlementId || '').trim()
@@ -265,12 +266,23 @@ export const supplierOpenOrderService = {
const requestedInvoiceType = Number(body.invoiceType || 10) const requestedInvoiceType = Number(body.invoiceType || 10)
const invoiceType = Number.isInteger(requestedInvoiceType) ? requestedInvoiceType : 10 const invoiceType = Number.isInteger(requestedInvoiceType) ? requestedInvoiceType : 10
const absoluteFilePath = path.join(invoiceOutputRoot(), supplierId, `${invoiceId}.pdf`) const absoluteFilePath = path.join(invoiceOutputRoot(), supplierId, `${invoiceId}.pdf`)
const filePath = path.relative(process.cwd(), absoluteFilePath).replaceAll('\\', '/')
try { try {
generateMockInvoicePdf({ generateMockInvoicePdf({
...body, ...body,
taxIdNumber, taxIdNumber,
settlementId,
orderIds: orderIdText,
orderNum: orders.length,
orderTotalPrice: actualTotalPrice,
invoiceContent: orders.flatMap(order => order.products.map(product => product.name)).filter(Boolean).join('、'),
orderProducts: orders.flatMap(order => order.products.map(product => ({
orderId: order.orderId,
sku: product.sku,
name: product.name,
number: product.number,
price: Number(product.price || 0)
}))),
documentInvoiceCode: invoiceCode, documentInvoiceCode: invoiceCode,
invoiceNumber: invoiceId, invoiceNumber: invoiceId,
checkCode, checkCode,
@@ -281,6 +293,11 @@ export const supplierOpenOrderService = {
taxRate taxRate
}, absoluteFilePath) }, absoluteFilePath)
await supplierFileService.uploadPdf(invoiceId, supplierId, `${invoiceId}.pdf`, absoluteFilePath) await supplierFileService.uploadPdf(invoiceId, supplierId, `${invoiceId}.pdf`, absoluteFilePath)
try {
unlinkSync(absoluteFilePath)
} catch (error) {
logger.error('supplier invoice temp file cleanup failed', { supplierId, settlementId, absoluteFilePath, error })
}
const mainOrderIds = [...new Set(orders.map(order => order.mainOrderId).filter(Boolean))] const mainOrderIds = [...new Set(orders.map(order => order.mainOrderId).filter(Boolean))]
await orderDao.transaction(async tx => { await orderDao.transaction(async tx => {
@@ -299,7 +316,7 @@ export const supplierOpenOrderService = {
checkCode, checkCode,
fileType: 'PDF', fileType: 'PDF',
fileId: invoiceId, fileId: invoiceId,
filePath, filePath: null,
failureMessage: null failureMessage: null
} }
}) })
@@ -319,13 +336,29 @@ export const supplierOpenOrderService = {
await orderDao.transaction(async tx => { await orderDao.transaction(async tx => {
await tx.supplierInvoiceInfo.update({ await tx.supplierInvoiceInfo.update({
where: { invoiceBizId: settlementId }, where: { invoiceBizId: settlementId },
data: { invoiceStatus: 3, failureMessage } data: {
invoiceStatus: 3, failureMessage, invoiceIds: invoiceId, invoiceCode,
invoiceDate, invoiceType, invoiceNakeAmount, invoiceTaxRate: taxRate,
invoiceTaxAmount, invoiceAmount: orderTotalPrice, checkCode, fileType: 'PDF',
fileId: null, filePath: null
}
}) })
}) })
logger.error('supplier mock invoice generate failed', { supplierId, settlementId, failureMessage }) logger.error('supplier mock invoice generate failed', { supplierId, settlementId, failureMessage })
if (notifyPlatform) {
await orderPushEventService.pushEvent({
supplierId, userId: String(firstOrder?.userId || 'SYSTEM'),
orderType: ORDER_EVENT_TYPE.INVOICE_INFO, orderId: settlementId,
mainOrderId: firstOrder?.mainOrderId, settlementId, invoiceBizId: settlementId
})
}
return { resultCode: 500, message: failureMessage } return { resultCode: 500, message: failureMessage }
} }
if (!notifyPlatform) {
return { resultCode: 0, message: '发票重新生成成功' }
}
const pushResult = await orderPushEventService.pushEvent({ const pushResult = await orderPushEventService.pushEvent({
supplierId, supplierId,
userId: String(firstOrder?.userId || 'SYSTEM'), userId: String(firstOrder?.userId || 'SYSTEM'),
@@ -337,8 +370,7 @@ export const supplierOpenOrderService = {
invoiceCode, invoiceCode,
invoiceIds: invoiceId, invoiceIds: invoiceId,
orderIds: orderIdText, orderIds: orderIdText,
fileId: invoiceId, fileId: invoiceId
filePath
}) })
const pushSuccess = pushResult.success && pushResult.data.pushSuccess const pushSuccess = pushResult.success && pushResult.data.pushSuccess
@@ -365,7 +397,7 @@ export const supplierOpenOrderService = {
return { resultCode: 401, message: '签名无效或已过期' } return { resultCode: 401, message: '签名无效或已过期' }
} }
const invoice = await orderDao.findInvoiceBySettlementId(supplierId, settlementId) let invoice = await orderDao.findInvoiceBySettlementId(supplierId, settlementId)
if (!invoice) { if (!invoice) {
return { resultCode: 404, message: '未找到对应的开票记录' } return { resultCode: 404, message: '未找到对应的开票记录' }
} }
@@ -373,6 +405,38 @@ export const supplierOpenOrderService = {
return { resultCode: 409, message: '发票正在开具中' } return { resultCode: 409, message: '发票正在开具中' }
} }
if (body.retry === true && invoice.invoiceStatus === 3) {
const previous = invoice
const claimed = await orderDao.transaction(tx => tx.supplierInvoiceInfo.updateMany({
where: { invoiceBizId: settlementId, supplierId, invoiceStatus: 3, deleted: 0 },
data: { invoiceStatus: 1 }
}))
if (!claimed.count) {
return { resultCode: 409, message: '发票正在重新生成,请勿重复操作' }
}
try {
const result = await this.applyForInvoices({
...storedRequest(previous.requestBody),
supplierId, settlementId, token, timestamp, RsrvFld1: body.RsrvFld1
}, false)
if (result.resultCode !== 0) {
await orderDao.transaction(tx => tx.supplierInvoiceInfo.updateMany({
where: { invoiceBizId: settlementId, supplierId, invoiceStatus: 1 },
data: { invoiceStatus: 3, failureMessage: result.message }
}))
}
} catch (error) {
const failureMessage = error instanceof Error ? error.message : '重新生成发票失败'
await orderDao.transaction(tx => tx.supplierInvoiceInfo.updateMany({
where: { invoiceBizId: settlementId, supplierId, invoiceStatus: 1 },
data: { invoiceStatus: 3, failureMessage }
}))
return { resultCode: 500, message: failureMessage }
}
invoice = await orderDao.findInvoiceBySettlementId(supplierId, settlementId)
if (!invoice) return { resultCode: 404, message: '未找到对应的开票记录' }
}
const request = storedRequest(invoice.requestBody) const request = storedRequest(invoice.requestBody)
const success = invoice.invoiceStatus === 2 const success = invoice.invoiceStatus === 2
return { return {
@@ -384,8 +448,10 @@ export const supplierOpenOrderService = {
sucOrderIds: success ? invoice.orderIds || '' : '', sucOrderIds: success ? invoice.orderIds || '' : '',
failOrderIds: success ? '' : invoice.orderIds || '', failOrderIds: success ? '' : invoice.orderIds || '',
failMsg: success ? '' : invoice.failureMessage || '开票失败', failMsg: success ? '' : invoice.failureMessage || '开票失败',
invoiceInfos: success ? [{ invoiceInfos: [{
invoiceId: invoice.invoiceIds, status: success ? 1 : 2,
remark: String(request.remark || ''),
invoiceId: invoice.invoiceIds || numericId(`invoice-id:${supplierId}:${settlementId}`, 20),
invoiceCode: invoice.invoiceCode, invoiceCode: invoice.invoiceCode,
invoiceDate: invoice.invoiceDate?.toISOString().slice(0, 10), invoiceDate: invoice.invoiceDate?.toISOString().slice(0, 10),
invoiceNakeAmount: Number(invoice.invoiceNakeAmount || 0), invoiceNakeAmount: Number(invoice.invoiceNakeAmount || 0),
@@ -398,7 +464,7 @@ export const supplierOpenOrderService = {
fileType: invoice.fileType || 'PDF', fileType: invoice.fileType || 'PDF',
checkCode: invoice.checkCode, checkCode: invoice.checkCode,
invoiceAddress: String(request.invoiceAddress || '') invoiceAddress: String(request.invoiceAddress || '')
}] : [] }]
} }
} }
}, },
@@ -445,17 +511,17 @@ export const supplierOpenOrderService = {
if (!invoice || invoice.invoiceStatus !== 2 || (!invoice.fileId && !invoice.filePath)) { if (!invoice || invoice.invoiceStatus !== 2 || (!invoice.fileId && !invoice.filePath)) {
return { resultCode: 404, message: '未找到已开具的发票文件', fileType: 'PDF', imageEncodeMap } return { resultCode: 404, message: '未找到已开具的发票文件', fileType: 'PDF', imageEncodeMap }
} }
if (fileIdList.some(fileId => !invoiceIds.includes(fileId))) { if (!fileIdList.length || invoiceIds.length !== 1 || fileIdList.some(fileId => fileId !== invoiceIds[0]) || (invoice.fileId && invoice.fileId !== invoiceIds[0])) {
return { resultCode: 404, message: '发票号码与结算单不匹配', fileType: 'PDF', imageEncodeMap } return { resultCode: 404, message: '发票号码与结算单不匹配', fileType: 'PDF', imageEncodeMap }
} }
const pdf = invoice.fileId const pdf = invoice.fileId
? await supplierFileService.read(invoice.fileId) ? await supplierFileService.read(invoice.fileId, supplierId)
: null : null
if (!pdf && invoice.filePath) { if (!pdf && invoice.filePath) {
const absoluteFilePath = path.resolve(process.cwd(), invoice.filePath) const absoluteFilePath = path.resolve(process.cwd(), invoice.filePath)
const outputRoot = invoiceOutputRoot() const outputRoot = invoiceOutputRoot()
const relativePath = path.relative(outputRoot, absoluteFilePath) const relativePath = path.relative(outputRoot, absoluteFilePath)
if (!relativePath.startsWith('..') && !path.isAbsolute(relativePath) && existsSync(absoluteFilePath)) { if (!relativePath.startsWith('..') && !path.isAbsolute(relativePath) && path.basename(absoluteFilePath) === `${invoiceIds[0]}.pdf` && existsSync(absoluteFilePath)) {
const localPdf = readFileSync(absoluteFilePath) const localPdf = readFileSync(absoluteFilePath)
if (localPdf.length <= 3.5 * 1024 * 1024) { if (localPdf.length <= 3.5 * 1024 * 1024) {
fileIdList.forEach(fileId => { fileIdList.forEach(fileId => {
@@ -12,11 +12,49 @@ type BridgePointsResponse = {
} }
export const supplierService = { export const supplierService = {
decryptSupplierSession(keyword: string) { async decryptSupplierSession(keyword: string) {
if (!keyword) { if (!keyword) {
return serviceFail('用户参数不能为空') return serviceFail('用户参数不能为空')
} }
if (/^[a-f0-9]{32}$/i.test(keyword)) {
const apiBase = getBridgeApiBase()
if (!apiBase) {
return serviceFail('平台积分商城接口地址未配置', 502, 'BRIDGE_SESSION_FAILED')
}
try {
const response = await fetch(`${apiBase}/connect/supplier-session`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ keyword })
})
const payload = (await response.json()) as {
code?: number
msg?: string
data?: { userId?: unknown }
}
if (!response.ok || payload.code !== 0) {
return serviceFail(payload.msg || '获取用户会话失败')
}
const userId = String(payload.data?.userId || '').trim()
if (!userId) {
return serviceFail('用户参数无效')
}
return serviceOk({ userId })
} catch (error) {
logger.warn('supplier session exchange failed', {
message: error instanceof Error ? error.message : String(error)
})
return serviceFail('获取用户会话失败', 502, 'BRIDGE_SESSION_FAILED')
}
}
const keyHex = process.env.SM4CBC_KEY_HEX || 'a1b2c3d4e5f60718293a4b5c6d7e8f90' const keyHex = process.env.SM4CBC_KEY_HEX || 'a1b2c3d4e5f60718293a4b5c6d7e8f90'
const ivHex = process.env.SM4CBC_IV_HEX || 'f9e8d7c6b5a49382716f0e1d2c3b4a5f' const ivHex = process.env.SM4CBC_IV_HEX || 'f9e8d7c6b5a49382716f0e1d2c3b4a5f'
const sessionKeyword = keyword.replace(/ /g, '+') const sessionKeyword = keyword.replace(/ /g, '+')
+227 -73
View File
@@ -1,4 +1,5 @@
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { deflateSync } from 'node:zlib'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
@@ -104,6 +105,88 @@ const createGlyphMapper = font => {
return codePoint => fromFormat12(codePoint) || fromFormat4(codePoint) 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 utf16Hex = value => {
const buffer = Buffer.from(value, 'utf16le') const buffer = Buffer.from(value, 'utf16le')
buffer.swap16() buffer.swap16()
@@ -202,9 +285,18 @@ const normalizeInvoice = input => {
export const generateMockInvoicePdf = (invoiceInput, outputPath, _options = {}) => { export const generateMockInvoicePdf = (invoiceInput, outputPath, _options = {}) => {
const invoice = normalizeInvoice(invoiceInput) const invoice = normalizeInvoice(invoiceInput)
const commands = [] let commands = []
const font = readFileSync(findFont(_options.fontPath))
const encode = value => utf16Hex(String(value ?? '')) 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') => { 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`) 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') commands.push('1 1 1 rg 0 0 842 595 re f')
text('增值税电子普通发票(模拟)', 280, 548, 22, '0.65 0.05 0.05') text(title, (PAGE_WIDTH - title.length * 21) / 2, 546, 21, labelColor)
line(274, 540, 568, 540, 1.2, '0.65 0.05 0.05') line(245, 531, 590, 531, 0.8, labelColor)
text('仅供系统联调测试,禁止报销', 302, 522, 11, '0.75 0.1 0.1') 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) rect(42, 100, 758, 388, labelColor)
text(`发票代码:${invoice.documentInvoiceCode}`, 610, 555, 9) line(42, 398, 800, 398, 0.7, labelColor)
text(`开票日期:${invoice.invoiceDate}`, 610, 538, 9) line(42, 217, 800, 217, 0.7, labelColor)
text(`校验码:${invoice.checkCode}`, 610, 521, 9) line(42, 183, 800, 183, 0.7, labelColor)
text(`结算单号:${invoice.settlementId || ''}`, 50, 521, 9) 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) const columns = [42, 240, 322, 385, 438, 510, 605, 692, 800]
line(42, 414, 800, 414) ;['项目名称', '规格型号', '单位', '数量', '单价', '金额', '税率/征收率', '税额'].forEach((label, index) => {
line(42, 320, 800, 320) const x = (columns[index] + columns[index + 1] - label.length * 9) / 2
line(42, 280, 800, 280) text(label, x, 382, 9, labelColor)
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)
}) })
wrappedText(`*商品*${invoice.invoiceContent}`, 50, 263, 28, 9) wrappedText(`*商品*${String(invoice.invoiceContent).length > 50 ? '订单商品汇总(详见后附明细)' : invoice.invoiceContent}`, 50, 360, 20, 9, 14)
text(invoice.invoiceType || '电子发票', 300, 263, 8) text('项', 344, 360, 9)
text('项', 401, 263, 9) fitText(invoice.orderNum, 398, 360, 32, 9)
text(invoice.orderNum, 458, 263, 9) fitText(money(invoice.naked / invoice.orderNum), 445, 360, 60, 9)
text(money(invoice.naked / invoice.orderNum), 503, 263, 8) fitText(money(invoice.naked), 526, 360, 73, 9)
text(money(invoice.naked), 590, 263, 8) fitText(`${Number((invoice.taxRate * 100).toFixed(4))}%`, 627, 360, 60, 9)
text(`${(invoice.taxRate * 100).toFixed(0)}%`, 674, 263, 8) fitText(money(invoice.tax), 722, 360, 70, 9)
text(money(invoice.tax), 730, 263, 8) 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) line(235, 183, 235, 217, 0.7, labelColor)
text(`¥${money(invoice.naked)}`, 590, 203, 10) text('价税合计(大写)', 88, 196, 10, labelColor)
text(`¥${money(invoice.tax)}`, 730, 203, 10) fitText(chineseAmount(invoice.total), 245, 196, 340, 11)
text(`价税合计(大写):${chineseAmount(invoice.total)}`, 52, 164, 11) text('(小写)', 603, 196, 10, labelColor)
text(`(小写):¥${money(invoice.total)}`, 600, 164, 11) fitText(`¥${money(invoice.total)}`, 657, 196, 133, 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') line(70, 100, 70, 183, 0.7, labelColor)
commands.push(`<${encode('模拟发票 禁止报销')}> Tj ET Q`) text('备', 51, 153, 10, labelColor)
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') 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() const writer = createPdfWriter()
writer.add('<< /Type /Catalog /Pages 2 0 R >>') 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(`<< /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(streamObject('', pages[0]))
writer.add('<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [6 0 R] >>') writer.add('<< /Type /Font /Subtype /Type0 /BaseFont /InvoiceFont /Encoding /Identity-H /DescendantFonts [6 0 R] /ToUnicode 10 0 R >>')
writer.add('<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> /DW 1000 >>') 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) writer.save(outputPath)
return 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(readFileSync(path.resolve(options.input), 'utf8'))
: JSON.parse(options.data) : JSON.parse(options.data)
const defaultName = `mock-invoice-${String(input.settlementId || Date.now()).replace(/[^a-zA-Z0-9_-]/g, '_')}.pdf` 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 }) generateMockInvoicePdf(input, outputPath, { fontPath: options.font })
console.log(outputPath) console.log(outputPath)
} catch (error) { } 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
}