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