feat: 积分商城-供应商-对账-发票4

This commit is contained in:
2026-09-08 20:12:40 +08:00
parent f42ea5b270
commit ff77ba16cc
39 changed files with 705 additions and 136 deletions
@@ -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) => {
+5 -3
View File
@@ -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' },
+16 -12
View File
@@ -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[]>`
+1
View File
@@ -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'
+19
View File
@@ -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] || ''
+46 -10
View File
@@ -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 = []