feat: 订单售后
This commit is contained in:
@@ -2,6 +2,8 @@ import type { Context } from '../utils/handler.js'
|
||||
import { guardMethod } from '../utils/method.js'
|
||||
import { withErrorBoundary } from '../utils/handler.js'
|
||||
import { basicService } from '../services/basic.service.js'
|
||||
import { minioUploadService, type PreviewInput, type UploadInput } from '../services/minio-upload.service.js'
|
||||
import { readJson } from '../utils/request.js'
|
||||
import { toHttpResponse } from './controller-response.js'
|
||||
|
||||
export const loginController = withErrorBoundary(async (request: Request, _context: Context) => {
|
||||
@@ -22,7 +24,14 @@ export const uploadController = withErrorBoundary(async (request: Request, _cont
|
||||
const methodResponse = guardMethod(request, ['POST'])
|
||||
if (methodResponse) return methodResponse
|
||||
|
||||
return toHttpResponse(basicService.upload())
|
||||
return toHttpResponse(await minioUploadService.upload(await readJson<UploadInput>(request)))
|
||||
})
|
||||
|
||||
export const uploadPreviewController = withErrorBoundary(async (request: Request, _context: Context) => {
|
||||
const methodResponse = guardMethod(request, ['POST'])
|
||||
if (methodResponse) return methodResponse
|
||||
|
||||
return toHttpResponse(minioUploadService.preview(await readJson<PreviewInput>(request)))
|
||||
})
|
||||
|
||||
export const userInfoController = withErrorBoundary(async (request: Request, _context: Context) => {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Context } from '../utils/handler.js'
|
||||
import { guardMethod } from '../utils/method.js'
|
||||
import { readJson } from '../utils/request.js'
|
||||
import { withErrorBoundary } from '../utils/handler.js'
|
||||
import {
|
||||
merchantService,
|
||||
type MerchantAfterSaleListInput,
|
||||
type MerchantOrderListInput,
|
||||
type MerchantShipInput
|
||||
} from '../services/merchant.service.js'
|
||||
import type { AfterSaleAuditInput, AfterSaleLogisticsInput } from '../services/after-sale.service.js'
|
||||
import { toHttpResponse } from './controller-response.js'
|
||||
|
||||
type MerchantAfterSaleActionInput = AfterSaleLogisticsInput & {
|
||||
logisticsNo?: string
|
||||
logisticsCompany?: string
|
||||
}
|
||||
|
||||
export const merchantOrderListController = withErrorBoundary(async (request: Request, _context: Context) => {
|
||||
const methodResponse = guardMethod(request, ['POST'])
|
||||
if (methodResponse) return methodResponse
|
||||
|
||||
return toHttpResponse(await merchantService.listOrders(await readJson<MerchantOrderListInput>(request)))
|
||||
})
|
||||
|
||||
export const merchantOrderShipController = withErrorBoundary(async (request: Request, _context: Context) => {
|
||||
const methodResponse = guardMethod(request, ['POST'])
|
||||
if (methodResponse) return methodResponse
|
||||
|
||||
return toHttpResponse(await merchantService.shipOrder(await readJson<MerchantShipInput>(request)))
|
||||
})
|
||||
|
||||
export const merchantAfterSaleListController = withErrorBoundary(async (request: Request, _context: Context) => {
|
||||
const methodResponse = guardMethod(request, ['POST'])
|
||||
if (methodResponse) return methodResponse
|
||||
|
||||
return toHttpResponse(await merchantService.listAfterSales(await readJson<MerchantAfterSaleListInput>(request)))
|
||||
})
|
||||
|
||||
export const merchantAfterSaleAuditController = withErrorBoundary(async (request: Request, _context: Context) => {
|
||||
const methodResponse = guardMethod(request, ['POST'])
|
||||
if (methodResponse) return methodResponse
|
||||
|
||||
return toHttpResponse(await merchantService.auditAfterSale(await readJson<AfterSaleAuditInput>(request)))
|
||||
})
|
||||
|
||||
export const merchantAfterSaleReceiveController = withErrorBoundary(async (request: Request, _context: Context) => {
|
||||
const methodResponse = guardMethod(request, ['POST'])
|
||||
if (methodResponse) return methodResponse
|
||||
|
||||
const body = await readJson<MerchantAfterSaleActionInput>(request)
|
||||
return toHttpResponse(await merchantService.receiveAfterSale(
|
||||
String(body.serviceOrderId || '').trim(),
|
||||
String(body.supplierId || '').trim()
|
||||
))
|
||||
})
|
||||
|
||||
export const merchantAfterSaleShipController = withErrorBoundary(async (request: Request, _context: Context) => {
|
||||
const methodResponse = guardMethod(request, ['POST'])
|
||||
if (methodResponse) return methodResponse
|
||||
|
||||
const body = await readJson<MerchantAfterSaleActionInput>(request)
|
||||
return toHttpResponse(await merchantService.shipExchange(
|
||||
String(body.serviceOrderId || '').trim(),
|
||||
String(body.supplierId || '').trim(),
|
||||
String(body.logisticsNo || body.merchantLogisticsNo || '').trim(),
|
||||
String(body.logisticsCompany || body.merchantLogisticsCompany || '').trim()
|
||||
))
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
afterSaleService,
|
||||
type AfterSaleApplyInput,
|
||||
type AfterSaleAuditInput,
|
||||
type AfterSaleDetailInput,
|
||||
type AfterSaleListInput,
|
||||
type AfterSaleLogisticsInput
|
||||
} from '../services/after-sale.service.js'
|
||||
@@ -99,6 +100,13 @@ export const afterSaleListController = withErrorBoundary(async (request: Request
|
||||
return toHttpResponse(await afterSaleService.list(await readJson<AfterSaleListInput>(request)))
|
||||
})
|
||||
|
||||
export const afterSaleDetailController = withErrorBoundary(async (request: Request, _context: Context) => {
|
||||
const methodResponse = guardMethod(request, ['POST'])
|
||||
if (methodResponse) return methodResponse
|
||||
|
||||
return toHttpResponse(await afterSaleService.detail(await readJson<AfterSaleDetailInput>(request)))
|
||||
})
|
||||
|
||||
export const afterSaleAuditController = withErrorBoundary(async (request: Request, _context: Context) => {
|
||||
const methodResponse = guardMethod(request, ['POST'])
|
||||
if (methodResponse) return methodResponse
|
||||
|
||||
@@ -137,6 +137,40 @@ export const orderDao = {
|
||||
})
|
||||
},
|
||||
|
||||
findMerchantSubOrders(supplierId: string, orderState?: number) {
|
||||
return prisma.supplierOrderSub.findMany({
|
||||
where: {
|
||||
supplierId,
|
||||
deleted: 0,
|
||||
...(Number.isInteger(orderState) ? { orderState } : {})
|
||||
},
|
||||
include: {
|
||||
products: {
|
||||
where: { deleted: 0 },
|
||||
orderBy: { createAt: 'asc' }
|
||||
}
|
||||
},
|
||||
orderBy: [
|
||||
{ createTime: 'desc' },
|
||||
{ createAt: 'desc' }
|
||||
]
|
||||
})
|
||||
},
|
||||
|
||||
findMerchantAfterSales(supplierId: string, serviceStates?: number[]) {
|
||||
return prisma.supplierAfterSale.findMany({
|
||||
where: {
|
||||
supplierId,
|
||||
deleted: 0,
|
||||
...(serviceStates?.length ? { serviceState: { in: serviceStates } } : {})
|
||||
},
|
||||
orderBy: [
|
||||
{ createTime: 'desc' },
|
||||
{ id: 'desc' }
|
||||
]
|
||||
})
|
||||
},
|
||||
|
||||
findUserAddresses(supplierId: string, userId: string) {
|
||||
return prisma.supplierUserAddress.findMany({
|
||||
where: { supplierId, userId, deleted: 0 },
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { afterSaleDetailController as default } from '../controllers/order.controller.js'
|
||||
@@ -0,0 +1 @@
|
||||
export { merchantAfterSaleAuditController as default } from '../controllers/merchant.controller.js'
|
||||
@@ -0,0 +1 @@
|
||||
export { merchantAfterSaleListController as default } from '../controllers/merchant.controller.js'
|
||||
@@ -0,0 +1 @@
|
||||
export { merchantAfterSaleReceiveController as default } from '../controllers/merchant.controller.js'
|
||||
@@ -0,0 +1 @@
|
||||
export { merchantAfterSaleShipController as default } from '../controllers/merchant.controller.js'
|
||||
@@ -0,0 +1 @@
|
||||
export { merchantOrderListController as default } from '../controllers/merchant.controller.js'
|
||||
@@ -0,0 +1 @@
|
||||
export { merchantOrderShipController as default } from '../controllers/merchant.controller.js'
|
||||
@@ -0,0 +1 @@
|
||||
export { uploadPreviewController as default } from '../controllers/basic.controller.js'
|
||||
@@ -14,11 +14,13 @@ import {
|
||||
cartAddController,
|
||||
loginController,
|
||||
uploadController,
|
||||
uploadPreviewController,
|
||||
userInfoController
|
||||
} from './controllers/basic.controller.js'
|
||||
import {
|
||||
afterSaleApplyController,
|
||||
afterSaleAuditController,
|
||||
afterSaleDetailController,
|
||||
afterSaleListController,
|
||||
afterSaleLogisticsController,
|
||||
orderCancelMainController,
|
||||
@@ -33,6 +35,14 @@ import {
|
||||
orderShipController,
|
||||
supplierOrderInfoController
|
||||
} from './controllers/order.controller.js'
|
||||
import {
|
||||
merchantAfterSaleAuditController,
|
||||
merchantAfterSaleListController,
|
||||
merchantAfterSaleReceiveController,
|
||||
merchantAfterSaleShipController,
|
||||
merchantOrderListController,
|
||||
merchantOrderShipController
|
||||
} from './controllers/merchant.controller.js'
|
||||
import { productDetailController, productListController } from './controllers/product.controller.js'
|
||||
import {
|
||||
supplierDebugTokensController,
|
||||
@@ -52,10 +62,17 @@ const handlers: Record<string, FunctionHandler> = {
|
||||
'address-save': addressSaveController,
|
||||
'after-sale-apply': afterSaleApplyController,
|
||||
'after-sale-audit': afterSaleAuditController,
|
||||
'after-sale-detail': afterSaleDetailController,
|
||||
'after-sale-list': afterSaleListController,
|
||||
'after-sale-logistics': afterSaleLogisticsController,
|
||||
'cart-add': cartAddController,
|
||||
login: loginController,
|
||||
'merchant-after-sale-audit': merchantAfterSaleAuditController,
|
||||
'merchant-after-sale-list': merchantAfterSaleListController,
|
||||
'merchant-after-sale-receive': merchantAfterSaleReceiveController,
|
||||
'merchant-after-sale-ship': merchantAfterSaleShipController,
|
||||
'merchant-order-list': merchantOrderListController,
|
||||
'merchant-order-ship': merchantOrderShipController,
|
||||
'order-cancel-main': orderCancelMainController,
|
||||
'order-cancel-paid-main': orderCancelPaidMainController,
|
||||
'order-cancel-sub': orderCancelSubController,
|
||||
@@ -74,6 +91,7 @@ const handlers: Record<string, FunctionHandler> = {
|
||||
'supplier-points': supplierPointsController,
|
||||
'supplier-token': supplierTokenController,
|
||||
upload: uploadController,
|
||||
'upload-preview': uploadPreviewController,
|
||||
'user-info': userInfoController
|
||||
}
|
||||
|
||||
@@ -116,7 +134,7 @@ const dispatch = (name: string) => async (ctx: Koa.Context) => {
|
||||
await writeResponse(ctx, response)
|
||||
}
|
||||
|
||||
app.use(bodyParser())
|
||||
app.use(bodyParser({ jsonLimit: '10mb' }))
|
||||
app.use(async (ctx, next) => {
|
||||
Object.entries(corsHeaders).forEach(([key, value]) => ctx.set(key, value))
|
||||
if (ctx.method === 'OPTIONS') {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ORDER_STATE
|
||||
} from '../constants/order.js'
|
||||
import { orderDao } from '../dao/order.dao.js'
|
||||
import { productDao } from '../dao/product.dao.js'
|
||||
import { logger } from '../utils/logger.js'
|
||||
import { serviceFail, serviceOk } from '../types/service-result.js'
|
||||
|
||||
@@ -17,6 +18,16 @@ export type AfterSaleApplyInput = {
|
||||
serviceType?: number
|
||||
amount?: number
|
||||
reason?: string
|
||||
reasonType?: string
|
||||
problemDescription?: string
|
||||
pickupMethod?: string
|
||||
pickupName?: string
|
||||
pickupMobile?: string
|
||||
pickupAddress?: string
|
||||
images?: unknown[]
|
||||
exchangeProductSku?: string
|
||||
exchangeProductName?: string
|
||||
exchangeProductPrice?: number | string
|
||||
}
|
||||
|
||||
export type AfterSaleListInput = {
|
||||
@@ -24,6 +35,12 @@ export type AfterSaleListInput = {
|
||||
userId?: string
|
||||
}
|
||||
|
||||
export type AfterSaleDetailInput = {
|
||||
supplierId?: string
|
||||
userId?: string
|
||||
serviceOrderId?: string
|
||||
}
|
||||
|
||||
export type AfterSaleAuditInput = {
|
||||
supplierId?: string
|
||||
serviceOrderId?: string
|
||||
@@ -111,6 +128,61 @@ const createServiceOrderId = (subOrderId: string, serviceType: number) => {
|
||||
return `AS${serviceType}_${subOrderId}_${Date.now()}`
|
||||
}
|
||||
|
||||
const afterSaleDetailPayload = (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>, subOrder?: Awaited<ReturnType<typeof orderDao.findOpenApiSubOrders>>[number] | null) => {
|
||||
const requestBody = record.requestBody && typeof record.requestBody === 'object' ? record.requestBody as AfterSaleApplyInput : {}
|
||||
const products = subOrder?.products.map(product => ({
|
||||
id: product.id,
|
||||
sku: product.sku,
|
||||
name: product.name,
|
||||
number: product.number,
|
||||
price: toYuanText(product.price),
|
||||
image: product.productImgUrl || firstImage(product.picInfo)
|
||||
})) || []
|
||||
|
||||
return {
|
||||
serviceOrderId: record.serviceOrderId,
|
||||
supplierId: record.supplierId,
|
||||
userId: record.userId,
|
||||
mainOrderId: record.mainOrderId,
|
||||
subOrderId: record.subOrderId,
|
||||
orderId: record.subOrderId,
|
||||
orderNumberId: subOrder?.orderNumberId || '',
|
||||
serviceType: record.serviceType,
|
||||
serviceTypeText: serviceTypeText(record.serviceType),
|
||||
serviceState: record.serviceState,
|
||||
serviceStateText: serviceStateText(record.serviceState),
|
||||
auditStatus: record.auditStatus,
|
||||
logisticsState: record.logisticsState,
|
||||
logisticsStateText: logisticsStateText(record.logisticsState),
|
||||
amount: toYuanText(record.amount),
|
||||
reason: record.reason || '',
|
||||
reasonType: record.reasonType || '',
|
||||
problemDescription: record.problemDescription || '',
|
||||
pickupMethod: record.pickupMethod || '',
|
||||
pickupName: record.pickupName || '',
|
||||
pickupMobile: record.pickupMobile || '',
|
||||
pickupAddress: record.pickupAddress || '',
|
||||
exchangeProduct: requestBody.exchangeProductSku ? {
|
||||
sku: requestBody.exchangeProductSku,
|
||||
name: requestBody.exchangeProductName || '',
|
||||
price: requestBody.exchangeProductPrice || ''
|
||||
} : null,
|
||||
images: Array.isArray(record.images) ? record.images : [],
|
||||
auditRemark: record.auditRemark || '',
|
||||
userLogisticsNo: record.userLogisticsNo || '',
|
||||
userLogisticsCompany: record.userLogisticsCompany || '',
|
||||
merchantLogisticsNo: record.merchantLogisticsNo || '',
|
||||
merchantLogisticsCompany: record.merchantLogisticsCompany || '',
|
||||
applyTime: toDateTimeText(record.applyTime || record.createTime),
|
||||
auditTime: toDateTimeText(record.auditTime),
|
||||
userShipTime: toDateTimeText(record.userShipTime),
|
||||
merchantReceiveTime: toDateTimeText(record.merchantReceiveTime),
|
||||
merchantShipTime: toDateTimeText(record.merchantShipTime),
|
||||
finishTime: toDateTimeText(record.finishTime),
|
||||
products
|
||||
}
|
||||
}
|
||||
|
||||
export const afterSaleService = {
|
||||
async apply(input: AfterSaleApplyInput) {
|
||||
const supplierId = String(input.supplierId || '').trim()
|
||||
@@ -118,6 +190,14 @@ export const afterSaleService = {
|
||||
const subOrderId = String(input.subOrderId || input.orderId || '').trim()
|
||||
const serviceType = Number(input.serviceType)
|
||||
const reason = String(input.reason || '').trim()
|
||||
const reasonType = String(input.reasonType || '').trim()
|
||||
const problemDescription = String(input.problemDescription || '').trim()
|
||||
const pickupMethod = String(input.pickupMethod || '').trim()
|
||||
const pickupName = String(input.pickupName || '').trim()
|
||||
const pickupMobile = String(input.pickupMobile || '').trim()
|
||||
const pickupAddress = String(input.pickupAddress || '').trim()
|
||||
const images = Array.isArray(input.images) ? input.images.slice(0, 3) : []
|
||||
const exchangeProductSku = String(input.exchangeProductSku || '').trim()
|
||||
logger.info('after sale apply', { supplierId, userId, subOrderId, serviceType })
|
||||
|
||||
if (!supplierId || !userId || !subOrderId || !serviceType) {
|
||||
@@ -137,6 +217,22 @@ export const afterSaleService = {
|
||||
return serviceFail('当前订单状态不能申请退款/售后', 400, 'ORDER_NOT_ALLOW_AFTER_SALE')
|
||||
}
|
||||
|
||||
if (serviceType === ORDER_EVENT_TYPE.EXCHANGE_ORDER) {
|
||||
if (!exchangeProductSku) {
|
||||
return serviceFail('换货商品不能为空', 400, 'EXCHANGE_PRODUCT_REQUIRED')
|
||||
}
|
||||
|
||||
const originalProduct = subOrder.products[0]
|
||||
const exchangeProduct = await productDao.findEnabledProduct('', exchangeProductSku)
|
||||
if (!originalProduct || !exchangeProduct) {
|
||||
return serviceFail('换货商品不存在或已下架', 404, 'EXCHANGE_PRODUCT_NOT_FOUND')
|
||||
}
|
||||
|
||||
if (toYuanText(exchangeProduct.price) !== toYuanText(originalProduct.price)) {
|
||||
return serviceFail('换货商品价格必须与原商品价格一致', 400, 'EXCHANGE_PRODUCT_PRICE_NOT_MATCH')
|
||||
}
|
||||
}
|
||||
|
||||
const activeAfterSale = await orderDao.findAfterSale({
|
||||
supplierId,
|
||||
userId,
|
||||
@@ -166,6 +262,13 @@ export const afterSaleService = {
|
||||
logisticsState: AFTER_SALE_LOGISTICS_STATE.NONE,
|
||||
amount,
|
||||
reason: reason || null,
|
||||
reasonType: reasonType || null,
|
||||
problemDescription: problemDescription || null,
|
||||
pickupMethod: pickupMethod || null,
|
||||
pickupName: pickupName || null,
|
||||
pickupMobile: pickupMobile || null,
|
||||
pickupAddress: pickupAddress || null,
|
||||
images,
|
||||
applyTime: now,
|
||||
requestBody: input
|
||||
}
|
||||
@@ -196,7 +299,20 @@ export const afterSaleService = {
|
||||
serviceState: AFTER_SALE_SERVICE_STATE.PENDING_AUDIT,
|
||||
serviceStateText: serviceStateText(AFTER_SALE_SERVICE_STATE.PENDING_AUDIT),
|
||||
logisticsState: AFTER_SALE_LOGISTICS_STATE.NONE,
|
||||
logisticsStateText: logisticsStateText(AFTER_SALE_LOGISTICS_STATE.NONE)
|
||||
logisticsStateText: logisticsStateText(AFTER_SALE_LOGISTICS_STATE.NONE),
|
||||
reason,
|
||||
reasonType,
|
||||
problemDescription,
|
||||
pickupMethod,
|
||||
pickupName,
|
||||
pickupMobile,
|
||||
pickupAddress,
|
||||
exchangeProduct: exchangeProductSku ? {
|
||||
sku: exchangeProductSku,
|
||||
name: input.exchangeProductName || '',
|
||||
price: input.exchangeProductPrice || ''
|
||||
} : null,
|
||||
images
|
||||
})
|
||||
},
|
||||
|
||||
@@ -222,50 +338,38 @@ export const afterSaleService = {
|
||||
const subOrderMap = new Map(subOrders.map(item => [item.orderId, item]))
|
||||
const afterSaleItems = records.map(record => {
|
||||
const subOrder = record.subOrderId ? subOrderMap.get(record.subOrderId) : null
|
||||
const products = subOrder?.products.map(product => ({
|
||||
id: product.id,
|
||||
sku: product.sku,
|
||||
name: product.name,
|
||||
number: product.number,
|
||||
price: toYuanText(product.price),
|
||||
image: product.productImgUrl || firstImage(product.picInfo)
|
||||
})) || []
|
||||
|
||||
return {
|
||||
serviceOrderId: record.serviceOrderId,
|
||||
supplierId: record.supplierId,
|
||||
userId: record.userId,
|
||||
mainOrderId: record.mainOrderId,
|
||||
subOrderId: record.subOrderId,
|
||||
orderId: record.subOrderId,
|
||||
orderNumberId: subOrder?.orderNumberId || '',
|
||||
serviceType: record.serviceType,
|
||||
serviceTypeText: serviceTypeText(record.serviceType),
|
||||
serviceState: record.serviceState,
|
||||
serviceStateText: serviceStateText(record.serviceState),
|
||||
auditStatus: record.auditStatus,
|
||||
logisticsState: record.logisticsState,
|
||||
logisticsStateText: logisticsStateText(record.logisticsState),
|
||||
amount: toYuanText(record.amount),
|
||||
reason: record.reason || '',
|
||||
auditRemark: record.auditRemark || '',
|
||||
userLogisticsNo: record.userLogisticsNo || '',
|
||||
userLogisticsCompany: record.userLogisticsCompany || '',
|
||||
merchantLogisticsNo: record.merchantLogisticsNo || '',
|
||||
merchantLogisticsCompany: record.merchantLogisticsCompany || '',
|
||||
applyTime: toDateTimeText(record.applyTime || record.createTime),
|
||||
auditTime: toDateTimeText(record.auditTime),
|
||||
userShipTime: toDateTimeText(record.userShipTime),
|
||||
merchantReceiveTime: toDateTimeText(record.merchantReceiveTime),
|
||||
merchantShipTime: toDateTimeText(record.merchantShipTime),
|
||||
finishTime: toDateTimeText(record.finishTime),
|
||||
products
|
||||
}
|
||||
return afterSaleDetailPayload(record, subOrder)
|
||||
})
|
||||
|
||||
return serviceOk({ afterSaleItems })
|
||||
},
|
||||
|
||||
async detail(input: AfterSaleDetailInput) {
|
||||
const supplierId = String(input.supplierId || '').trim()
|
||||
const userId = String(input.userId || '').trim()
|
||||
const serviceOrderId = String(input.serviceOrderId || '').trim()
|
||||
logger.info('after sale detail query', { supplierId, userId, serviceOrderId })
|
||||
|
||||
if (!supplierId || !userId || !serviceOrderId) {
|
||||
return serviceFail('供应商、用户或售后单不能为空')
|
||||
}
|
||||
|
||||
const record = await orderDao.findAfterSale({
|
||||
supplierId,
|
||||
userId,
|
||||
serviceOrderId,
|
||||
deleted: 0
|
||||
})
|
||||
if (!record) {
|
||||
return serviceFail('售后申请不存在', 404, 'AFTER_SALE_NOT_FOUND')
|
||||
}
|
||||
|
||||
const subOrders = record.subOrderId
|
||||
? await orderDao.findOpenApiSubOrders(supplierId, [record.subOrderId])
|
||||
: []
|
||||
return serviceOk({ afterSale: afterSaleDetailPayload(record, subOrders[0]) })
|
||||
},
|
||||
|
||||
async audit(input: AfterSaleAuditInput) {
|
||||
const supplierId = String(input.supplierId || '').trim()
|
||||
const serviceOrderId = String(input.serviceOrderId || '').trim()
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
AFTER_SALE_LOGISTICS_STATE,
|
||||
AFTER_SALE_SERVICE_STATE,
|
||||
ORDER_EVENT_TYPE,
|
||||
ORDER_STATE
|
||||
} from '../constants/order.js'
|
||||
import { orderDao } from '../dao/order.dao.js'
|
||||
import { afterSaleService, type AfterSaleAuditInput, type AfterSaleLogisticsInput } from './after-sale.service.js'
|
||||
import { orderActionService } from './order-action.service.js'
|
||||
import { logger } from '../utils/logger.js'
|
||||
import { serviceFail, serviceOk } from '../types/service-result.js'
|
||||
|
||||
export type MerchantOrderListInput = {
|
||||
supplierId?: string
|
||||
orderState?: number
|
||||
}
|
||||
|
||||
export type MerchantShipInput = {
|
||||
supplierId?: string
|
||||
userId?: string
|
||||
orderId?: string
|
||||
logisticsOrderId?: string
|
||||
crrgBsnNm?: string
|
||||
}
|
||||
|
||||
export type MerchantAfterSaleListInput = {
|
||||
supplierId?: string
|
||||
scene?: string
|
||||
}
|
||||
|
||||
const serviceStateText = (serviceState: number) => {
|
||||
if (serviceState === AFTER_SALE_SERVICE_STATE.PENDING_AUDIT) return '商家待审核'
|
||||
if (serviceState === AFTER_SALE_SERVICE_STATE.APPROVED) return '审核通过'
|
||||
if (serviceState === AFTER_SALE_SERVICE_STATE.REJECTED) return '审核拒绝'
|
||||
if (serviceState === AFTER_SALE_SERVICE_STATE.USER_SHIPPED) return '用户已寄回'
|
||||
if (serviceState === AFTER_SALE_SERVICE_STATE.RECEIVED) return '商家已收货'
|
||||
if (serviceState === AFTER_SALE_SERVICE_STATE.FINISHED) return '售后完成'
|
||||
if (serviceState === AFTER_SALE_SERVICE_STATE.CANCELLED) return '已撤销'
|
||||
return '处理中'
|
||||
}
|
||||
|
||||
const serviceTypeText = (serviceType: number) => {
|
||||
if (serviceType === ORDER_EVENT_TYPE.RETURN_ORDER) return '退货'
|
||||
if (serviceType === ORDER_EVENT_TYPE.EXCHANGE_ORDER) return '换货'
|
||||
return '售后'
|
||||
}
|
||||
|
||||
const toYuanText = (value: { toFixed: (digits: number) => string } | number | string | null | undefined) => {
|
||||
if (value && typeof value === 'object' && 'toFixed' in value) {
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
return Number(value || 0).toFixed(2)
|
||||
}
|
||||
|
||||
const toDateTimeText = (date?: Date | null) => {
|
||||
if (!date) return ''
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
const firstImage = (picInfo: unknown) => {
|
||||
if (Array.isArray(picInfo)) {
|
||||
return String(picInfo[0] || '')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const afterSaleStatesByScene = (scene: string) => {
|
||||
if (scene === 'audit') return [AFTER_SALE_SERVICE_STATE.PENDING_AUDIT]
|
||||
if (scene === 'process') {
|
||||
return [
|
||||
AFTER_SALE_SERVICE_STATE.APPROVED,
|
||||
AFTER_SALE_SERVICE_STATE.USER_SHIPPED,
|
||||
AFTER_SALE_SERVICE_STATE.RECEIVED
|
||||
]
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const merchantService = {
|
||||
async listOrders(input: MerchantOrderListInput) {
|
||||
const supplierId = String(input.supplierId || '').trim()
|
||||
const orderState = Number(input.orderState ?? ORDER_STATE.FINISH_PAY)
|
||||
logger.info('merchant order list query', { supplierId, orderState })
|
||||
|
||||
if (!supplierId) {
|
||||
return serviceFail('供应商不能为空')
|
||||
}
|
||||
|
||||
const orders = await orderDao.findMerchantSubOrders(supplierId, orderState)
|
||||
const orderItems = orders.map(order => ({
|
||||
orderId: order.orderId,
|
||||
mainOrderId: order.mainOrderId,
|
||||
userId: order.userId || '',
|
||||
supplierId: order.supplierId,
|
||||
orderState: order.orderState,
|
||||
orderNumberId: order.orderNumberId || '',
|
||||
createTime: toDateTimeText(order.createTime || order.createAt),
|
||||
amount: toYuanText(order.totalPrice),
|
||||
logisticsOrderId: order.logisticsOrderId || '',
|
||||
crrgBsnNm: order.crrgBsnNm || '',
|
||||
products: order.products.map(product => ({
|
||||
id: product.id,
|
||||
sku: product.sku,
|
||||
name: product.name,
|
||||
number: product.number,
|
||||
price: toYuanText(product.price),
|
||||
image: product.productImgUrl || firstImage(product.picInfo)
|
||||
}))
|
||||
}))
|
||||
|
||||
return serviceOk({ orderItems })
|
||||
},
|
||||
|
||||
shipOrder(input: MerchantShipInput) {
|
||||
return orderActionService.shipOrder({
|
||||
supplierId: String(input.supplierId || '').trim(),
|
||||
userId: String(input.userId || '').trim(),
|
||||
orderId: String(input.orderId || '').trim(),
|
||||
deliveryStatus: 1,
|
||||
logisticsOrderId: String(input.logisticsOrderId || '').trim(),
|
||||
crrgBsnNm: String(input.crrgBsnNm || '').trim()
|
||||
})
|
||||
},
|
||||
|
||||
async listAfterSales(input: MerchantAfterSaleListInput) {
|
||||
const supplierId = String(input.supplierId || '').trim()
|
||||
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 subOrderMap = new Map(subOrders.map(item => [item.orderId, item]))
|
||||
const afterSaleItems = records.map(record => {
|
||||
const subOrder = record.subOrderId ? subOrderMap.get(record.subOrderId) : null
|
||||
|
||||
return {
|
||||
serviceOrderId: record.serviceOrderId,
|
||||
supplierId: record.supplierId,
|
||||
userId: record.userId || '',
|
||||
mainOrderId: record.mainOrderId || '',
|
||||
subOrderId: record.subOrderId || '',
|
||||
serviceType: record.serviceType,
|
||||
serviceTypeText: serviceTypeText(record.serviceType),
|
||||
serviceState: record.serviceState,
|
||||
serviceStateText: serviceStateText(record.serviceState),
|
||||
logisticsState: record.logisticsState,
|
||||
reason: record.reason || '',
|
||||
problemDescription: record.problemDescription || '',
|
||||
pickupMethod: record.pickupMethod || '',
|
||||
pickupName: record.pickupName || '',
|
||||
pickupMobile: record.pickupMobile || '',
|
||||
pickupAddress: record.pickupAddress || '',
|
||||
userLogisticsNo: record.userLogisticsNo || '',
|
||||
userLogisticsCompany: record.userLogisticsCompany || '',
|
||||
applyTime: toDateTimeText(record.applyTime || record.createTime),
|
||||
products: subOrder?.products.map(product => ({
|
||||
id: product.id,
|
||||
sku: product.sku,
|
||||
name: product.name,
|
||||
number: product.number,
|
||||
price: toYuanText(product.price),
|
||||
image: product.productImgUrl || firstImage(product.picInfo)
|
||||
})) || []
|
||||
}
|
||||
})
|
||||
|
||||
return serviceOk({ afterSaleItems })
|
||||
},
|
||||
|
||||
auditAfterSale(input: AfterSaleAuditInput) {
|
||||
return afterSaleService.audit(input)
|
||||
},
|
||||
|
||||
updateAfterSaleLogistics(input: AfterSaleLogisticsInput) {
|
||||
return afterSaleService.updateLogistics(input)
|
||||
},
|
||||
|
||||
receiveAfterSale(serviceOrderId: string, supplierId: string) {
|
||||
return afterSaleService.updateLogistics({
|
||||
supplierId,
|
||||
serviceOrderId,
|
||||
logisticsState: AFTER_SALE_LOGISTICS_STATE.MERCHANT_RECEIVED
|
||||
})
|
||||
},
|
||||
|
||||
shipExchange(serviceOrderId: string, supplierId: string, logisticsNo: string, logisticsCompany: string) {
|
||||
return afterSaleService.updateLogistics({
|
||||
supplierId,
|
||||
serviceOrderId,
|
||||
logisticsState: AFTER_SALE_LOGISTICS_STATE.MERCHANT_SHIPPED,
|
||||
merchantLogisticsNo: logisticsNo,
|
||||
merchantLogisticsCompany: logisticsCompany
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { serviceFail, serviceOk } from '../types/service-result.js'
|
||||
|
||||
export type UploadInput = {
|
||||
fileName?: string
|
||||
contentType?: string
|
||||
contentBase64?: string
|
||||
folder?: string
|
||||
}
|
||||
|
||||
export type PreviewInput = {
|
||||
objectName?: string
|
||||
expires?: number
|
||||
}
|
||||
|
||||
const minioConfig = {
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || '',
|
||||
secretKey: process.env.MINIO_SECRET_KEY || '',
|
||||
endPoint: process.env.MINIO_ENDPOINT || 'http://localhost:9000',
|
||||
bucket: process.env.MINIO_BUCKET || 'njupt',
|
||||
region: process.env.MINIO_REGION || 'us-east-1'
|
||||
}
|
||||
|
||||
const amzDate = (date: Date) => date.toISOString().replace(/[:-]|\.\d{3}/g, '')
|
||||
const shortDate = (date: Date) => amzDate(date).slice(0, 8)
|
||||
const sha256 = (value: crypto.BinaryLike) => crypto.createHash('sha256').update(value).digest('hex')
|
||||
const hmac = (key: crypto.BinaryLike | crypto.KeyObject, value: string) => crypto.createHmac('sha256', key).update(value).digest()
|
||||
const encodePath = (value: string) => value.split('/').map(item => encodeURIComponent(item)).join('/')
|
||||
|
||||
const ensureConfig = () => Boolean(minioConfig.accessKey && minioConfig.secretKey && minioConfig.endPoint && minioConfig.bucket)
|
||||
|
||||
const signingKey = (date: string) => {
|
||||
const dateKey = hmac(`AWS4${minioConfig.secretKey}`, date)
|
||||
const regionKey = hmac(dateKey, minioConfig.region)
|
||||
const serviceKey = hmac(regionKey, 's3')
|
||||
return hmac(serviceKey, 'aws4_request')
|
||||
}
|
||||
|
||||
const objectUrl = (objectName: string) => {
|
||||
const endpoint = new URL(minioConfig.endPoint)
|
||||
endpoint.pathname = `${endpoint.pathname.replace(/\/$/, '')}/${minioConfig.bucket}/${encodePath(objectName)}`
|
||||
return endpoint
|
||||
}
|
||||
|
||||
const extName = (fileName: string) => {
|
||||
const matched = fileName.match(/\.[a-zA-Z0-9]+$/)
|
||||
return matched ? matched[0].toLowerCase() : ''
|
||||
}
|
||||
|
||||
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 now = new Date()
|
||||
const date = shortDate(now)
|
||||
const fullDate = amzDate(now)
|
||||
const credentialScope = `${date}/${minioConfig.region}/s3/aws4_request`
|
||||
const endpoint = objectUrl(objectName)
|
||||
const params = new URLSearchParams({
|
||||
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
|
||||
'X-Amz-Credential': `${minioConfig.accessKey}/${credentialScope}`,
|
||||
'X-Amz-Date': fullDate,
|
||||
'X-Amz-Expires': String(Math.min(Math.max(Number(expires) || 3600, 1), 604800)),
|
||||
'X-Amz-SignedHeaders': 'host'
|
||||
})
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
endpoint.pathname,
|
||||
params.toString(),
|
||||
`host:${endpoint.host}\n`,
|
||||
'host',
|
||||
'UNSIGNED-PAYLOAD'
|
||||
].join('\n')
|
||||
const stringToSign = [
|
||||
'AWS4-HMAC-SHA256',
|
||||
fullDate,
|
||||
credentialScope,
|
||||
sha256(canonicalRequest)
|
||||
].join('\n')
|
||||
const signature = crypto.createHmac('sha256', signingKey(date)).update(stringToSign).digest('hex')
|
||||
params.set('X-Amz-Signature', signature)
|
||||
endpoint.search = params.toString()
|
||||
return endpoint.toString()
|
||||
}
|
||||
|
||||
const signedPutHeaders = (url: URL, body: Buffer, contentType: string) => {
|
||||
const now = new Date()
|
||||
const date = shortDate(now)
|
||||
const fullDate = amzDate(now)
|
||||
const payloadHash = sha256(body)
|
||||
const credentialScope = `${date}/${minioConfig.region}/s3/aws4_request`
|
||||
const canonicalHeaders = [
|
||||
`content-type:${contentType}`,
|
||||
`host:${url.host}`,
|
||||
`x-amz-content-sha256:${payloadHash}`,
|
||||
`x-amz-date:${fullDate}`
|
||||
].join('\n') + '\n'
|
||||
const signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date'
|
||||
const canonicalRequest = [
|
||||
'PUT',
|
||||
url.pathname,
|
||||
'',
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
payloadHash
|
||||
].join('\n')
|
||||
const stringToSign = [
|
||||
'AWS4-HMAC-SHA256',
|
||||
fullDate,
|
||||
credentialScope,
|
||||
sha256(canonicalRequest)
|
||||
].join('\n')
|
||||
const signature = crypto.createHmac('sha256', signingKey(date)).update(stringToSign).digest('hex')
|
||||
|
||||
return {
|
||||
'Content-Type': contentType,
|
||||
'X-Amz-Content-Sha256': payloadHash,
|
||||
'X-Amz-Date': fullDate,
|
||||
Authorization: `AWS4-HMAC-SHA256 Credential=${minioConfig.accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
|
||||
}
|
||||
}
|
||||
|
||||
export const minioUploadService = {
|
||||
async upload(input: UploadInput) {
|
||||
if (!ensureConfig()) {
|
||||
return serviceFail('MinIO配置不完整', 500, 'MINIO_CONFIG_MISSING')
|
||||
}
|
||||
|
||||
const fileName = String(input.fileName || '').trim()
|
||||
const contentType = String(input.contentType || 'application/octet-stream').trim()
|
||||
const contentBase64 = String(input.contentBase64 || '').replace(/^data:[^;]+;base64,/, '')
|
||||
if (!fileName || !contentBase64) {
|
||||
return serviceFail('文件名或图片内容不能为空')
|
||||
}
|
||||
if (!contentType.startsWith('image/')) {
|
||||
return serviceFail('只支持上传图片', 400, 'ONLY_IMAGE_ALLOWED')
|
||||
}
|
||||
|
||||
const body = Buffer.from(contentBase64, 'base64')
|
||||
const objectName = `${safeFolder(input.folder)}/${Date.now()}_${crypto.randomUUID()}${extName(fileName)}`
|
||||
const url = objectUrl(objectName)
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: signedPutHeaders(url, body, contentType),
|
||||
body
|
||||
})
|
||||
if (!response.ok) {
|
||||
return serviceFail(`图片上传失败:${response.status}`, 500, 'MINIO_UPLOAD_FAILED')
|
||||
}
|
||||
|
||||
const previewUrl = presign('GET', objectName)
|
||||
return serviceOk({
|
||||
objectName,
|
||||
fileName,
|
||||
contentType,
|
||||
size: body.length,
|
||||
url: previewUrl,
|
||||
previewUrl
|
||||
})
|
||||
},
|
||||
|
||||
preview(input: PreviewInput) {
|
||||
if (!ensureConfig()) {
|
||||
return serviceFail('MinIO配置不完整', 500, 'MINIO_CONFIG_MISSING')
|
||||
}
|
||||
|
||||
const objectName = String(input.objectName || '').trim()
|
||||
if (!objectName) {
|
||||
return serviceFail('图片对象名不能为空')
|
||||
}
|
||||
|
||||
const previewUrl = presign('GET', objectName, Number(input.expires || 3600))
|
||||
return serviceOk({
|
||||
objectName,
|
||||
url: previewUrl,
|
||||
previewUrl
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -197,6 +197,7 @@ export const orderActionService = {
|
||||
return serviceFail('当前订单状态不可支付,请刷新订单状态', 400, 'ORDER_NOT_PAYABLE')
|
||||
}
|
||||
|
||||
const payTime = new Date()
|
||||
await orderDao.transaction(async tx => {
|
||||
await tx.supplierOrderMain.update({
|
||||
where: { orderId },
|
||||
@@ -206,12 +207,13 @@ export const orderActionService = {
|
||||
totalPrice,
|
||||
wPayOrAPay,
|
||||
pointsPrice,
|
||||
updateTime: new Date()
|
||||
payTime,
|
||||
updateTime: payTime
|
||||
}
|
||||
})
|
||||
await tx.supplierOrderSub.updateMany({
|
||||
where: { mainOrderId: orderId, deleted: 0 },
|
||||
data: { orderState: ORDER_STATE.FINISH_PAY, updateTime: new Date() }
|
||||
data: { orderState: ORDER_STATE.FINISH_PAY, payTime, updateTime: payTime }
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ export const orderListService = {
|
||||
receiverAddress: addressView?.address || '',
|
||||
remark: subOrder.remark || mainOrder.remark || '',
|
||||
createTime: toDateTimeText(subOrder.createTime || subOrder.createAt),
|
||||
payTime: toDateTimeText(subOrder.payTime || mainOrder.payTime),
|
||||
status: status.text,
|
||||
statusClass: status.statusClass,
|
||||
orderState: subOrder.orderState,
|
||||
@@ -138,6 +139,7 @@ export const orderListService = {
|
||||
receiverAddress: addressView?.address || '',
|
||||
remark: mainOrder.remark || '',
|
||||
createTime: toDateTimeText(mainOrder.createTime || mainOrder.createAt),
|
||||
payTime: toDateTimeText(mainOrder.payTime),
|
||||
status: mainStatus.text,
|
||||
statusClass: mainStatus.statusClass,
|
||||
orderState: mainOrder.orderState,
|
||||
|
||||
Reference in New Issue
Block a user