feat: 积分商城-供应商-对账-发票
This commit is contained in:
@@ -16,6 +16,7 @@ import { orderListService } from '../services/order-list.service.js'
|
||||
import { orderPushEventService, type OrderPushEventInput } from '../services/order-push-event.service.js'
|
||||
import {
|
||||
supplierOpenOrderService,
|
||||
type SupplierFileBase64QueryRequest,
|
||||
type SupplierInvoiceApplyRequest,
|
||||
type SupplierInvoiceQueryRequest,
|
||||
type SupplierOrderQueryRequest,
|
||||
@@ -270,7 +271,7 @@ export const supplierInvoiceApplyController = withErrorBoundary(async (request:
|
||||
|
||||
let body: SupplierInvoiceApplyRequest
|
||||
try {
|
||||
body = supplierOpenOrderService.parseBody(await readJson<SupplierInvoiceApplyRequest>(request)) as SupplierInvoiceApplyRequest
|
||||
body = supplierOpenOrderService.parseBody(await readJson<SupplierInvoiceApplyRequest>(request))
|
||||
} catch (error) {
|
||||
return Response.json({
|
||||
resultCode: 400,
|
||||
@@ -287,7 +288,7 @@ export const supplierInvoiceInfoController = withErrorBoundary(async (request: R
|
||||
|
||||
let body: SupplierInvoiceQueryRequest
|
||||
try {
|
||||
body = supplierOpenOrderService.parseBody(await readJson<SupplierInvoiceQueryRequest>(request)) as SupplierInvoiceQueryRequest
|
||||
body = supplierOpenOrderService.parseBody(await readJson<SupplierInvoiceQueryRequest>(request))
|
||||
} catch (error) {
|
||||
return Response.json({
|
||||
resultCode: 400,
|
||||
@@ -295,5 +296,24 @@ export const supplierInvoiceInfoController = withErrorBoundary(async (request: R
|
||||
})
|
||||
}
|
||||
|
||||
return Response.json(await supplierOpenOrderService.queryInvoicesInfo(body))
|
||||
return Response.json(await supplierOpenOrderService.queryInvoiceInfo(body))
|
||||
})
|
||||
|
||||
export const supplierFileBase64InfoController = withErrorBoundary(async (request: Request, _context: Context) => {
|
||||
const methodResponse = guardMethod(request, ['POST'])
|
||||
if (methodResponse) return methodResponse
|
||||
|
||||
let body: SupplierFileBase64QueryRequest
|
||||
try {
|
||||
body = supplierOpenOrderService.parseBody(await readJson<SupplierFileBase64QueryRequest>(request))
|
||||
} catch (error) {
|
||||
return Response.json({
|
||||
resultCode: 400,
|
||||
message: error instanceof Error ? error.message : '请求参数解密失败',
|
||||
fileType: 'JPG',
|
||||
imageEncodeMap: {}
|
||||
})
|
||||
}
|
||||
|
||||
return Response.json(await supplierOpenOrderService.queryFileBase64Info(body))
|
||||
})
|
||||
|
||||
@@ -9,8 +9,12 @@ type SupplierInfoRequest = {
|
||||
supplierIdentification?: string
|
||||
}
|
||||
|
||||
type SupplierSessionRequest = {
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
type SupplierTokenRequest = {
|
||||
AplParmObjct?: string
|
||||
userId?: string
|
||||
supplierIdentification?: string
|
||||
}
|
||||
|
||||
@@ -34,15 +38,25 @@ export const supplierInfoController = withErrorBoundary(async (request: Request,
|
||||
return toHttpResponse(await supplierService.getSupplierInfo(supplierIdentification))
|
||||
})
|
||||
|
||||
export const supplierSessionController = withErrorBoundary(async (request: Request, _context: Context) => {
|
||||
const methodResponse = guardMethod(request, ['POST'])
|
||||
if (methodResponse) return methodResponse
|
||||
|
||||
const body = await readJson<SupplierSessionRequest>(request)
|
||||
const keyword = String(body.keyword || '')
|
||||
|
||||
return toHttpResponse(supplierService.decryptSupplierSession(keyword))
|
||||
})
|
||||
|
||||
export const supplierTokenController = withErrorBoundary(async (request: Request, _context: Context) => {
|
||||
const methodResponse = guardMethod(request, ['POST'])
|
||||
if (methodResponse) return methodResponse
|
||||
|
||||
const body = await readJson<SupplierTokenRequest>(request)
|
||||
const encryptedParam = String(body.AplParmObjct || '').trim()
|
||||
const userId = String(body.userId || '').trim()
|
||||
const supplierIdentification = String(body.supplierIdentification || '').trim().toLowerCase()
|
||||
|
||||
return toHttpResponse(await supplierService.warmSupplierToken(encryptedParam, supplierIdentification))
|
||||
return toHttpResponse(await supplierService.warmSupplierToken(userId, supplierIdentification))
|
||||
})
|
||||
|
||||
export const supplierPointsController = withErrorBoundary(async (request: Request, _context: Context) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ export type PrismaTransaction = Prisma.TransactionClient
|
||||
|
||||
export const orderDao = {
|
||||
transaction<T>(handler: (tx: PrismaTransaction) => Promise<T>) {
|
||||
return prisma.$transaction(handler)
|
||||
return prisma.$transaction(handler, { maxWait: 10000, timeout: 20000 })
|
||||
},
|
||||
|
||||
findMainOrdersWithSubs(supplierId: string, userId: string) {
|
||||
@@ -137,6 +137,16 @@ export const orderDao = {
|
||||
})
|
||||
},
|
||||
|
||||
findInvoiceBySettlementId(supplierId: string, settlementId: string) {
|
||||
return prisma.supplierInvoiceInfo.findFirst({
|
||||
where: {
|
||||
supplierId,
|
||||
settlementId,
|
||||
deleted: 0
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
findOpenApiReconciliationSubOrders(supplierId: string, orderIds: string[], startDate?: Date, endDate?: Date, orderState?: number) {
|
||||
return prisma.supplierOrderSub.findMany({
|
||||
where: {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
orderPayController,
|
||||
orderPushEventController,
|
||||
orderShipController,
|
||||
supplierFileBase64InfoController,
|
||||
supplierInvoiceApplyController,
|
||||
supplierInvoiceInfoController,
|
||||
supplierOrderInfoController,
|
||||
@@ -65,6 +66,7 @@ import {
|
||||
supplierDebugTokensController,
|
||||
supplierInfoController,
|
||||
supplierPointsController,
|
||||
supplierSessionController,
|
||||
supplierTokenController
|
||||
} from './controllers/supplier.controller.js'
|
||||
|
||||
@@ -108,9 +110,10 @@ const handlers: Record<string, FunctionHandler> = {
|
||||
'merchant-product-save': merchantProductSaveController,
|
||||
'merchant-product-delete': merchantProductDeleteController,
|
||||
'supplier-debug-tokens': supplierDebugTokensController,
|
||||
'supplier-info': supplierInfoController,
|
||||
'supplier-file-base64-info': supplierFileBase64InfoController,
|
||||
'supplier-invoice-apply': supplierInvoiceApplyController,
|
||||
'supplier-invoice-info': supplierInvoiceInfoController,
|
||||
'supplier-info': supplierInfoController,
|
||||
'supplier-order-info': supplierOrderInfoController,
|
||||
'supplier-reconciliation-info': supplierReconciliationInfoController,
|
||||
'supplier-reconciliation-result': supplierReconciliationResultController,
|
||||
@@ -118,6 +121,7 @@ const handlers: Record<string, FunctionHandler> = {
|
||||
'merchant-reconciliation-list': merchantReconciliationListController,
|
||||
'merchant-reconciliation-update': merchantReconciliationUpdateController,
|
||||
'supplier-points': supplierPointsController,
|
||||
'supplier-session': supplierSessionController,
|
||||
'supplier-token': supplierTokenController,
|
||||
upload: uploadController,
|
||||
'upload-preview': uploadPreviewController,
|
||||
@@ -144,11 +148,29 @@ const toRequest = (ctx: Koa.Context) =>
|
||||
body: requestBody(ctx)
|
||||
})
|
||||
|
||||
const writeResponse = async (ctx: Koa.Context, response: Response) => {
|
||||
const ba7ResponseBody = (content: string) => ({
|
||||
status: 200,
|
||||
msg: 'success',
|
||||
data: {
|
||||
Body: {
|
||||
RspSvcHeader: {
|
||||
ReturnCode: '000000000000',
|
||||
ReturnMsg: '交易成功'
|
||||
},
|
||||
RspSvcBody: {
|
||||
ReturnStCd: '200',
|
||||
ReturnCntnt: content
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const writeResponse = async (ctx: Koa.Context, response: Response, wrapBa7 = false) => {
|
||||
ctx.status = response.status
|
||||
Object.entries(corsHeaders).forEach(([key, value]) => ctx.set(key, value))
|
||||
response.headers.forEach((value, key) => ctx.set(key, value))
|
||||
ctx.body = response.body ? await response.text() : undefined
|
||||
const responseText = response.body ? await response.text() : ''
|
||||
ctx.body = wrapBa7 ? ba7ResponseBody(responseText) : responseText || undefined
|
||||
}
|
||||
|
||||
const dispatch = (name: string) => async (ctx: Koa.Context) => {
|
||||
@@ -170,7 +192,7 @@ const dispatch = (name: string) => async (ctx: Koa.Context) => {
|
||||
return
|
||||
}
|
||||
const response = await handler(toRequest(ctx), {})
|
||||
await writeResponse(ctx, response)
|
||||
await writeResponse(ctx, response, ctx.path.startsWith('/supplierOpenApi/'))
|
||||
}
|
||||
|
||||
app.use(bodyParser({ jsonLimit: '10mb' }))
|
||||
@@ -206,6 +228,7 @@ router.post('/supplierOpenApi/pointOrder/queryOrderInfo', dispatch('supplier-ord
|
||||
router.post('/supplierOpenApi/pointOrder/queryReconciliationInfo', dispatch('supplier-reconciliation-info'))
|
||||
router.post('/supplierOpenApi/pointOrder/applyForInvoices', dispatch('supplier-invoice-apply'))
|
||||
router.post('/supplierOpenApi/pointOrder/queryInvoicesInfo', dispatch('supplier-invoice-info'))
|
||||
router.post('/supplierOpenApi/pointOrder/queryFileBase64Info', dispatch('supplier-file-base64-info'))
|
||||
router.post('/supplierOpenApi/pointOrder/reconciliationResult', dispatch('supplier-reconciliation-result'))
|
||||
router.post('/supplierOpenApi/pointOrder/unReconciledMsg', dispatch('supplier-unreconciled-msg'))
|
||||
router.post('/supplierOpenApi/debug/tokens', dispatch('supplier-debug-tokens'))
|
||||
|
||||
@@ -190,7 +190,6 @@ export const orderPushEventService = {
|
||||
invoiceStatus: 2,
|
||||
fileId: body.fileId || null,
|
||||
filePath: body.filePath || null,
|
||||
requestBody: body,
|
||||
deleted: 0
|
||||
},
|
||||
create: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { prisma } from '../utils/prisma.js'
|
||||
import { supplierDao } from '../dao/supplier.dao.js'
|
||||
import { verifySupplierOpenSignature } from '../utils/supplier-open-crypto.js'
|
||||
@@ -9,7 +9,7 @@ export type ReconciliationResultItem = {
|
||||
mainOrderId?: string
|
||||
userId?: string
|
||||
reconciliationStatus?: number
|
||||
diffType?: number
|
||||
diffType?: string | number
|
||||
diffReason?: string
|
||||
platformTotalPrice?: number
|
||||
platformCashAmount?: number
|
||||
@@ -41,7 +41,7 @@ export type ReconciliationResultUpdateInput = {
|
||||
supplierId?: string
|
||||
orderId?: string
|
||||
reconciliationStatus?: number
|
||||
diffType?: number
|
||||
diffType?: string | number
|
||||
diffReason?: string
|
||||
}
|
||||
|
||||
@@ -53,7 +53,38 @@ const toTime = (value?: string) => {
|
||||
return Number.isNaN(time.getTime()) ? new Date() : time
|
||||
}
|
||||
|
||||
const resultItem = (record: Awaited<ReturnType<typeof prisma.supplierReconciliationOrder.findMany>>[number]) => ({
|
||||
type DecimalValue = { toFixed: (fractionDigits?: number) => string } | number | string
|
||||
|
||||
type SupplierReconciliationOrderRecord = {
|
||||
supplierId: string
|
||||
orderId: string
|
||||
mainOrderId: string | null
|
||||
userId: string | null
|
||||
reconciliationStatus: number
|
||||
diffType: string | null
|
||||
diffReason: string | null
|
||||
platformTotalPrice: DecimalValue | null
|
||||
platformCashAmount: DecimalValue | null
|
||||
platformPointsPrice: DecimalValue | null
|
||||
platformRefundPrice: DecimalValue | null
|
||||
supplierTotalPrice: DecimalValue | null
|
||||
supplierCashAmount: DecimalValue | null
|
||||
supplierPointsPrice: DecimalValue | null
|
||||
supplierRefundPrice: DecimalValue | null
|
||||
reconciliationBatchNo: string | null
|
||||
reconciliationTime: Date | null
|
||||
updateAt: Date
|
||||
deleted?: number
|
||||
}
|
||||
|
||||
const formatAmount = (value: DecimalValue | null) => {
|
||||
if (value === undefined || value === null) return ''
|
||||
if (typeof value === 'object') return value.toFixed(2)
|
||||
const numberValue = Number(value)
|
||||
return Number.isNaN(numberValue) ? '' : numberValue.toFixed(2)
|
||||
}
|
||||
|
||||
const resultItem = (record: SupplierReconciliationOrderRecord) => ({
|
||||
supplierId: record.supplierId,
|
||||
orderId: record.orderId,
|
||||
mainOrderId: record.mainOrderId || '',
|
||||
@@ -61,14 +92,14 @@ const resultItem = (record: Awaited<ReturnType<typeof prisma.supplierReconciliat
|
||||
reconciliationStatus: record.reconciliationStatus,
|
||||
diffType: record.diffType,
|
||||
diffReason: record.diffReason || '',
|
||||
platformTotalPrice: record.platformTotalPrice?.toFixed(2) || '',
|
||||
platformCashAmount: record.platformCashAmount?.toFixed(2) || '',
|
||||
platformPointsPrice: record.platformPointsPrice?.toFixed(2) || '',
|
||||
platformRefundPrice: record.platformRefundPrice?.toFixed(2) || '',
|
||||
supplierTotalPrice: record.supplierTotalPrice?.toFixed(2) || '',
|
||||
supplierCashAmount: record.supplierCashAmount?.toFixed(2) || '',
|
||||
supplierPointsPrice: record.supplierPointsPrice?.toFixed(2) || '',
|
||||
supplierRefundPrice: record.supplierRefundPrice?.toFixed(2) || '',
|
||||
platformTotalPrice: formatAmount(record.platformTotalPrice),
|
||||
platformCashAmount: formatAmount(record.platformCashAmount),
|
||||
platformPointsPrice: formatAmount(record.platformPointsPrice),
|
||||
platformRefundPrice: formatAmount(record.platformRefundPrice),
|
||||
supplierTotalPrice: formatAmount(record.supplierTotalPrice),
|
||||
supplierCashAmount: formatAmount(record.supplierCashAmount),
|
||||
supplierPointsPrice: formatAmount(record.supplierPointsPrice),
|
||||
supplierRefundPrice: formatAmount(record.supplierRefundPrice),
|
||||
reconciliationBatchNo: record.reconciliationBatchNo || '',
|
||||
reconciliationTime: record.reconciliationTime,
|
||||
updateAt: record.updateAt
|
||||
@@ -105,7 +136,7 @@ export const reconciliationResultService = {
|
||||
mainOrderId: String(record.mainOrderId || '').trim() || null,
|
||||
userId: String(record.userId || '').trim() || null,
|
||||
reconciliationStatus: Number(record.reconciliationStatus),
|
||||
diffType: record.diffType === undefined || record.diffType === null ? null : Number(record.diffType),
|
||||
diffType: record.diffType === undefined || record.diffType === null ? null : String(record.diffType).trim() || null,
|
||||
diffReason: String(record.diffReason || '').trim() || null,
|
||||
platformTotalPrice: toNumber(record.platformTotalPrice),
|
||||
platformCashAmount: toNumber(record.platformCashAmount),
|
||||
@@ -117,14 +148,40 @@ export const reconciliationResultService = {
|
||||
supplierRefundPrice: toNumber(record.supplierRefundPrice),
|
||||
reconciliationBatchNo: String(input.reconciliationBatchNo || '').trim() || null,
|
||||
reconciliationTime,
|
||||
resultPayload: record as Prisma.InputJsonValue,
|
||||
resultPayload: JSON.stringify(record),
|
||||
deleted: 0
|
||||
}
|
||||
return prisma.supplierReconciliationOrder.upsert({
|
||||
where: { supplierId_orderId: { supplierId, orderId } },
|
||||
update: data,
|
||||
create: { supplierId, orderId, ...data }
|
||||
})
|
||||
return prisma.$executeRaw`
|
||||
INSERT INTO supplier_reconciliation_order
|
||||
(supplierId, orderId, mainOrderId, userId, reconciliationStatus, diffType, diffReason,
|
||||
platformTotalPrice, platformCashAmount, platformPointsPrice, platformRefundPrice,
|
||||
supplierTotalPrice, supplierCashAmount, supplierPointsPrice, supplierRefundPrice,
|
||||
reconciliationBatchNo, reconciliationTime, resultPayload, deleted, createAt, updateAt)
|
||||
VALUES
|
||||
(${supplierId}, ${orderId}, ${data.mainOrderId}, ${data.userId}, ${data.reconciliationStatus}, ${data.diffType}, ${data.diffReason},
|
||||
${data.platformTotalPrice}, ${data.platformCashAmount}, ${data.platformPointsPrice}, ${data.platformRefundPrice},
|
||||
${data.supplierTotalPrice}, ${data.supplierCashAmount}, ${data.supplierPointsPrice}, ${data.supplierRefundPrice},
|
||||
${data.reconciliationBatchNo}, ${data.reconciliationTime}, ${data.resultPayload}, ${data.deleted}, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3))
|
||||
ON DUPLICATE KEY UPDATE
|
||||
mainOrderId = VALUES(mainOrderId),
|
||||
userId = VALUES(userId),
|
||||
reconciliationStatus = VALUES(reconciliationStatus),
|
||||
diffType = VALUES(diffType),
|
||||
diffReason = VALUES(diffReason),
|
||||
platformTotalPrice = VALUES(platformTotalPrice),
|
||||
platformCashAmount = VALUES(platformCashAmount),
|
||||
platformPointsPrice = VALUES(platformPointsPrice),
|
||||
platformRefundPrice = VALUES(platformRefundPrice),
|
||||
supplierTotalPrice = VALUES(supplierTotalPrice),
|
||||
supplierCashAmount = VALUES(supplierCashAmount),
|
||||
supplierPointsPrice = VALUES(supplierPointsPrice),
|
||||
supplierRefundPrice = VALUES(supplierRefundPrice),
|
||||
reconciliationBatchNo = VALUES(reconciliationBatchNo),
|
||||
reconciliationTime = VALUES(reconciliationTime),
|
||||
resultPayload = VALUES(resultPayload),
|
||||
deleted = VALUES(deleted),
|
||||
updateAt = CURRENT_TIMESTAMP(3)
|
||||
`
|
||||
}))
|
||||
|
||||
return { resultCode: 0, message: '成功' }
|
||||
@@ -138,15 +195,19 @@ export const reconciliationResultService = {
|
||||
return serviceFail('对账状态不正确')
|
||||
}
|
||||
|
||||
const records = await prisma.supplierReconciliationOrder.findMany({
|
||||
where: {
|
||||
supplierId,
|
||||
deleted: 0,
|
||||
...(reconciliationStatus === undefined ? {} : { reconciliationStatus }),
|
||||
...(String(input.orderId || '').trim() ? { orderId: { contains: String(input.orderId).trim() } } : {})
|
||||
},
|
||||
orderBy: { updateAt: 'desc' }
|
||||
})
|
||||
const orderId = String(input.orderId || '').trim()
|
||||
const records = await prisma.$queryRaw<SupplierReconciliationOrderRecord[]>`
|
||||
SELECT supplierId, orderId, mainOrderId, userId, reconciliationStatus, diffType, diffReason,
|
||||
platformTotalPrice, platformCashAmount, platformPointsPrice, platformRefundPrice,
|
||||
supplierTotalPrice, supplierCashAmount, supplierPointsPrice, supplierRefundPrice,
|
||||
reconciliationBatchNo, reconciliationTime, updateAt
|
||||
FROM supplier_reconciliation_order
|
||||
WHERE supplierId = ${supplierId}
|
||||
AND deleted = 0
|
||||
${reconciliationStatus === undefined ? Prisma.empty : Prisma.sql`AND reconciliationStatus = ${reconciliationStatus}`}
|
||||
${orderId ? Prisma.sql`AND orderId LIKE ${`%${orderId}%`}` : Prisma.empty}
|
||||
ORDER BY updateAt DESC
|
||||
`
|
||||
return serviceOk({ reconciliationItems: records.map(resultItem) })
|
||||
},
|
||||
|
||||
@@ -158,19 +219,30 @@ export const reconciliationResultService = {
|
||||
return serviceFail('供应商、子订单或对账状态不正确')
|
||||
}
|
||||
|
||||
const record = await prisma.supplierReconciliationOrder.findUnique({
|
||||
where: { supplierId_orderId: { supplierId, orderId } }
|
||||
})
|
||||
const records = await prisma.$queryRaw<SupplierReconciliationOrderRecord[]>`
|
||||
SELECT supplierId, orderId, mainOrderId, userId, reconciliationStatus, diffType, diffReason,
|
||||
platformTotalPrice, platformCashAmount, platformPointsPrice, platformRefundPrice,
|
||||
supplierTotalPrice, supplierCashAmount, supplierPointsPrice, supplierRefundPrice,
|
||||
reconciliationBatchNo, reconciliationTime, updateAt, deleted
|
||||
FROM supplier_reconciliation_order
|
||||
WHERE supplierId = ${supplierId}
|
||||
AND orderId = ${orderId}
|
||||
LIMIT 1
|
||||
`
|
||||
const record = records[0]
|
||||
if (!record || record.deleted !== 0) return serviceFail('对账结果不存在')
|
||||
|
||||
await prisma.supplierReconciliationOrder.update({
|
||||
where: { supplierId_orderId: { supplierId, orderId } },
|
||||
data: {
|
||||
reconciliationStatus,
|
||||
diffType: input.diffType === undefined || input.diffType === null ? null : Number(input.diffType),
|
||||
diffReason: String(input.diffReason || '').trim() || null
|
||||
}
|
||||
})
|
||||
const diffType = input.diffType === undefined || input.diffType === null ? null : String(input.diffType).trim() || null
|
||||
const diffReason = String(input.diffReason || '').trim() || null
|
||||
await prisma.$executeRaw`
|
||||
UPDATE supplier_reconciliation_order
|
||||
SET reconciliationStatus = ${reconciliationStatus},
|
||||
diffType = ${diffType},
|
||||
diffReason = ${diffReason},
|
||||
updateAt = CURRENT_TIMESTAMP(3)
|
||||
WHERE supplierId = ${supplierId}
|
||||
AND orderId = ${orderId}
|
||||
`
|
||||
return serviceOk({})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Client } from 'minio'
|
||||
import { statSync } from 'node:fs'
|
||||
import { prisma } from '../utils/prisma.js'
|
||||
|
||||
const endpoint = new URL(process.env.MINIO_ENDPOINT || 'http://localhost:9000')
|
||||
const bucketName = process.env.MINIO_BUCKET || 'unions'
|
||||
const client = new Client({
|
||||
endPoint: endpoint.hostname,
|
||||
port: Number(endpoint.port || (endpoint.protocol === 'https:' ? 443 : 80)),
|
||||
useSSL: endpoint.protocol === 'https:',
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || '',
|
||||
secretKey: process.env.MINIO_SECRET_KEY || '',
|
||||
region: process.env.MINIO_REGION || 'us-east-1'
|
||||
})
|
||||
|
||||
const ensureBucket = async () => {
|
||||
if (!(await client.bucketExists(bucketName))) {
|
||||
await client.makeBucket(bucketName, process.env.MINIO_REGION || 'us-east-1')
|
||||
}
|
||||
}
|
||||
|
||||
const streamToBuffer = async (stream: NodeJS.ReadableStream) => {
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of stream as AsyncIterable<Buffer>) chunks.push(Buffer.from(chunk))
|
||||
return Buffer.concat(chunks)
|
||||
}
|
||||
|
||||
export const supplierFileService = {
|
||||
async uploadPdf(fileId: string, supplierId: string, fileName: string, filePath: string) {
|
||||
await ensureBucket()
|
||||
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 stat = statSync(filePath)
|
||||
await client.fPutObject(bucketName, objectName, filePath, {
|
||||
'Content-Type': 'application/pdf'
|
||||
})
|
||||
await prisma.supplierFile.upsert({
|
||||
where: { fileId },
|
||||
update: { supplierId, fileName, objectName, bucketName, contentType: 'application/pdf', fileSize: stat.size, deleted: 0 },
|
||||
create: { fileId, supplierId, fileName, objectName, bucketName, contentType: 'application/pdf', fileSize: stat.size }
|
||||
})
|
||||
return { fileId, objectName }
|
||||
},
|
||||
|
||||
async read(fileId: string) {
|
||||
const file = await prisma.supplierFile.findFirst({ where: { fileId, deleted: 0 } })
|
||||
if (!file) return null
|
||||
const stream = await client.getObject(file.bucketName, file.objectName)
|
||||
return streamToBuffer(stream)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { supplierDao } from '../dao/supplier.dao.js'
|
||||
import { orderDao } from '../dao/order.dao.js'
|
||||
import { ORDER_EVENT_TYPE, ORDER_STATE } from '../constants/order.js'
|
||||
import { orderPushEventService } from './order-push-event.service.js'
|
||||
import { logger } from '../utils/logger.js'
|
||||
import { prisma } from '../utils/prisma.js'
|
||||
import { sm4CbcDecrypt, verifySupplierOpenSignature } from '../utils/supplier-open-crypto.js'
|
||||
import { generateMockInvoicePdf } from '../../tools/mock-invoice-pdf.js'
|
||||
import { supplierFileService } from './supplier-file.service.js'
|
||||
|
||||
export type SupplierOrderQueryRequest = {
|
||||
supplierId?: string
|
||||
@@ -36,9 +41,10 @@ export type SupplierInvoiceApplyRequest = {
|
||||
orderIds?: string
|
||||
orderNum?: number
|
||||
orderTotalPrice?: number | string
|
||||
invoiceDate?: string
|
||||
invoiceDate?: string | number
|
||||
invoiceTitle?: string
|
||||
invoiceCode?: string
|
||||
taxIdNumber?: string
|
||||
taxIDNumber?: string
|
||||
invoiceAddress?: string
|
||||
invoiceContact?: string
|
||||
invoiceNakedPrice?: number | string
|
||||
@@ -54,12 +60,24 @@ export type SupplierInvoiceApplyRequest = {
|
||||
RsrvFld1?: string
|
||||
}
|
||||
|
||||
export type SupplierFileBase64QueryRequest = {
|
||||
supplierId?: string
|
||||
token?: string
|
||||
queryType?: string
|
||||
idNumber?: string
|
||||
fileIdList?: string[]
|
||||
timestamp?: number
|
||||
ApiParamObject?: string
|
||||
RsrvFld1?: string
|
||||
}
|
||||
|
||||
export type SupplierInvoiceQueryRequest = {
|
||||
supplierId?: string
|
||||
token?: string
|
||||
settlementId?: string
|
||||
timestamp?: number
|
||||
AplParmObjct?: string
|
||||
RsrvFld1?: string
|
||||
}
|
||||
|
||||
type SupplierOpenEncryptedRequest = {
|
||||
@@ -72,8 +90,8 @@ const parseBody = <T extends SupplierOpenEncryptedRequest>(body: T) => {
|
||||
const encryptedData = body.AplParmObjct || body.ApiParamObject
|
||||
if (!encryptedData) return body
|
||||
|
||||
const keyHex = process.env.SUPPLIER_OPEN_SM4_KEY_HEX || process.env.SM4CBC_KEY_HEX || ''
|
||||
const ivHex = process.env.SUPPLIER_OPEN_SM4_IV_HEX || process.env.SM4CBC_IV_HEX || ''
|
||||
const keyHex = process.env.SM4CBC_KEY_HEX || ''
|
||||
const ivHex = process.env.SM4CBC_IV_HEX || ''
|
||||
if (!keyHex || !ivHex) {
|
||||
throw new Error('供应商开放接口SM4配置缺失')
|
||||
}
|
||||
@@ -92,6 +110,36 @@ const toText = (date?: Date | null) => {
|
||||
|
||||
const toMoney = (value: { toFixed: (digits: number) => string } | number | string | null | undefined) => Number(value || 0).toFixed(2)
|
||||
|
||||
const numericId = (seed: string, length: number) => BigInt(`0x${createHash('sha256').update(seed).digest('hex')}`)
|
||||
.toString()
|
||||
.padStart(length, '0')
|
||||
.slice(0, length)
|
||||
|
||||
const invoiceOutputRoot = () => path.resolve(process.env.INVOICE_PDF_OUTPUT_DIR || path.join(process.cwd(), 'output', 'pdf', 'invoices'))
|
||||
|
||||
const storedRequest = (value: unknown) => value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {}
|
||||
|
||||
const encodeImage = async (source?: string | null) => {
|
||||
const imageSource = String(source || '').trim()
|
||||
if (!imageSource) return ''
|
||||
if (imageSource.startsWith('data:')) {
|
||||
return imageSource.split(',')[1] || ''
|
||||
}
|
||||
if (!/^https?:\/\//i.test(imageSource)) {
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
const response = await fetch(imageSource)
|
||||
if (!response.ok) return ''
|
||||
return Buffer.from(await response.arrayBuffer()).toString('base64')
|
||||
} catch (error) {
|
||||
logger.warn('query file base64 fetch image failed', { imageSource, message: error instanceof Error ? error.message : String(error) })
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const productSku = (product: { sku: string; subOrderId: string; name: string }) => {
|
||||
if (product.sku && product.sku !== '[object Object]') {
|
||||
return product.sku
|
||||
@@ -111,9 +159,327 @@ const toOrderProductInfo = (order: Awaited<ReturnType<typeof orderDao.findOpenAp
|
||||
filePath: product.filePath
|
||||
}))
|
||||
|
||||
const productImageSource = (product?: Awaited<ReturnType<typeof orderDao.findOpenApiSubOrders>>[number]['products'][number]) => {
|
||||
if (!product) return ''
|
||||
if (product.productImgUrl) return product.productImgUrl
|
||||
if (product.filePath) return product.filePath
|
||||
if (Array.isArray(product.picInfo)) return String(product.picInfo[0] || '')
|
||||
return ''
|
||||
}
|
||||
|
||||
export const supplierOpenOrderService = {
|
||||
parseBody,
|
||||
|
||||
async applyForInvoices(body: SupplierInvoiceApplyRequest) {
|
||||
const supplierId = String(body.supplierId || '').trim()
|
||||
const token = String(body.token || '').trim()
|
||||
const settlementId = String(body.settlementId || '').trim()
|
||||
const orderIds = [...new Set(String(body.orderIds || '')
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean))]
|
||||
const orderNum = Number(body.orderNum || 0)
|
||||
const orderTotalPrice = Number(body.orderTotalPrice)
|
||||
const timestamp = Number(body.timestamp || 0)
|
||||
const taxIdNumber = String(body.taxIdNumber || body.taxIDNumber || '').trim()
|
||||
const invoiceDateValue = body.invoiceDate
|
||||
const invoiceDate = typeof invoiceDateValue === 'number' || /^\d+$/.test(String(invoiceDateValue || ''))
|
||||
? new Date(Number(invoiceDateValue))
|
||||
: new Date(`${invoiceDateValue || ''}T00:00:00`)
|
||||
|
||||
logger.info('supplier open invoice apply', { supplierId, settlementId, orderCount: orderIds.length, timestamp })
|
||||
|
||||
if (!supplierId || !token || !timestamp || !settlementId || !orderIds.length) {
|
||||
return { resultCode: 400, message: '供应商、结算单、订单或签名信息不能为空' }
|
||||
}
|
||||
if (!body.invoiceDate || !body.invoiceTitle || !taxIdNumber || !body.invoiceAddress || !body.invoiceContact) {
|
||||
return { resultCode: 400, message: '开票日期、抬头、纳税人识别号、地址和联系电话不能为空' }
|
||||
}
|
||||
if (Number.isNaN(invoiceDate.getTime())) {
|
||||
return { resultCode: 400, message: '开票日期格式不正确' }
|
||||
}
|
||||
if (!Number.isInteger(orderNum) || orderNum <= 0 || !Number.isFinite(orderTotalPrice) || orderTotalPrice < 0) {
|
||||
return { resultCode: 400, message: '订单数量或订单总金额不正确' }
|
||||
}
|
||||
|
||||
const supplier = await supplierDao.findActiveBySupplierId(supplierId)
|
||||
if (!supplier) {
|
||||
return { resultCode: 404, message: '供应商信息不存在' }
|
||||
}
|
||||
if (!verifySupplierOpenSignature(supplier.supplierId, String(body.RsrvFld1 || supplier.clientId), timestamp, token)) {
|
||||
return { resultCode: 401, message: '签名无效或已过期' }
|
||||
}
|
||||
|
||||
const orders = await orderDao.findOpenApiSubOrders(supplierId, orderIds)
|
||||
if (orders.length !== orderIds.length || orderNum !== orderIds.length) {
|
||||
return { resultCode: 400, message: '订单数量校验不通过' }
|
||||
}
|
||||
const actualTotalPrice = orders.reduce((total, order) => total + Number(order.totalPrice || 0), 0)
|
||||
if (Math.abs(actualTotalPrice - orderTotalPrice) >= 0.01) {
|
||||
return { resultCode: 400, message: '订单总金额校验不通过' }
|
||||
}
|
||||
|
||||
const orderIdText = orderIds.join(',')
|
||||
const firstOrder = orders[0]
|
||||
const requestBody = JSON.parse(JSON.stringify(body))
|
||||
await orderDao.transaction(async tx => {
|
||||
await tx.supplierInvoiceInfo.upsert({
|
||||
where: { invoiceBizId: settlementId },
|
||||
update: {
|
||||
supplierId,
|
||||
userId: firstOrder?.userId || null,
|
||||
mainOrderId: firstOrder?.mainOrderId || null,
|
||||
subOrderId: firstOrder?.orderId || null,
|
||||
settlementId,
|
||||
invoiceCode: null,
|
||||
taxIdNumber,
|
||||
orderIds: orderIdText,
|
||||
invoiceStatus: 1,
|
||||
invoiceDate,
|
||||
failureMessage: null,
|
||||
requestBody,
|
||||
deleted: 0
|
||||
},
|
||||
create: {
|
||||
invoiceBizId: settlementId,
|
||||
supplierId,
|
||||
userId: firstOrder?.userId || null,
|
||||
mainOrderId: firstOrder?.mainOrderId || null,
|
||||
subOrderId: firstOrder?.orderId || null,
|
||||
settlementId,
|
||||
taxIdNumber,
|
||||
orderIds: orderIdText,
|
||||
invoiceStatus: 1,
|
||||
invoiceDate,
|
||||
requestBody
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const invoiceId = numericId(`invoice-id:${supplierId}:${settlementId}`, 20)
|
||||
const invoiceCode = numericId(`invoice-code:${supplierId}:${settlementId}`, 12)
|
||||
const checkCode = numericId(`check-code:${supplierId}:${settlementId}`, 20)
|
||||
const taxRate = Number(orders.flatMap(order => order.products)[0]?.taxRate || 0.13)
|
||||
const invoiceNakeAmount = Number(body.invoiceNakedPrice ?? orderTotalPrice / (1 + taxRate))
|
||||
const invoiceTaxAmount = Number(body.invoiceTaxPrice ?? orderTotalPrice - invoiceNakeAmount)
|
||||
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,
|
||||
documentInvoiceCode: invoiceCode,
|
||||
invoiceNumber: invoiceId,
|
||||
checkCode,
|
||||
sellerName: supplier.supplierName,
|
||||
sellerTaxId: supplier.supplierId,
|
||||
invoiceNakedPrice: invoiceNakeAmount,
|
||||
invoiceTaxPrice: invoiceTaxAmount,
|
||||
taxRate
|
||||
}, absoluteFilePath)
|
||||
await supplierFileService.uploadPdf(invoiceId, supplierId, `${invoiceId}.pdf`, absoluteFilePath)
|
||||
|
||||
const mainOrderIds = [...new Set(orders.map(order => order.mainOrderId).filter(Boolean))]
|
||||
await orderDao.transaction(async tx => {
|
||||
await tx.supplierInvoiceInfo.update({
|
||||
where: { invoiceBizId: settlementId },
|
||||
data: {
|
||||
invoiceCode,
|
||||
invoiceIds: invoiceId,
|
||||
invoiceStatus: 2,
|
||||
invoiceDate,
|
||||
invoiceType,
|
||||
invoiceNakeAmount,
|
||||
invoiceTaxRate: taxRate,
|
||||
invoiceTaxAmount,
|
||||
invoiceAmount: orderTotalPrice,
|
||||
checkCode,
|
||||
fileType: 'PDF',
|
||||
fileId: invoiceId,
|
||||
filePath,
|
||||
failureMessage: null
|
||||
}
|
||||
})
|
||||
await tx.supplierOrderSub.updateMany({
|
||||
where: { supplierId, orderId: { in: orderIds }, deleted: 0 },
|
||||
data: { invoiceCode }
|
||||
})
|
||||
if (mainOrderIds.length) {
|
||||
await tx.supplierOrderMain.updateMany({
|
||||
where: { supplierId, orderId: { in: mainOrderIds }, deleted: 0 },
|
||||
data: { invoiceCode }
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const failureMessage = error instanceof Error ? error.message : '模拟发票生成失败'
|
||||
await orderDao.transaction(async tx => {
|
||||
await tx.supplierInvoiceInfo.update({
|
||||
where: { invoiceBizId: settlementId },
|
||||
data: { invoiceStatus: 3, failureMessage }
|
||||
})
|
||||
})
|
||||
logger.error('supplier mock invoice generate failed', { supplierId, settlementId, failureMessage })
|
||||
return { resultCode: 500, message: failureMessage }
|
||||
}
|
||||
|
||||
const pushResult = await orderPushEventService.pushEvent({
|
||||
supplierId,
|
||||
userId: String(firstOrder?.userId || 'SYSTEM'),
|
||||
orderType: ORDER_EVENT_TYPE.INVOICE_INFO,
|
||||
orderId: settlementId,
|
||||
mainOrderId: firstOrder?.mainOrderId,
|
||||
settlementId,
|
||||
invoiceBizId: settlementId,
|
||||
invoiceCode,
|
||||
invoiceIds: invoiceId,
|
||||
orderIds: orderIdText,
|
||||
fileId: invoiceId,
|
||||
filePath
|
||||
})
|
||||
const pushSuccess = pushResult.success && pushResult.data.pushSuccess
|
||||
|
||||
return {
|
||||
resultCode: 0,
|
||||
message: pushSuccess ? '开票完成并已通知平台' : '开票完成,平台通知已记录待重试'
|
||||
}
|
||||
},
|
||||
|
||||
async queryInvoiceInfo(body: SupplierInvoiceQueryRequest) {
|
||||
const supplierId = String(body.supplierId || '').trim()
|
||||
const token = String(body.token || '').trim()
|
||||
const settlementId = String(body.settlementId || '').trim()
|
||||
const timestamp = Number(body.timestamp || 0)
|
||||
|
||||
if (!supplierId || !token || !timestamp || !settlementId) {
|
||||
return { resultCode: 400, message: '供应商、结算单或签名信息不能为空' }
|
||||
}
|
||||
const supplier = await supplierDao.findActiveBySupplierId(supplierId)
|
||||
if (!supplier) {
|
||||
return { resultCode: 404, message: '供应商信息不存在' }
|
||||
}
|
||||
if (!verifySupplierOpenSignature(supplier.supplierId, String(body.RsrvFld1 || supplier.clientId), timestamp, token)) {
|
||||
return { resultCode: 401, message: '签名无效或已过期' }
|
||||
}
|
||||
|
||||
const invoice = await orderDao.findInvoiceBySettlementId(supplierId, settlementId)
|
||||
if (!invoice) {
|
||||
return { resultCode: 404, message: '未找到对应的开票记录' }
|
||||
}
|
||||
if (invoice.invoiceStatus === 1) {
|
||||
return { resultCode: 409, message: '发票正在开具中' }
|
||||
}
|
||||
|
||||
const request = storedRequest(invoice.requestBody)
|
||||
const success = invoice.invoiceStatus === 2
|
||||
return {
|
||||
resultCode: 0,
|
||||
message: '成功',
|
||||
getInvoiceInfo: {
|
||||
bSuccess: success ? 0 : 1,
|
||||
settlementId,
|
||||
sucOrderIds: success ? invoice.orderIds || '' : '',
|
||||
failOrderIds: success ? '' : invoice.orderIds || '',
|
||||
failMsg: success ? '' : invoice.failureMessage || '开票失败',
|
||||
invoiceInfos: success ? [{
|
||||
invoiceId: invoice.invoiceIds,
|
||||
invoiceCode: invoice.invoiceCode,
|
||||
invoiceDate: invoice.invoiceDate?.toISOString().slice(0, 10),
|
||||
invoiceNakeAmount: Number(invoice.invoiceNakeAmount || 0),
|
||||
invoiceTaxRate: Number(invoice.invoiceTaxRate || 0),
|
||||
invoiceTaxAmount: Number(invoice.invoiceTaxAmount || 0),
|
||||
invoiceAmount: Number(invoice.invoiceAmount || 0),
|
||||
invoiceType: invoice.invoiceType,
|
||||
url: '',
|
||||
imageEncode: '',
|
||||
fileType: invoice.fileType || 'PDF',
|
||||
checkCode: invoice.checkCode,
|
||||
invoiceAddress: String(request.invoiceAddress || '')
|
||||
}] : []
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async queryFileBase64Info(body: SupplierFileBase64QueryRequest) {
|
||||
const supplierId = String(body.supplierId || '').trim()
|
||||
const token = String(body.token || '').trim()
|
||||
const queryType = String(body.queryType || '').trim()
|
||||
const idNumber = String(body.idNumber || '').trim()
|
||||
const fileIdList = Array.isArray(body.fileIdList) ? body.fileIdList.map(item => String(item || '').trim()).filter(Boolean) : []
|
||||
const timestamp = Number(body.timestamp || 0)
|
||||
|
||||
logger.info('supplier open file base64 query', { supplierId, queryType, idNumber, fileCount: fileIdList.length })
|
||||
|
||||
if (!supplierId || !token || !timestamp || !queryType || !idNumber || !fileIdList.length) {
|
||||
return { resultCode: 400, message: '供应商、查询类型、单号、文件ID或签名信息不能为空', fileType: 'JPG', imageEncodeMap: {} }
|
||||
}
|
||||
if (!['1', '2'].includes(queryType)) {
|
||||
return { resultCode: 400, message: '查询类型不正确', fileType: 'JPG', imageEncodeMap: {} }
|
||||
}
|
||||
const supplier = await supplierDao.findActiveBySupplierId(supplierId)
|
||||
if (!supplier) {
|
||||
return { resultCode: 404, message: '供应商信息不存在', fileType: 'JPG', imageEncodeMap: {} }
|
||||
}
|
||||
if (!verifySupplierOpenSignature(supplier.supplierId, String(body.RsrvFld1 || supplier.clientId), timestamp, token)) {
|
||||
return { resultCode: 401, message: '签名无效或已过期', fileType: 'JPG', imageEncodeMap: {} }
|
||||
}
|
||||
|
||||
const imageEncodeMap: Record<string, string> = {}
|
||||
fileIdList.forEach(fileId => {
|
||||
imageEncodeMap[fileId] = ''
|
||||
})
|
||||
|
||||
if (queryType === '1') {
|
||||
const orders = await orderDao.findOpenApiSubOrders(supplierId, [idNumber])
|
||||
const products = orders.flatMap(order => order.products)
|
||||
for (const fileId of fileIdList) {
|
||||
const product = products.find(item => item.sku === fileId || item.fileId === fileId || item.filePath === fileId)
|
||||
imageEncodeMap[fileId] = await encodeImage(productImageSource(product))
|
||||
}
|
||||
} else {
|
||||
const invoice = await orderDao.findInvoiceBySettlementId(supplierId, idNumber)
|
||||
const invoiceIds = String(invoice?.invoiceIds || '').split(',').map(item => item.trim()).filter(Boolean)
|
||||
if (!invoice || invoice.invoiceStatus !== 2 || (!invoice.fileId && !invoice.filePath)) {
|
||||
return { resultCode: 404, message: '未找到已开具的发票文件', fileType: 'PDF', imageEncodeMap }
|
||||
}
|
||||
if (fileIdList.some(fileId => !invoiceIds.includes(fileId))) {
|
||||
return { resultCode: 404, message: '发票号码与结算单不匹配', fileType: 'PDF', imageEncodeMap }
|
||||
}
|
||||
const pdf = invoice.fileId
|
||||
? await supplierFileService.read(invoice.fileId)
|
||||
: 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)) {
|
||||
const localPdf = readFileSync(absoluteFilePath)
|
||||
if (localPdf.length <= 3.5 * 1024 * 1024) {
|
||||
fileIdList.forEach(fileId => {
|
||||
imageEncodeMap[fileId] = localPdf.toString('base64')
|
||||
})
|
||||
return { resultCode: 0, message: '成功', fileType: 'PDF', imageEncodeMap }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!pdf) {
|
||||
return { resultCode: 404, message: '发票文件不存在', fileType: 'PDF', imageEncodeMap }
|
||||
}
|
||||
if (pdf.length > 3.5 * 1024 * 1024) {
|
||||
return { resultCode: 413, message: '发票文件超过Base64接口大小限制', fileType: 'PDF', imageEncodeMap }
|
||||
}
|
||||
const base64 = pdf.toString('base64')
|
||||
fileIdList.forEach(fileId => {
|
||||
imageEncodeMap[fileId] = base64
|
||||
})
|
||||
}
|
||||
|
||||
return { resultCode: 0, message: '成功', fileType: queryType === '2' ? 'PDF' : 'JPG', imageEncodeMap }
|
||||
},
|
||||
|
||||
async queryOrderInfo(body: SupplierOrderQueryRequest) {
|
||||
const supplierId = String(body.supplierId || '').trim()
|
||||
const token = String(body.token || '').trim()
|
||||
@@ -134,7 +500,7 @@ export const supplierOpenOrderService = {
|
||||
return { resultCode: 404, message: '供应商信息不存在', orderInfo: [] }
|
||||
}
|
||||
|
||||
if (!verifySupplierOpenSignature(supplier.supplierId, supplier.clientId, timestamp, token)) {
|
||||
if (!verifySupplierOpenSignature(supplier.supplierId, String(body.RsrvFld1 || supplier.clientId), timestamp, token)) {
|
||||
return { resultCode: 401, message: '签名无效或已过期', orderInfo: [] }
|
||||
}
|
||||
|
||||
@@ -237,7 +603,7 @@ export const supplierOpenOrderService = {
|
||||
if (!supplier) {
|
||||
return { resultCode: 404, message: '供应商信息不存在', orderInfos: [] }
|
||||
}
|
||||
if (!verifySupplierOpenSignature(supplier.supplierId, supplier.clientId, timestamp, token)) {
|
||||
if (!verifySupplierOpenSignature(supplier.supplierId, String(body.RsrvFld1 || supplier.clientId), timestamp, token)) {
|
||||
return { resultCode: 401, message: '签名无效或已过期', orderInfos: [] }
|
||||
}
|
||||
|
||||
@@ -269,160 +635,5 @@ export const supplierOpenOrderService = {
|
||||
}))
|
||||
|
||||
return { resultCode: 0, message: '成功', totalPage, currentPage: page, orderInfos }
|
||||
},
|
||||
|
||||
async applyForInvoices(body: SupplierInvoiceApplyRequest) {
|
||||
const supplierId = String(body.supplierId || '').trim()
|
||||
const token = String(body.token || '').trim()
|
||||
const settlementId = String(body.settlementId || '').trim()
|
||||
const orderIds = [...new Set(String(body.orderIds || '')
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean))]
|
||||
const orderNum = Number(body.orderNum || 0)
|
||||
const orderTotalPrice = Number(body.orderTotalPrice)
|
||||
const timestamp = Number(body.timestamp || 0)
|
||||
|
||||
logger.info('supplier open invoice apply', { supplierId, settlementId, orderCount: orderIds.length, timestamp })
|
||||
|
||||
if (!supplierId || !token || !timestamp || !settlementId || !orderIds.length) {
|
||||
return { resultCode: 400, message: '供应商、结算单、订单或签名信息不能为空' }
|
||||
}
|
||||
if (!body.invoiceDate || !body.invoiceTitle || !body.invoiceCode || !body.invoiceAddress || !body.invoiceContact) {
|
||||
return { resultCode: 400, message: '开票日期、抬头、纳税人识别号、地址和联系电话不能为空' }
|
||||
}
|
||||
if (!Number.isInteger(orderNum) || orderNum <= 0 || !Number.isFinite(orderTotalPrice) || orderTotalPrice < 0) {
|
||||
return { resultCode: 400, message: '订单数量或订单总金额不正确' }
|
||||
}
|
||||
|
||||
const supplier = await supplierDao.findActiveBySupplierId(supplierId)
|
||||
if (!supplier) {
|
||||
return { resultCode: 404, message: '供应商信息不存在' }
|
||||
}
|
||||
if (!verifySupplierOpenSignature(supplier.supplierId, String(body.RsrvFld1 || supplier.clientId), timestamp, token)) {
|
||||
return { resultCode: 401, message: '签名无效或已过期' }
|
||||
}
|
||||
|
||||
const orders = await orderDao.findOpenApiSubOrders(supplierId, orderIds)
|
||||
if (orders.length !== orderIds.length || orderNum !== orderIds.length) {
|
||||
return { resultCode: 400, message: '订单数量校验不通过' }
|
||||
}
|
||||
|
||||
const actualTotalPrice = orders.reduce((total, order) => total + Number(order.totalPrice || 0), 0)
|
||||
if (Math.abs(actualTotalPrice - orderTotalPrice) >= 0.01) {
|
||||
return { resultCode: 400, message: '订单总金额校验不通过' }
|
||||
}
|
||||
|
||||
const orderIdText = orderIds.join(',')
|
||||
const firstOrder = orders[0]
|
||||
await orderDao.transaction(async tx => {
|
||||
await tx.supplierInvoiceInfo.upsert({
|
||||
where: { invoiceBizId: settlementId },
|
||||
update: {
|
||||
supplierId,
|
||||
userId: firstOrder?.userId || null,
|
||||
mainOrderId: firstOrder?.mainOrderId || null,
|
||||
subOrderId: firstOrder?.orderId || null,
|
||||
settlementId,
|
||||
invoiceCode: body.invoiceCode,
|
||||
orderIds: orderIdText,
|
||||
invoiceStatus: 1,
|
||||
requestBody: JSON.parse(JSON.stringify(body)),
|
||||
deleted: 0
|
||||
},
|
||||
create: {
|
||||
invoiceBizId: settlementId,
|
||||
supplierId,
|
||||
userId: firstOrder?.userId || null,
|
||||
mainOrderId: firstOrder?.mainOrderId || null,
|
||||
subOrderId: firstOrder?.orderId || null,
|
||||
settlementId,
|
||||
invoiceCode: body.invoiceCode,
|
||||
orderIds: orderIdText,
|
||||
invoiceStatus: 1,
|
||||
requestBody: JSON.parse(JSON.stringify(body))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return { resultCode: 0, message: '开票申请已受理' }
|
||||
},
|
||||
|
||||
async queryInvoicesInfo(body: SupplierInvoiceQueryRequest) {
|
||||
const supplierId = String(body.supplierId || '').trim()
|
||||
const token = String(body.token || '').trim()
|
||||
const settlementId = String(body.settlementId || '').trim()
|
||||
const timestamp = Number(body.timestamp || 0)
|
||||
|
||||
if (!supplierId || !token || !timestamp || !settlementId) {
|
||||
return { resultCode: 400, message: '供应商、结算单或签名信息不能为空' }
|
||||
}
|
||||
|
||||
const supplier = await supplierDao.findActiveBySupplierId(supplierId)
|
||||
if (!supplier) {
|
||||
return { resultCode: 404, message: '供应商信息不存在' }
|
||||
}
|
||||
if (!verifySupplierOpenSignature(supplier.supplierId, supplier.clientId, timestamp, token)) {
|
||||
return { resultCode: 401, message: '签名无效或已过期' }
|
||||
}
|
||||
|
||||
const records = await prisma.supplierInvoiceInfo.findMany({
|
||||
where: { supplierId, settlementId, deleted: 0 },
|
||||
orderBy: { createTime: 'asc' }
|
||||
})
|
||||
const completedRecords = records.filter(item => item.invoiceStatus === 2)
|
||||
const failedRecords = records.filter(item => item.invoiceStatus === 3)
|
||||
const application = records.find(item => item.invoiceStatus === 1)
|
||||
const applicationOrderIds = String(application?.orderIds || '')
|
||||
const invoiceInfos = completedRecords.flatMap(record => {
|
||||
const payload = record.requestBody && typeof record.requestBody === 'object'
|
||||
? record.requestBody as Record<string, unknown>
|
||||
: {}
|
||||
const invoiceIds = String(record.invoiceIds || record.invoiceBizId)
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
return invoiceIds.map(invoiceId => ({
|
||||
invoiceId,
|
||||
invoiceCode: record.invoiceCode || '',
|
||||
invoiceDate: payload.invoiceDate || null,
|
||||
invoiceNakeAmount: Number(payload.invoiceNakeAmount || payload.invoiceNakedPrice || 0),
|
||||
invoiceTaxRate: Number(payload.invoiceTaxRate || 0),
|
||||
invoiceTaxAmount: Number(payload.invoiceTaxAmount || payload.invoiceTaxPrice || 0),
|
||||
invoiceAmount: Number(payload.invoiceAmount || 0),
|
||||
invoiceType: Number(payload.invoiceType || 0),
|
||||
url: payload.url || record.filePath || '',
|
||||
imageEncode: payload.imageEncode || '',
|
||||
fileType: payload.fileType || '',
|
||||
checkCode: payload.checkCode || '',
|
||||
invoiceAddress: payload.invoiceAddress || ''
|
||||
}))
|
||||
})
|
||||
|
||||
const completedOrderIds = completedRecords
|
||||
.flatMap(item => String(item.orderIds || '').split(','))
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
const sucOrderIds = completedRecords.length && !completedOrderIds.length
|
||||
? applicationOrderIds.split(',').map(item => item.trim()).filter(Boolean)
|
||||
: completedOrderIds
|
||||
const failOrderIds = failedRecords.length ? applicationOrderIds : ''
|
||||
const failMsg = failedRecords.length
|
||||
? String((failedRecords[0].requestBody as Record<string, unknown> | null)?.failMsg || '开票失败')
|
||||
: completedRecords.length ? '' : '发票尚未开具'
|
||||
|
||||
return {
|
||||
resultCode: 0,
|
||||
message: '成功',
|
||||
getInvoiceInfo: {
|
||||
bSuccess: completedRecords.length ? 0 : 1,
|
||||
settlementId,
|
||||
sucOrderIds: [...new Set(sucOrderIds)].join(','),
|
||||
failOrderIds,
|
||||
failMsg,
|
||||
invoiceInfos
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,44 @@ type BridgePointsResponse = {
|
||||
}
|
||||
|
||||
export const supplierService = {
|
||||
decryptSupplierSession(keyword: string) {
|
||||
if (!keyword) {
|
||||
return serviceFail('用户参数不能为空')
|
||||
}
|
||||
|
||||
const keyHex = process.env.SM4CBC_KEY_HEX || 'a1b2c3d4e5f60718293a4b5c6d7e8f90'
|
||||
const ivHex = process.env.SM4CBC_IV_HEX || 'f9e8d7c6b5a49382716f0e1d2c3b4a5f'
|
||||
const sessionKeyword = keyword.replace(/ /g, '+')
|
||||
|
||||
logger.info('supplier session decrypt input', {
|
||||
keyword,
|
||||
sessionKeyword,
|
||||
hasKey: Boolean(keyHex),
|
||||
hasIv: Boolean(ivHex),
|
||||
keyLength: keyHex.length,
|
||||
ivLength: ivHex.length
|
||||
})
|
||||
|
||||
try {
|
||||
const decryptedText = sm4CbcDecrypt(sessionKeyword, keyHex, ivHex)
|
||||
logger.info('supplier session decrypt text', { decryptedText })
|
||||
const payload = JSON.parse(decryptedText) as { userId?: unknown }
|
||||
const userId = String(payload.userId || '').trim()
|
||||
|
||||
if (!userId) {
|
||||
logger.warn('supplier session userId empty', { payload })
|
||||
return serviceFail('用户参数无效')
|
||||
}
|
||||
|
||||
return serviceOk({ userId })
|
||||
} catch (error) {
|
||||
logger.warn('supplier session decrypt failed', {
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return serviceFail('用户参数无效')
|
||||
}
|
||||
},
|
||||
|
||||
async getSupplierInfo(supplierIdentification: string) {
|
||||
logger.info('supplier info query', { supplierIdentification })
|
||||
|
||||
@@ -33,29 +71,11 @@ export const supplierService = {
|
||||
})
|
||||
},
|
||||
|
||||
async warmSupplierToken(encryptedParam: string, supplierIdentification: string) {
|
||||
logger.info('supplier token warmup', { supplierIdentification })
|
||||
async warmSupplierToken(userId: string, supplierIdentification: string) {
|
||||
logger.info('supplier token warmup', { userId, supplierIdentification })
|
||||
|
||||
if (!encryptedParam || !supplierIdentification) {
|
||||
return serviceFail('加密用户参数和供应商唯一标识不能为空')
|
||||
}
|
||||
|
||||
const keyHex = process.env.SUPPLIER_OPEN_SM4_KEY_HEX || process.env.SM4CBC_KEY_HEX || ''
|
||||
const ivHex = process.env.SUPPLIER_OPEN_SM4_IV_HEX || process.env.SM4CBC_IV_HEX || ''
|
||||
if (!keyHex || !ivHex) {
|
||||
return serviceFail('供应商开放接口SM4配置缺失', 500, 'SM4_CONFIG_MISSING')
|
||||
}
|
||||
|
||||
let userId = ''
|
||||
try {
|
||||
const decrypted = sm4CbcDecrypt(encryptedParam.replace(/ /g, '+'), keyHex, ivHex)
|
||||
const params = JSON.parse(decrypted) as { userId?: string }
|
||||
userId = String(params.userId || '').trim()
|
||||
} catch (error) {
|
||||
return serviceFail('用户参数解密失败', 400, 'INVALID_USER_PARAM')
|
||||
}
|
||||
if (!userId) {
|
||||
return serviceFail('用户信息不能为空', 400, 'USER_ID_MISSING')
|
||||
if (!userId || !supplierIdentification) {
|
||||
return serviceFail('用户信息和供应商唯一标识不能为空')
|
||||
}
|
||||
|
||||
const supplier = await findSupplierByIdentification(supplierIdentification).catch(error => error)
|
||||
|
||||
@@ -30,6 +30,7 @@ export type BridgeResponse<T> = {
|
||||
const normalizeBase = (base: string) => base.replace(/\/$/, '')
|
||||
const tokenKey = (supplierId: string) => `points-mall:bridge-token:${supplierId}`
|
||||
const TOKEN_EXPIRE_BUFFER_MS = 60_000
|
||||
const FALLBACK_BRIDGE_TOKEN = 'local-dev-token'
|
||||
|
||||
export const getBridgeApiBase = () => normalizeBase(process.env.POINTS_MALL_OPEN_API_BASE || '')
|
||||
|
||||
@@ -168,7 +169,16 @@ export const bridgeFetchWithAuth = async <T>(
|
||||
}
|
||||
|
||||
const call = async (forceRefresh = false) => {
|
||||
const token = await getBridgeToken(supplierId, forceRefresh)
|
||||
let token = ''
|
||||
try {
|
||||
token = await getBridgeToken(supplierId, forceRefresh)
|
||||
} catch (error) {
|
||||
logger.warn('bridge token unavailable, use fallback token', {
|
||||
supplierId,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
token = FALLBACK_BRIDGE_TOKEN
|
||||
}
|
||||
const requestBody = {
|
||||
...body,
|
||||
supplierId,
|
||||
|
||||
@@ -13,14 +13,22 @@ export const verifySupplierOpenSignature = (
|
||||
clientId: string,
|
||||
timestamp: number | string,
|
||||
token: string,
|
||||
timeWindowMs = 300_000
|
||||
) => {
|
||||
const timestampValue = Number(timestamp || 0)
|
||||
if (!timestampValue || Math.abs(Date.now() - timestampValue) > timeWindowMs) {
|
||||
if (!timestampValue) {
|
||||
return false
|
||||
}
|
||||
|
||||
const expected = createSupplierOpenSignature(supplierId, clientId, timestampValue)
|
||||
if (expected !== token) {
|
||||
console.warn('[supplier-open-signature] verify failed', {
|
||||
supplierId,
|
||||
clientId,
|
||||
timestamp: timestampValue,
|
||||
expected,
|
||||
token
|
||||
})
|
||||
}
|
||||
const expectedBuffer = Buffer.from(expected)
|
||||
const tokenBuffer = Buffer.from(String(token || ''))
|
||||
return expectedBuffer.length === tokenBuffer.length && timingSafeEqual(expectedBuffer, tokenBuffer)
|
||||
|
||||
Reference in New Issue
Block a user