feat: 积分商城-供应商-对账-发票4
This commit is contained in:
@@ -40,7 +40,19 @@ export const uploadController = withErrorBoundary(async (request: Request, _cont
|
||||
return toHttpResponse(await minioUploadService.upload(await readJson<UploadInput>(request)))
|
||||
})
|
||||
|
||||
export const fileController = withErrorBoundary(async (request: Request, _context: Context) => {
|
||||
const methodResponse = guardMethod(request, ['GET'])
|
||||
if (methodResponse) return methodResponse
|
||||
const result = await minioUploadService.imageUrl(new URL(request.url).searchParams.get('fid')?.trim() || '')
|
||||
if (!result.success) return toHttpResponse(result)
|
||||
return new Response(null, { status: 302, headers: { Location: result.data.url, 'Cache-Control': 'no-store' } })
|
||||
})
|
||||
|
||||
export const uploadPreviewController = withErrorBoundary(async (request: Request, _context: Context) => {
|
||||
if (request.method === 'GET') {
|
||||
const fid = new URL(request.url).searchParams.get('fid') || ''
|
||||
return new Response(null, { status: 302, headers: { Location: `/api/file?fid=${encodeURIComponent(fid)}`, 'Cache-Control': 'no-store' } })
|
||||
}
|
||||
const methodResponse = guardMethod(request, ['POST'])
|
||||
if (methodResponse) return methodResponse
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { withErrorBoundary } from '../utils/handler.js'
|
||||
import { guardMethod } from '../utils/method.js'
|
||||
import { getBearerToken, readJson } from '../utils/request.js'
|
||||
import { adminAuthService } from '../services/admin-auth.service.js'
|
||||
import { serviceFail } from '../types/service-result.js'
|
||||
import { merchantInvoiceService, type MerchantInvoiceListInput, type MerchantInvoiceFileInput } from '../services/merchant-invoice.service.js'
|
||||
import { toHttpResponse } from './controller-response.js'
|
||||
|
||||
export const merchantInvoiceListController = withErrorBoundary(async request => {
|
||||
const methodResponse = guardMethod(request, ['POST'])
|
||||
if (methodResponse) return methodResponse
|
||||
if (!await adminAuthService.findSession(getBearerToken(request))) return toHttpResponse(serviceFail('登录已失效,请重新登录', 401, 'UNAUTHORIZED'))
|
||||
return toHttpResponse(await merchantInvoiceService.list(await readJson<MerchantInvoiceListInput>(request)))
|
||||
})
|
||||
|
||||
export const merchantInvoiceFileController = withErrorBoundary(async request => {
|
||||
const methodResponse = guardMethod(request, ['POST'])
|
||||
if (methodResponse) return methodResponse
|
||||
if (!await adminAuthService.findSession(getBearerToken(request))) return toHttpResponse(serviceFail('登录已失效,请重新登录', 401, 'UNAUTHORIZED'))
|
||||
return toHttpResponse(await merchantInvoiceService.file(await readJson<MerchantInvoiceFileInput>(request)))
|
||||
})
|
||||
@@ -31,8 +31,8 @@ export const merchantProductListController = withErrorBoundary(async (request: R
|
||||
const methodResponse = guardMethod(request, ['POST'])
|
||||
if (methodResponse) return methodResponse
|
||||
|
||||
const body = await readJson<{ keyword?: string; categoryId?: string }>(request)
|
||||
return toHttpResponse(await productService.listManageProducts(String(body.keyword || '').trim(), String(body.categoryId || '').trim()))
|
||||
const body = await readJson<{ keyword?: string; categoryId?: string; pageNumber?: number; pageSize?: number }>(request)
|
||||
return toHttpResponse(await productService.listManageProducts(String(body.keyword || '').trim(), String(body.categoryId || '').trim(), body.pageNumber, body.pageSize))
|
||||
})
|
||||
|
||||
export const merchantProductSaveController = withErrorBoundary(async (request: Request, _context: Context) => {
|
||||
|
||||
@@ -171,13 +171,14 @@ export const orderDao = {
|
||||
})
|
||||
},
|
||||
|
||||
async findMerchantSubOrders(supplierId: string, orderState?: number, orderId?: string, userId?: string, excludedOrderIds: string[] = [], pagination?: { pageNumber: number; pageSize: number }) {
|
||||
async findMerchantSubOrders(supplierId: string, orderState?: number, orderId?: string, userId?: string, excludedOrderIds: string[] = [], pagination?: { pageNumber: number; pageSize: number }, matchingUserIds?: string[]) {
|
||||
const where: Prisma.SupplierOrderSubWhereInput = {
|
||||
...(supplierId ? { supplierId } : {}),
|
||||
deleted: 0,
|
||||
...(Number.isInteger(orderState) ? { orderState } : {}),
|
||||
...(orderId ? { OR: [{ orderId: { contains: orderId } }, { orderNumberId: { contains: orderId } }] } : {}),
|
||||
...(userId ? { userId: { contains: userId } } : {}),
|
||||
...(matchingUserIds !== undefined ? { AND: [{ userId: { in: matchingUserIds } }] } : {}),
|
||||
...(excludedOrderIds.length ? { orderId: { notIn: excludedOrderIds } } : {})
|
||||
}
|
||||
const [orders, totalCount] = await prisma.$transaction([
|
||||
@@ -201,12 +202,13 @@ export const orderDao = {
|
||||
return { orders, totalCount }
|
||||
},
|
||||
|
||||
findMerchantAfterSales(supplierId: string, serviceStates?: number[]) {
|
||||
findMerchantAfterSales(supplierId: string, serviceStates?: number[], serviceOrderId = '') {
|
||||
return prisma.supplierAfterSale.findMany({
|
||||
where: {
|
||||
...(supplierId ? { supplierId } : {}),
|
||||
deleted: 0,
|
||||
...(serviceStates?.length ? { serviceState: { in: serviceStates } } : {})
|
||||
...(serviceStates?.length ? { serviceState: { in: serviceStates } } : {}),
|
||||
...(serviceOrderId ? { serviceOrderId: { contains: serviceOrderId } } : {})
|
||||
},
|
||||
orderBy: [
|
||||
{ createTime: 'desc' },
|
||||
|
||||
@@ -35,18 +35,22 @@ export const productDao = {
|
||||
})
|
||||
},
|
||||
|
||||
findManageProducts(keyword: string, categoryId: string) {
|
||||
return prisma.supplierProduct.findMany({
|
||||
where: {
|
||||
deleted: 0,
|
||||
...(keyword ? { OR: [{ name: { contains: keyword } }, { sku: { contains: keyword } }] } : {}),
|
||||
...(categoryId ? { categoryId } : {})
|
||||
},
|
||||
orderBy: [
|
||||
{ sortOrder: 'asc' },
|
||||
{ createdAt: 'desc' }
|
||||
]
|
||||
})
|
||||
async findManageProducts(keyword: string, categoryId: string, pageNumber = 1, pageSize = 10) {
|
||||
const where = {
|
||||
deleted: 0,
|
||||
...(keyword ? { OR: [{ name: { contains: keyword } }, { sku: { contains: keyword } }] } : {}),
|
||||
...(categoryId ? { categoryId } : {})
|
||||
}
|
||||
const [products, totalCount] = await prisma.$transaction([
|
||||
prisma.supplierProduct.findMany({
|
||||
where,
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (pageNumber - 1) * pageSize,
|
||||
take: pageSize
|
||||
}),
|
||||
prisma.supplierProduct.count({ where })
|
||||
])
|
||||
return { products, totalCount }
|
||||
},
|
||||
|
||||
createProduct(data: Parameters<typeof prisma.supplierProduct.create>[0]['data']) {
|
||||
|
||||
@@ -31,6 +31,11 @@ export const suppliedUserDao = {
|
||||
if (!user) throw new Error('获取供应商用户信息失败')
|
||||
return user
|
||||
},
|
||||
async findVirtualIdsByNickname(nickname: string) {
|
||||
return prisma.$queryRaw<Array<{ virtualId: string }>>`
|
||||
SELECT virtualId FROM supplied_user
|
||||
WHERE virtualId IS NOT NULL AND LOCATE(${nickname}, nickname) > 0`
|
||||
},
|
||||
async findByVirtualIds(virtualIds: string[]) {
|
||||
if (!virtualIds.length) return []
|
||||
return prisma.$queryRaw<SuppliedCustomer[]>`
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { fileController as default } from '../controllers/basic.controller.js'
|
||||
@@ -0,0 +1 @@
|
||||
export { merchantInvoiceFileController as default } from '../controllers/merchant-invoice.controller.js'
|
||||
@@ -0,0 +1 @@
|
||||
export { merchantInvoiceListController as default } from '../controllers/merchant-invoice.controller.js'
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
loginController,
|
||||
uploadController,
|
||||
uploadPreviewController,
|
||||
fileController,
|
||||
userInfoController
|
||||
} from './controllers/basic.controller.js'
|
||||
import {
|
||||
@@ -78,7 +79,11 @@ const app = new Koa()
|
||||
const router = new Router()
|
||||
const port = Number(process.env.PORT || 8888)
|
||||
|
||||
import { merchantInvoiceListController, merchantInvoiceFileController } from './controllers/merchant-invoice.controller.js'
|
||||
|
||||
const handlers: Record<string, FunctionHandler> = {
|
||||
'merchant-invoice-list': merchantInvoiceListController,
|
||||
'merchant-invoice-file': merchantInvoiceFileController,
|
||||
'address-default': addressDefaultController,
|
||||
'address-delete': addressDeleteController,
|
||||
'address-list': addressListController,
|
||||
@@ -132,6 +137,7 @@ const handlers: Record<string, FunctionHandler> = {
|
||||
'supplier-token': supplierTokenController,
|
||||
upload: uploadController,
|
||||
'upload-preview': uploadPreviewController,
|
||||
file: fileController,
|
||||
'user-info': userInfoController
|
||||
}
|
||||
|
||||
@@ -176,6 +182,11 @@ const writeResponse = async (ctx: Koa.Context, response: Response, wrapBa7 = fal
|
||||
ctx.status = response.status
|
||||
Object.entries(corsHeaders).forEach(([key, value]) => ctx.set(key, value))
|
||||
response.headers.forEach((value, key) => ctx.set(key, value))
|
||||
if (response.status >= 300 && response.status < 400 && response.headers.has('Location')) {
|
||||
ctx.body = ''
|
||||
ctx.status = response.status
|
||||
return
|
||||
}
|
||||
const responseText = response.body ? await response.text() : ''
|
||||
ctx.body = wrapBa7 ? ba7ResponseBody(responseText) : responseText || undefined
|
||||
}
|
||||
@@ -199,6 +210,14 @@ const dispatch = (name: string) => async (ctx: Koa.Context) => {
|
||||
ctx.body = { success: false, error: { code: 'NOT_FOUND', message: '接口不存在' } }
|
||||
return
|
||||
}
|
||||
if (name.startsWith('merchant-')) {
|
||||
const token = ctx.get('Authorization').match(/^Bearer\s+(.+)$/i)?.[1] || ''
|
||||
if (!await adminAuthService.findSession(token)) {
|
||||
ctx.status = 401
|
||||
ctx.body = { success: false, error: { code: 'UNAUTHORIZED', message: '登录已失效,请重新登录' } }
|
||||
return
|
||||
}
|
||||
}
|
||||
const response = await handler(toRequest(ctx), {})
|
||||
await writeResponse(ctx, response, ctx.path.startsWith('/supplierOpenApi/'))
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
AFTER_SALE_LOGISTICS_STATE,
|
||||
AFTER_SALE_SERVICE_STATE,
|
||||
@@ -132,8 +133,8 @@ const toDateTimeText = (date?: Date | null) => {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
const createServiceOrderId = (subOrderId: string, serviceType: number) => {
|
||||
return `AS${serviceType}_${subOrderId}_${Date.now()}`
|
||||
const createServiceOrderId = (serviceType: number) => {
|
||||
return `AS${serviceType}_${randomBytes(8).toString('hex')}`
|
||||
}
|
||||
|
||||
const isOrderSigned = (orderState?: number | null) => signedOrderStates.has(Number(orderState))
|
||||
@@ -323,7 +324,7 @@ export const afterSaleService = {
|
||||
|
||||
const now = new Date()
|
||||
const amount = Number(input.amount || subOrder.totalPrice || 0)
|
||||
const serviceOrderId = createServiceOrderId(subOrderId, serviceType)
|
||||
const serviceOrderId = createServiceOrderId(serviceType)
|
||||
const { isSigned: _isSigned, ...requestBody } = input
|
||||
|
||||
await orderDao.transaction(async tx => {
|
||||
@@ -490,7 +491,7 @@ export const afterSaleService = {
|
||||
const subOrders = record.subOrderId
|
||||
? await orderDao.findOpenApiSubOrders(supplierId, [record.subOrderId])
|
||||
: []
|
||||
const isSigned = isOrderSigned(subOrders[0]?.orderState)
|
||||
const isSigned = Boolean(record.pickupMethod || record.pickupName || record.pickupMobile || record.pickupAddress) || isOrderSigned(subOrders[0]?.orderState)
|
||||
const approved = auditStatus === 1
|
||||
const isExchange = record.serviceType === ORDER_EVENT_TYPE.EXCHANGE_ORDER
|
||||
const finishUnsignedReturn = approved && !isSigned && record.serviceType === ORDER_EVENT_TYPE.RETURN_ORDER
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { prisma } from '../utils/prisma.js'
|
||||
import { serviceFail, serviceOk } from '../types/service-result.js'
|
||||
import { supplierFileService } from './supplier-file.service.js'
|
||||
|
||||
export type MerchantInvoiceListInput = { settlementId?: string; invoiceStatus?: number; pageNumber?: number; pageSize?: number }
|
||||
export type MerchantInvoiceFileInput = { id?: number; download?: boolean }
|
||||
|
||||
export const merchantInvoiceService = {
|
||||
async list(input: MerchantInvoiceListInput) {
|
||||
const settlementId = String(input.settlementId || '').trim()
|
||||
const invoiceStatus = input.invoiceStatus === undefined || input.invoiceStatus === null ? undefined : Number(input.invoiceStatus)
|
||||
if (invoiceStatus !== undefined && ![0, 1, 2, 3].includes(invoiceStatus)) return serviceFail('开票状态不正确')
|
||||
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 ? Math.min(Number(input.pageSize), 100) : 10
|
||||
const where = { deleted: 0, ...(settlementId ? { settlementId: { contains: settlementId } } : {}), ...(invoiceStatus === undefined ? {} : { invoiceStatus }) }
|
||||
const [records, totalCount] = await prisma.$transaction([
|
||||
prisma.supplierInvoiceInfo.findMany({ where, orderBy: [{ createTime: 'desc' }, { id: 'desc' }], skip: (pageNumber - 1) * pageSize, take: pageSize }),
|
||||
prisma.supplierInvoiceInfo.count({ where })
|
||||
])
|
||||
const invoiceItems = records.map(record => ({
|
||||
id: record.id, settlementId: record.settlementId || record.invoiceBizId, invoiceIds: record.invoiceIds || '',
|
||||
invoiceCode: record.invoiceCode || '', invoiceStatus: record.invoiceStatus, invoiceDate: record.invoiceDate,
|
||||
invoiceType: record.invoiceType, invoiceAmount: record.invoiceAmount?.toFixed(2) || '0.00',
|
||||
invoiceNakeAmount: record.invoiceNakeAmount?.toFixed(2) || '0.00', invoiceTaxAmount: record.invoiceTaxAmount?.toFixed(2) || '0.00',
|
||||
invoiceTaxRate: record.invoiceTaxRate?.toString() || '', taxIdNumber: record.taxIdNumber || '', checkCode: record.checkCode || '',
|
||||
orderIds: record.orderIds || '', failureMessage: record.failureMessage || '', createTime: record.createTime,
|
||||
hasFile: record.invoiceStatus === 2 && Boolean(record.fileId)
|
||||
}))
|
||||
return serviceOk({ invoiceItems, totalCount, pageNumber, pageSize })
|
||||
},
|
||||
async file(input: MerchantInvoiceFileInput) {
|
||||
const id = Number(input.id)
|
||||
if (!Number.isInteger(id) || id <= 0) return serviceFail('发票参数不正确')
|
||||
const invoice = await prisma.supplierInvoiceInfo.findFirst({ where: { id, deleted: 0, invoiceStatus: 2 } })
|
||||
if (!invoice?.fileId) return serviceFail('发票文件不存在', 404, 'INVOICE_FILE_NOT_FOUND')
|
||||
const url = await supplierFileService.pdfUrl(invoice.fileId, invoice.supplierId, input.download === true)
|
||||
if (!url) return serviceFail('发票文件不存在', 404, 'INVOICE_FILE_NOT_FOUND')
|
||||
return serviceOk({ url })
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export type MerchantOrderListInput = {
|
||||
orderState?: number
|
||||
pageNumber?: number
|
||||
pageSize?: number
|
||||
userNickname?: string
|
||||
orderId?: string
|
||||
userId?: string
|
||||
}
|
||||
@@ -34,6 +35,7 @@ export type MerchantShipInput = {
|
||||
export type MerchantAfterSaleListInput = {
|
||||
supplierId?: string
|
||||
scene?: string
|
||||
serviceOrderId?: string
|
||||
}
|
||||
|
||||
const serviceStateText = (serviceState: number) => {
|
||||
@@ -76,7 +78,7 @@ const firstImage = (picInfo: unknown) => {
|
||||
}
|
||||
|
||||
const afterSaleStatesByScene = (scene: string) => {
|
||||
if (scene === 'audit') return [AFTER_SALE_SERVICE_STATE.PENDING_AUDIT]
|
||||
if (scene === 'audit') return [AFTER_SALE_SERVICE_STATE.PENDING_AUDIT, AFTER_SALE_SERVICE_STATE.APPROVED, AFTER_SALE_SERVICE_STATE.USER_SHIPPED, AFTER_SALE_SERVICE_STATE.RECEIVED]
|
||||
if (scene === 'process') {
|
||||
return [
|
||||
AFTER_SALE_SERVICE_STATE.APPROVED,
|
||||
@@ -123,7 +125,9 @@ export const merchantService = {
|
||||
const pagination = input.pageNumber !== undefined || input.pageSize !== undefined ? { pageNumber, pageSize } : undefined
|
||||
const afterSales = await orderDao.findMerchantAfterSales(supplierId, pendingShipExcludeAfterSaleStates)
|
||||
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 userNickname = String(input.userNickname || '').trim()
|
||||
const matchingUserIds = userNickname ? (await suppliedUserDao.findVirtualIdsByNickname(userNickname)).map(user => user.virtualId) : undefined
|
||||
const { orders, totalCount } = await orderDao.findMerchantSubOrders(supplierId, orderState, orderId, userId, afterSaleSubOrderIds, pagination, matchingUserIds)
|
||||
const virtualIds = [...new Set(orders.map(order => order.userId).filter((id): id is string => Boolean(id)))]
|
||||
const users = await suppliedUserDao.findByVirtualIds(virtualIds)
|
||||
const nicknames = new Map(users.map(user => [user.virtualId, user.nickname]))
|
||||
@@ -224,13 +228,12 @@ export const merchantService = {
|
||||
const scene = String(input.scene || '').trim()
|
||||
logger.info('merchant after sale list query', { supplierId, scene })
|
||||
|
||||
if (!supplierId) {
|
||||
return serviceFail('供应商不能为空')
|
||||
}
|
||||
|
||||
const records = await orderDao.findMerchantAfterSales(supplierId, afterSaleStatesByScene(scene))
|
||||
const subOrderIds = records.map(item => item.subOrderId).filter(Boolean) as string[]
|
||||
const subOrders = subOrderIds.length ? await orderDao.findOpenApiSubOrders(supplierId, subOrderIds) : []
|
||||
const records = await orderDao.findMerchantAfterSales(supplierId, afterSaleStatesByScene(scene), String(input.serviceOrderId || '').trim())
|
||||
const supplierIds = [...new Set(records.map(item => item.supplierId))]
|
||||
const subOrders = (await Promise.all(supplierIds.map(recordSupplierId => {
|
||||
const subOrderIds = records.filter(item => item.supplierId === recordSupplierId).map(item => item.subOrderId).filter(Boolean) as string[]
|
||||
return subOrderIds.length ? orderDao.findOpenApiSubOrders(recordSupplierId, subOrderIds) : []
|
||||
}))).flat()
|
||||
const subOrderMap = new Map(subOrders.map(item => [item.orderId, item]))
|
||||
const afterSaleItems = records.map(record => {
|
||||
const subOrder = record.subOrderId ? subOrderMap.get(record.subOrderId) : null
|
||||
@@ -246,7 +249,7 @@ export const merchantService = {
|
||||
serviceState: record.serviceState,
|
||||
serviceStateText: serviceStateText(record.serviceState),
|
||||
logisticsState: record.logisticsState,
|
||||
isSigned: isOrderSigned(subOrder?.orderState),
|
||||
isSigned: Boolean(record.pickupMethod || record.pickupName || record.pickupMobile || record.pickupAddress) || isOrderSigned(subOrder?.orderState),
|
||||
reason: record.reason || '',
|
||||
problemDescription: record.problemDescription || '',
|
||||
pickupMethod: record.pickupMethod || '',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { prisma } from '../utils/prisma.js'
|
||||
import { serviceFail, serviceOk } from '../types/service-result.js'
|
||||
|
||||
export type UploadInput = {
|
||||
@@ -36,9 +37,9 @@ const signingKey = (date: string) => {
|
||||
return hmac(serviceKey, 'aws4_request')
|
||||
}
|
||||
|
||||
const objectUrl = (objectName: string) => {
|
||||
const objectUrl = (objectName: string, bucket = minioConfig.bucket) => {
|
||||
const endpoint = new URL(minioConfig.endPoint)
|
||||
endpoint.pathname = `${endpoint.pathname.replace(/\/$/, '')}/${minioConfig.bucket}/${encodePath(objectName)}`
|
||||
endpoint.pathname = `${endpoint.pathname.replace(/\/$/, '')}/${bucket}/${encodePath(objectName)}`
|
||||
return endpoint
|
||||
}
|
||||
|
||||
@@ -49,12 +50,12 @@ const extName = (fileName: string) => {
|
||||
|
||||
const safeFolder = (folder?: string) => String(folder || 'after-sale').replace(/[^a-zA-Z0-9/_-]/g, '').replace(/^\/+|\/+$/g, '') || 'after-sale'
|
||||
|
||||
const presign = (method: 'GET', objectName: string, expires = 3600) => {
|
||||
const presign = (method: 'GET', objectName: string, expires = 3600, bucket = minioConfig.bucket) => {
|
||||
const now = new Date()
|
||||
const date = shortDate(now)
|
||||
const fullDate = amzDate(now)
|
||||
const credentialScope = `${date}/${minioConfig.region}/s3/aws4_request`
|
||||
const endpoint = objectUrl(objectName)
|
||||
const endpoint = objectUrl(objectName, bucket)
|
||||
const params = new URLSearchParams({
|
||||
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
|
||||
'X-Amz-Credential': `${minioConfig.accessKey}/${credentialScope}`,
|
||||
@@ -147,8 +148,15 @@ export const minioUploadService = {
|
||||
return serviceFail(`图片上传失败:${response.status}`, 500, 'MINIO_UPLOAD_FAILED')
|
||||
}
|
||||
|
||||
const fileId = crypto.randomUUID()
|
||||
await prisma.supplierFile.create({
|
||||
data: { fileId, fileName, objectName, bucketName: minioConfig.bucket, contentType, fileSize: body.length }
|
||||
})
|
||||
const fileUrl = `/api/file?fid=${encodeURIComponent(fileId)}`
|
||||
const previewUrl = presign('GET', objectName)
|
||||
return serviceOk({
|
||||
fileId,
|
||||
fileUrl,
|
||||
objectName,
|
||||
fileName,
|
||||
contentType,
|
||||
@@ -158,6 +166,16 @@ export const minioUploadService = {
|
||||
})
|
||||
},
|
||||
|
||||
async imageUrl(fileId: string) {
|
||||
if (!ensureConfig()) return serviceFail('MinIO配置不完整', 500, 'MINIO_CONFIG_MISSING')
|
||||
if (!fileId) return serviceFail('文件ID不能为空', 400, 'FILE_ID_REQUIRED')
|
||||
const file = await prisma.supplierFile.findFirst({ where: { fileId, deleted: 0 } })
|
||||
if (!file || !file.contentType?.startsWith('image/')) {
|
||||
return serviceFail('图片不存在', 404, 'IMAGE_NOT_FOUND')
|
||||
}
|
||||
return serviceOk({ url: presign('GET', file.objectName, 3600, file.bucketName) })
|
||||
},
|
||||
|
||||
preview(input: PreviewInput) {
|
||||
if (!ensureConfig()) {
|
||||
return serviceFail('MinIO配置不完整', 500, 'MINIO_CONFIG_MISSING')
|
||||
|
||||
@@ -204,9 +204,9 @@ export const orderActionService = {
|
||||
}
|
||||
|
||||
const payTime = new Date()
|
||||
await orderDao.transaction(async tx => {
|
||||
await tx.supplierOrderMain.update({
|
||||
where: { orderId },
|
||||
const paid = await orderDao.transaction(async tx => {
|
||||
const changed = await tx.supplierOrderMain.updateMany({
|
||||
where: { orderId, supplierId, deleted: 0, orderState: ORDER_STATE.PENDING_PAY },
|
||||
data: {
|
||||
orderState: ORDER_STATE.FINISH_PAY,
|
||||
isSplit: ORDER_SPLIT_STATE.NOT_SPLIT,
|
||||
@@ -217,11 +217,38 @@ export const orderActionService = {
|
||||
updateTime: payTime
|
||||
}
|
||||
})
|
||||
if (changed.count !== 1) return false
|
||||
await tx.supplierOrderSub.updateMany({
|
||||
where: { mainOrderId: orderId, deleted: 0 },
|
||||
data: { orderState: ORDER_STATE.FINISH_PAY, payTime, updateTime: payTime }
|
||||
})
|
||||
const products = await tx.supplierOrderProduct.findMany({
|
||||
where: { deleted: 0, subOrder: { mainOrderId: orderId, supplierId, deleted: 0 } },
|
||||
select: { sku: true, number: true }
|
||||
})
|
||||
const quantities = new Map<string, number>()
|
||||
products.forEach(product => {
|
||||
if (product.number > 0) quantities.set(product.sku, (quantities.get(product.sku) || 0) + product.number)
|
||||
})
|
||||
// 锁定商品行后累加销量,与首次支付状态变更一起提交,避免并发丢失或重复累计。
|
||||
for (const sku of [...quantities.keys()].sort()) {
|
||||
const rows = await tx.$queryRaw<{ id: number; sales: string | null }[]>`
|
||||
SELECT id, sales FROM supplier_product WHERE sku = ${sku} FOR UPDATE
|
||||
`
|
||||
const product = rows[0]
|
||||
if (!product) continue
|
||||
const text = String(product.sales || '').trim().replace(/,/g, '')
|
||||
const match = text.match(/^(\d+(?:\.\d+)?)\s*([万千])?(?:件)?\+?$/)
|
||||
if (text && !match) throw new Error(`商品销量格式不正确:${sku}`)
|
||||
const sales = match ? Math.floor(Number(match[1]) * (match[2] === '万' ? 10000 : match[2] === '千' ? 1000 : 1)) : 0
|
||||
await tx.supplierProduct.update({
|
||||
where: { id: product.id },
|
||||
data: { sales: String(sales + quantities.get(sku)!) }
|
||||
})
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (!paid) return serviceFail('当前订单状态不可支付,请刷新订单状态', 400, 'ORDER_NOT_PAYABLE')
|
||||
|
||||
const confirm = await bridgeFetchWithAuth<BridgeOrderResponse>(supplierId, '/pointOrder/confirmPurchase', {
|
||||
orderId,
|
||||
|
||||
@@ -34,7 +34,7 @@ const toProductItem = (product: ProductRecord) => {
|
||||
categoryId: product.categoryId || '',
|
||||
category: product.category || '',
|
||||
tag: product.tag || '',
|
||||
sales: product.sales || '',
|
||||
sales: product.sales?.trim() || '0',
|
||||
stock: product.stock,
|
||||
sortOrder: product.sortOrder,
|
||||
enabled: product.enabled
|
||||
@@ -68,9 +68,11 @@ export const productService = {
|
||||
return serviceOk(toProductItem(product))
|
||||
},
|
||||
|
||||
async listManageProducts(keyword: string, categoryId: string) {
|
||||
const products = await productDao.findManageProducts(keyword, categoryId)
|
||||
return serviceOk({ categories, products: products.map(toProductItem) })
|
||||
async listManageProducts(keyword: string, categoryId: string, requestedPageNumber = 1, requestedPageSize = 10) {
|
||||
const pageNumber = Number.isInteger(Number(requestedPageNumber)) && Number(requestedPageNumber) > 0 ? Number(requestedPageNumber) : 1
|
||||
const pageSize = Number.isInteger(Number(requestedPageSize)) && Number(requestedPageSize) > 0 ? Math.min(Number(requestedPageSize), 100) : 10
|
||||
const { products, totalCount } = await productDao.findManageProducts(keyword, categoryId, pageNumber, pageSize)
|
||||
return serviceOk({ categories, products: products.map(product => ({ ...toProductItem(product), description: product.description || '', detail: product.detail || '' })), totalCount, pageNumber, pageSize })
|
||||
},
|
||||
|
||||
async saveManageProduct(input: {
|
||||
@@ -99,6 +101,13 @@ export const productService = {
|
||||
return serviceFail('商品SKU、名称和价格不能为空')
|
||||
}
|
||||
|
||||
let imageUrl = String(input.imageUrl || '').trim()
|
||||
if (imageUrl) {
|
||||
const image = new URL(imageUrl, 'http://supplier.local')
|
||||
if ((image.pathname.endsWith('/api/file') || image.pathname.endsWith('/api/upload-preview')) && image.searchParams.get('fid')) {
|
||||
imageUrl = `/api/file?fid=${encodeURIComponent(image.searchParams.get('fid')!)}`
|
||||
}
|
||||
}
|
||||
const data = {
|
||||
sku,
|
||||
name,
|
||||
@@ -108,17 +117,22 @@ export const productService = {
|
||||
taxRate: Number(input.taxRate ?? 0.13),
|
||||
categoryId: String(input.categoryId || '').trim() || null,
|
||||
category: String(input.category || '').trim() || null,
|
||||
imageUrl: String(input.imageUrl || '').trim() || null,
|
||||
imageUrl: imageUrl || null,
|
||||
description: String(input.description || '').trim() || null,
|
||||
detail: String(input.detail || '').trim() || null,
|
||||
originPrice: input.originPrice === undefined || input.originPrice === null ? null : Number(input.originPrice),
|
||||
tag: String(input.tag || '').trim() || null,
|
||||
sales: String(input.sales || '').trim() || null,
|
||||
sales: '0',
|
||||
sortOrder: Math.max(Number(input.sortOrder || 0), 0),
|
||||
enabled: input.enabled !== false
|
||||
}
|
||||
const id = Number(input.id || 0)
|
||||
const product = id > 0 ? await productDao.updateProduct(id, data) : await productDao.createProduct(data)
|
||||
const updateData: Partial<typeof data> = { ...data }
|
||||
for (const field of Object.keys(data) as Array<keyof typeof data>) {
|
||||
if (input[field] === undefined) delete updateData[field]
|
||||
}
|
||||
delete updateData.sales
|
||||
const product = id > 0 ? await productDao.updateProduct(id, updateData) : await productDao.createProduct(data)
|
||||
return serviceOk(toProductItem(product))
|
||||
},
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ export type ReconciliationResultCallbackInput = {
|
||||
}
|
||||
|
||||
export type ReconciliationResultListInput = {
|
||||
pageNumber?: number
|
||||
pageSize?: number
|
||||
supplierId?: string
|
||||
reconciliationStatus?: number
|
||||
orderId?: string
|
||||
@@ -189,26 +191,34 @@ export const reconciliationResultService = {
|
||||
|
||||
async list(input: ReconciliationResultListInput) {
|
||||
const supplierId = String(input.supplierId || '').trim()
|
||||
if (!supplierId) return serviceFail('供应商不能为空')
|
||||
const reconciliationStatus = input.reconciliationStatus === undefined ? undefined : Number(input.reconciliationStatus)
|
||||
if (reconciliationStatus !== undefined && ![0, 1, 2].includes(reconciliationStatus)) {
|
||||
return serviceFail('对账状态不正确')
|
||||
}
|
||||
|
||||
const orderId = String(input.orderId || '').trim()
|
||||
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 ? Math.min(Number(input.pageSize), 100) : 10
|
||||
const condition = Prisma.sql`
|
||||
WHERE deleted = 0
|
||||
${supplierId ? Prisma.sql`AND supplierId = ${supplierId}` : Prisma.empty}
|
||||
${reconciliationStatus === undefined ? Prisma.empty : Prisma.sql`AND reconciliationStatus = ${reconciliationStatus}`}
|
||||
${orderId ? Prisma.sql`AND orderId LIKE ${`%${orderId}%`}` : Prisma.empty}
|
||||
`
|
||||
const totals = await prisma.$queryRaw<{ totalCount: bigint }[]>`
|
||||
SELECT COUNT(*) AS totalCount FROM supplier_reconciliation_order ${condition}
|
||||
`
|
||||
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
|
||||
${condition}
|
||||
ORDER BY updateAt DESC, supplierId, orderId
|
||||
LIMIT ${pageSize} OFFSET ${(pageNumber - 1) * pageSize}
|
||||
`
|
||||
return serviceOk({ reconciliationItems: records.map(resultItem) })
|
||||
return serviceOk({ reconciliationItems: records.map(resultItem), totalCount: Number(totals[0]?.totalCount || 0), pageNumber, pageSize })
|
||||
},
|
||||
|
||||
async update(input: ReconciliationResultUpdateInput) {
|
||||
|
||||
@@ -44,6 +44,15 @@ export const supplierFileService = {
|
||||
return { fileId, objectName }
|
||||
},
|
||||
|
||||
async pdfUrl(fileId: string, supplierId: string, download = false) {
|
||||
const file = await prisma.supplierFile.findFirst({ where: { fileId, supplierId, deleted: 0, contentType: 'application/pdf' } })
|
||||
if (!file) return null
|
||||
return client.presignedGetObject(file.bucketName, file.objectName, 3600, {
|
||||
'response-content-type': 'application/pdf',
|
||||
'response-content-disposition': `${download ? 'attachment' : 'inline'}; filename*=UTF-8''${encodeURIComponent(file.fileName)}`
|
||||
})
|
||||
},
|
||||
|
||||
async read(fileId: string, supplierId: string) {
|
||||
const file = await prisma.supplierFile.findFirst({ where: { fileId, supplierId, deleted: 0 } })
|
||||
if (!file) return null
|
||||
|
||||
@@ -9,6 +9,7 @@ import { logger } from '../utils/logger.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'
|
||||
import { minioUploadService } from './minio-upload.service.js'
|
||||
|
||||
export type SupplierOrderQueryRequest = {
|
||||
supplierId?: string
|
||||
@@ -123,7 +124,13 @@ const storedRequest = (value: unknown) => value && typeof value === 'object' &&
|
||||
: {}
|
||||
|
||||
const encodeImage = async (source?: string | null) => {
|
||||
const imageSource = String(source || '').trim()
|
||||
let imageSource = String(source || '').trim()
|
||||
if (/^\/api\/(?:file|upload-preview)\?/.test(imageSource)) {
|
||||
const fileId = new URL(imageSource, 'http://supplier.local').searchParams.get('fid') || ''
|
||||
const result = await minioUploadService.imageUrl(fileId)
|
||||
if (!result.success) return ''
|
||||
imageSource = result.data.url
|
||||
}
|
||||
if (!imageSource) return ''
|
||||
if (imageSource.startsWith('data:')) {
|
||||
return imageSource.split(',')[1] || ''
|
||||
|
||||
@@ -324,6 +324,32 @@ export const generateMockInvoicePdf = (invoiceInput, outputPath, _options = {})
|
||||
const invoiceDate = dateValue.replace(/^(\d{4})-(\d{2})-(\d{2}).*$/, '$1年$2月$3日')
|
||||
const title = Number(invoice.invoiceType) === 9 ? '电子发票(增值税专用发票)' : '电子发票(普通发票)'
|
||||
|
||||
const products = Array.isArray(invoice.orderProducts) && invoice.orderProducts.length
|
||||
? invoice.orderProducts
|
||||
: [{ name: invoice.invoiceContent, number: invoice.orderNum, price: invoice.total / invoice.orderNum, unit: '项' }]
|
||||
const weights = products.map(product => Math.max(Number(product.price || 0) * Number(product.number || 1), 0))
|
||||
const totalWeight = weights.reduce((sum, value) => sum + value, 0)
|
||||
const nakedCents = Math.round(invoice.naked * 100)
|
||||
const taxCents = Math.round(invoice.tax * 100)
|
||||
let accumulatedWeight = 0
|
||||
let allocatedNaked = 0
|
||||
let allocatedTax = 0
|
||||
// 按商品金额分摊票面金额,累计取整以消除逐行计算产生的分位尾差。
|
||||
const invoiceItems = products.map((product, index) => {
|
||||
accumulatedWeight += weights[index]
|
||||
const ratio = totalWeight > 0 ? accumulatedWeight / totalWeight : (index + 1) / products.length
|
||||
const nextNaked = index === products.length - 1 ? nakedCents : Math.round(nakedCents * ratio)
|
||||
const nextTax = index === products.length - 1 ? taxCents : Math.round(taxCents * ratio)
|
||||
const item = { ...product, number: Number(product.number || 1), naked: (nextNaked - allocatedNaked) / 100, tax: (nextTax - allocatedTax) / 100 }
|
||||
allocatedNaked = nextNaked
|
||||
allocatedTax = nextTax
|
||||
return item
|
||||
})
|
||||
const pages = []
|
||||
const itemsPerPage = 5
|
||||
for (let itemOffset = 0; itemOffset < invoiceItems.length; itemOffset += itemsPerPage) {
|
||||
commands = []
|
||||
const lastItemPage = itemOffset + itemsPerPage >= invoiceItems.length
|
||||
commands.push('1 1 1 rg 0 0 842 595 re f')
|
||||
text(title, (PAGE_WIDTH - title.length * 21) / 2, 546, 21, labelColor)
|
||||
line(245, 531, 590, 531, 0.8, labelColor)
|
||||
@@ -358,22 +384,30 @@ export const generateMockInvoicePdf = (invoiceInput, outputPath, _options = {})
|
||||
const x = (columns[index] + columns[index + 1] - label.length * 9) / 2
|
||||
text(label, x, 382, 9, labelColor)
|
||||
})
|
||||
wrappedText(`*商品*${String(invoice.invoiceContent).length > 50 ? '订单商品汇总(详见后附明细)' : invoice.invoiceContent}`, 50, 360, 20, 9, 14)
|
||||
text('项', 344, 360, 9)
|
||||
fitText(invoice.orderNum, 398, 360, 32, 9)
|
||||
fitText(money(invoice.naked / invoice.orderNum), 445, 360, 60, 9)
|
||||
fitText(money(invoice.naked), 526, 360, 73, 9)
|
||||
fitText(`${Number((invoice.taxRate * 100).toFixed(4))}%`, 627, 360, 60, 9)
|
||||
fitText(money(invoice.tax), 722, 360, 70, 9)
|
||||
invoiceItems.slice(itemOffset, itemOffset + itemsPerPage).forEach((item, index) => {
|
||||
const y = 360 - index * 23
|
||||
fitText(`*商品*${item.name || item.sku || '商品'}`, 50, y, 184, 9)
|
||||
fitText(item.spec || item.skuText || '', 247, y, 69, 8)
|
||||
fitText(item.unit || '件', 337, y, 40, 9)
|
||||
fitText(item.number, 391, y, 40, 9)
|
||||
fitText(Number((item.naked / item.number).toFixed(6)).toString(), 445, y, 60, 9)
|
||||
fitText(money(item.naked), 526, y, 73, 9)
|
||||
fitText(`${Number((invoice.taxRate * 100).toFixed(4))}%`, 627, y, 60, 9)
|
||||
fitText(money(item.tax), 722, y, 70, 9)
|
||||
})
|
||||
if (lastItemPage) {
|
||||
text('合 计', 92, 230, 10, labelColor)
|
||||
fitText(`¥${money(invoice.naked)}`, 526, 230, 73, 10)
|
||||
fitText(`¥${money(invoice.tax)}`, 722, 230, 70, 10)
|
||||
} else {
|
||||
text('项目明细续下页,合计见末页', 92, 230, 10, labelColor)
|
||||
}
|
||||
|
||||
line(235, 183, 235, 217, 0.7, labelColor)
|
||||
text('价税合计(大写)', 88, 196, 10, labelColor)
|
||||
fitText(chineseAmount(invoice.total), 245, 196, 340, 11)
|
||||
fitText(lastItemPage ? chineseAmount(invoice.total) : '详见项目明细末页', 245, 196, 340, 11)
|
||||
text('(小写)', 603, 196, 10, labelColor)
|
||||
fitText(`¥${money(invoice.total)}`, 657, 196, 133, 11)
|
||||
if (lastItemPage) fitText(`¥${money(invoice.total)}`, 657, 196, 133, 11)
|
||||
|
||||
line(70, 100, 70, 183, 0.7, labelColor)
|
||||
text('备', 51, 153, 10, labelColor)
|
||||
@@ -384,7 +418,9 @@ export const generateMockInvoicePdf = (invoiceInput, outputPath, _options = {})
|
||||
text('开票人:', 90, 73, 10, labelColor)
|
||||
fitText(invoice.biller || '模拟开票员', 140, 73, 250, 10)
|
||||
|
||||
const pages = [Buffer.from(commands.join('\n'), 'ascii')]
|
||||
text(`项目明细第 ${pages.length + 1} / ${Math.ceil(invoiceItems.length / itemsPerPage)} 页`, 620, 73, 9, labelColor)
|
||||
pages.push(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 = []
|
||||
|
||||
Reference in New Issue
Block a user