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
@@ -30,6 +30,13 @@ export const merchantSupplierListController = withErrorBoundary(async (request:
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) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
@@ -45,7 +45,7 @@ export const supplierSessionController = withErrorBoundary(async (request: Reque
const body = await readJson<SupplierSessionRequest>(request)
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) => {
+29 -21
View File
@@ -171,32 +171,40 @@ export const orderDao = {
})
},
findMerchantSubOrders(supplierId: string, orderState?: number, orderId?: string, userId?: string) {
return prisma.supplierOrderSub.findMany({
where: {
supplierId,
deleted: 0,
...(Number.isInteger(orderState) ? { orderState } : {}),
...(orderId ? { OR: [{ orderId: { contains: orderId } }, { orderNumberId: { contains: orderId } }] } : {}),
...(userId ? { userId: { contains: userId } } : {})
},
include: {
products: {
where: { deleted: 0 },
orderBy: { createAt: 'asc' }
}
},
orderBy: [
{ createTime: 'desc' },
{ createAt: 'desc' }
]
})
async findMerchantSubOrders(supplierId: string, orderState?: number, orderId?: string, userId?: string, excludedOrderIds: string[] = [], pagination?: { pageNumber: number; pageSize: number }) {
const where: Prisma.SupplierOrderSubWhereInput = {
...(supplierId ? { supplierId } : {}),
deleted: 0,
...(Number.isInteger(orderState) ? { orderState } : {}),
...(orderId ? { OR: [{ orderId: { contains: orderId } }, { orderNumberId: { contains: orderId } }] } : {}),
...(userId ? { userId: { contains: userId } } : {}),
...(excludedOrderIds.length ? { orderId: { notIn: excludedOrderIds } } : {})
}
const [orders, totalCount] = await prisma.$transaction([
prisma.supplierOrderSub.findMany({
where,
include: {
products: {
where: { deleted: 0 },
orderBy: { createAt: 'asc' }
}
},
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[]) {
return prisma.supplierAfterSale.findMany({
where: {
supplierId,
...(supplierId ? { supplierId } : {}),
deleted: 0,
...(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,
merchantOrderListController,
merchantOrderShipController,
merchantOrderRepushSplitController,
merchantSupplierListController
} from './controllers/merchant.controller.js'
import {
@@ -93,6 +94,7 @@ const handlers: Record<string, FunctionHandler> = {
'merchant-after-sale-ship': merchantAfterSaleShipController,
'merchant-order-list': merchantOrderListController,
'merchant-order-ship': merchantOrderShipController,
'merchant-order-repush-split': merchantOrderRepushSplitController,
'merchant-supplier-list': merchantSupplierListController,
'order-cancel-main': orderCancelMainController,
'order-cancel-paid-main': orderCancelPaidMainController,
@@ -2,6 +2,7 @@ import {
AFTER_SALE_LOGISTICS_STATE,
AFTER_SALE_SERVICE_STATE,
ORDER_EVENT_TYPE,
ORDER_SPLIT_STATE,
ORDER_STATE
} from '../constants/order.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 { orderActionService } from './order-action.service.js'
import { logger } from '../utils/logger.js'
import { bridgeFetchWithAuth } from '../utils/bridge-auth.js'
import { serviceFail, serviceOk } from '../types/service-result.js'
export type MerchantOrderListInput = {
supplierId?: string
orderState?: number
pageNumber?: number
pageSize?: number
orderId?: string
userId?: string
}
@@ -108,19 +112,18 @@ const isOrderSigned = (orderState?: number | null) => signedOrderStates.has(Numb
export const merchantService = {
async listOrders(input: MerchantOrderListInput) {
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 userId = String(input.userId || '').trim()
logger.info('merchant order list query', { supplierId, orderState })
if (!supplierId) {
return serviceFail('供应商不能为空')
}
const orders = await orderDao.findMerchantSubOrders(supplierId, orderState, orderId, userId)
const pageNumber = Number.isInteger(Number(input.pageNumber)) && Number(input.pageNumber) > 0 ? Number(input.pageNumber) : 1
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 afterSales = await orderDao.findMerchantAfterSales(supplierId, pendingShipExcludeAfterSaleStates)
const afterSaleSubOrderIds = new Set(afterSales.map(item => item.subOrderId).filter(Boolean))
const orderItems = orders.filter(order => !afterSaleSubOrderIds.has(order.orderId)).map(order => ({
const afterSaleSubOrderIds = afterSales.map(item => item.subOrderId).filter((id): id is string => Boolean(id))
const { orders, totalCount } = await orderDao.findMerchantSubOrders(supplierId, orderState, orderId, userId, afterSaleSubOrderIds, pagination)
const orderItems = orders.map(order => ({
orderId: order.orderId,
mainOrderId: order.mainOrderId,
userId: order.userId || '',
@@ -141,7 +144,7 @@ export const merchantService = {
}))
}))
return serviceOk({ orderItems })
return serviceOk({ orderItems, totalCount, pageNumber, pageSize })
},
async listSuppliers() {
@@ -149,6 +152,57 @@ export const merchantService = {
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) {
return orderActionService.shipOrder({
supplierId: String(input.supplierId || '').trim(),
@@ -31,7 +31,7 @@ export const supplierFileService = {
const now = new Date()
const pad = (value: number) => String(value).padStart(2, '0')
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)
await client.fPutObject(bucketName, objectName, filePath, {
'Content-Type': 'application/pdf'
@@ -44,8 +44,8 @@ export const supplierFileService = {
return { fileId, objectName }
},
async read(fileId: string) {
const file = await prisma.supplierFile.findFirst({ where: { fileId, deleted: 0 } })
async read(fileId: string, supplierId: string) {
const file = await prisma.supplierFile.findFirst({ where: { fileId, supplierId, deleted: 0 } })
if (!file) return null
const stream = await client.getObject(file.bucketName, file.objectName)
return streamToBuffer(stream)
@@ -1,5 +1,5 @@
import { createHash } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { existsSync, readFileSync, unlinkSync } from 'node:fs'
import path from 'node:path'
import { supplierDao } from '../dao/supplier.dao.js'
import { orderDao } from '../dao/order.dao.js'
@@ -72,6 +72,7 @@ export type SupplierFileBase64QueryRequest = {
}
export type SupplierInvoiceQueryRequest = {
retry?: boolean
supplierId?: string
token?: string
settlementId?: string
@@ -90,8 +91,8 @@ const parseBody = <T extends SupplierOpenEncryptedRequest>(body: T) => {
const encryptedData = body.AplParmObjct || body.ApiParamObject
if (!encryptedData) return body
const keyHex = process.env.SM4CBC_KEY_HEX || ''
const ivHex = process.env.SM4CBC_IV_HEX || ''
const keyHex = process.env.SM4CBC_KEY_HEX || process.env.SUPPLIER_OPEN_SM4_KEY_HEX || ''
const ivHex = process.env.SM4CBC_IV_HEX || process.env.SUPPLIER_OPEN_SM4_IV_HEX || ''
if (!keyHex || !ivHex) {
throw new Error('供应商开放接口SM4配置缺失')
}
@@ -115,7 +116,7 @@ const numericId = (seed: string, length: number) => BigInt(`0x${createHash('sha2
.padStart(length, '0')
.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)
? value as Record<string, unknown>
@@ -170,7 +171,7 @@ const productImageSource = (product?: Awaited<ReturnType<typeof orderDao.findOpe
export const supplierOpenOrderService = {
parseBody,
async applyForInvoices(body: SupplierInvoiceApplyRequest) {
async applyForInvoices(body: SupplierInvoiceApplyRequest, notifyPlatform = true) {
const supplierId = String(body.supplierId || '').trim()
const token = String(body.token || '').trim()
const settlementId = String(body.settlementId || '').trim()
@@ -265,12 +266,23 @@ export const supplierOpenOrderService = {
const requestedInvoiceType = Number(body.invoiceType || 10)
const invoiceType = Number.isInteger(requestedInvoiceType) ? requestedInvoiceType : 10
const absoluteFilePath = path.join(invoiceOutputRoot(), supplierId, `${invoiceId}.pdf`)
const filePath = path.relative(process.cwd(), absoluteFilePath).replaceAll('\\', '/')
try {
generateMockInvoicePdf({
...body,
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,
invoiceNumber: invoiceId,
checkCode,
@@ -281,6 +293,11 @@ export const supplierOpenOrderService = {
taxRate
}, 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))]
await orderDao.transaction(async tx => {
@@ -299,7 +316,7 @@ export const supplierOpenOrderService = {
checkCode,
fileType: 'PDF',
fileId: invoiceId,
filePath,
filePath: null,
failureMessage: null
}
})
@@ -319,13 +336,29 @@ export const supplierOpenOrderService = {
await orderDao.transaction(async tx => {
await tx.supplierInvoiceInfo.update({
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 })
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 }
}
if (!notifyPlatform) {
return { resultCode: 0, message: '发票重新生成成功' }
}
const pushResult = await orderPushEventService.pushEvent({
supplierId,
userId: String(firstOrder?.userId || 'SYSTEM'),
@@ -337,8 +370,7 @@ export const supplierOpenOrderService = {
invoiceCode,
invoiceIds: invoiceId,
orderIds: orderIdText,
fileId: invoiceId,
filePath
fileId: invoiceId
})
const pushSuccess = pushResult.success && pushResult.data.pushSuccess
@@ -365,7 +397,7 @@ export const supplierOpenOrderService = {
return { resultCode: 401, message: '签名无效或已过期' }
}
const invoice = await orderDao.findInvoiceBySettlementId(supplierId, settlementId)
let invoice = await orderDao.findInvoiceBySettlementId(supplierId, settlementId)
if (!invoice) {
return { resultCode: 404, message: '未找到对应的开票记录' }
}
@@ -373,6 +405,38 @@ export const supplierOpenOrderService = {
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 success = invoice.invoiceStatus === 2
return {
@@ -384,8 +448,10 @@ export const supplierOpenOrderService = {
sucOrderIds: success ? invoice.orderIds || '' : '',
failOrderIds: success ? '' : invoice.orderIds || '',
failMsg: success ? '' : invoice.failureMessage || '开票失败',
invoiceInfos: success ? [{
invoiceId: invoice.invoiceIds,
invoiceInfos: [{
status: success ? 1 : 2,
remark: String(request.remark || ''),
invoiceId: invoice.invoiceIds || numericId(`invoice-id:${supplierId}:${settlementId}`, 20),
invoiceCode: invoice.invoiceCode,
invoiceDate: invoice.invoiceDate?.toISOString().slice(0, 10),
invoiceNakeAmount: Number(invoice.invoiceNakeAmount || 0),
@@ -398,7 +464,7 @@ export const supplierOpenOrderService = {
fileType: invoice.fileType || 'PDF',
checkCode: invoice.checkCode,
invoiceAddress: String(request.invoiceAddress || '')
}] : []
}]
}
}
},
@@ -445,17 +511,17 @@ export const supplierOpenOrderService = {
if (!invoice || invoice.invoiceStatus !== 2 || (!invoice.fileId && !invoice.filePath)) {
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 }
}
const pdf = invoice.fileId
? await supplierFileService.read(invoice.fileId)
? await supplierFileService.read(invoice.fileId, supplierId)
: null
if (!pdf && invoice.filePath) {
const absoluteFilePath = path.resolve(process.cwd(), invoice.filePath)
const outputRoot = invoiceOutputRoot()
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)
if (localPdf.length <= 3.5 * 1024 * 1024) {
fileIdList.forEach(fileId => {
@@ -12,11 +12,49 @@ type BridgePointsResponse = {
}
export const supplierService = {
decryptSupplierSession(keyword: string) {
async decryptSupplierSession(keyword: string) {
if (!keyword) {
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 ivHex = process.env.SM4CBC_IV_HEX || 'f9e8d7c6b5a49382716f0e1d2c3b4a5f'
const sessionKeyword = keyword.replace(/ /g, '+')