完善接单平台上传与物品规则
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
import crypto from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
|
||||
import sharp from 'sharp'
|
||||
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import { createFileAsset, type FileAssetRow } from '../../repositories/file-asset-repo.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { getObjectStorage, normalizeObjectKey } from './object-storage.js'
|
||||
|
||||
const IMAGE_VARIANT_THUMB = 'thumb'
|
||||
const IMAGE_VARIANT_MEDIUM = 'medium'
|
||||
const THUMB_MAX_SIDE = 480
|
||||
const MEDIUM_MAX_SIDE = 1280
|
||||
|
||||
const ALLOWED_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf'])
|
||||
|
||||
const IMAGE_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
||||
|
||||
export type UploadedFileDto = {
|
||||
objectKey: string
|
||||
url: string
|
||||
thumbnailUrl: string
|
||||
mediumUrl: string
|
||||
filename: string
|
||||
contentType: string
|
||||
size: number
|
||||
}
|
||||
|
||||
type UploadFileAssetInput = {
|
||||
file: Express.Multer.File | null | undefined
|
||||
scene: unknown
|
||||
uploaderType: string
|
||||
uploaderId: string
|
||||
}
|
||||
|
||||
export async function uploadFileAsset(input: UploadFileAssetInput) {
|
||||
const file = input.file
|
||||
if (!file?.buffer?.length) {
|
||||
throw createHttpError('请选择要上传的文件', {
|
||||
statusCode: 400,
|
||||
errorCode: 'upload_file_required',
|
||||
})
|
||||
}
|
||||
|
||||
const maxUploadSize = getMaxUploadSizeBytes()
|
||||
if (file.buffer.length > maxUploadSize) {
|
||||
throw createHttpError(`文件不能超过 ${runtimeConfig.storage.maxUploadSizeMb}MB`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'upload_file_too_large',
|
||||
})
|
||||
}
|
||||
|
||||
const contentType = normalizeContentType(file.mimetype, file.buffer)
|
||||
if (!ALLOWED_CONTENT_TYPES.has(contentType)) {
|
||||
throw createHttpError('文件不符合规则,仅支持 JPG、PNG、WebP 或 PDF', {
|
||||
statusCode: 400,
|
||||
errorCode: 'upload_file_type_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const scene = normalizeScene(input.scene)
|
||||
const objectKey = createObjectKey(scene, file.originalname, contentType)
|
||||
const storage = getObjectStorage()
|
||||
await storage.putObject({
|
||||
key: objectKey,
|
||||
content: file.buffer,
|
||||
contentType,
|
||||
metadata: {
|
||||
'original-filename': file.originalname,
|
||||
},
|
||||
})
|
||||
|
||||
const variants = await generateImageVariants(objectKey, file.buffer, contentType)
|
||||
let thumbnailObjectKey = ''
|
||||
let mediumObjectKey = ''
|
||||
|
||||
for (const variant of variants) {
|
||||
try {
|
||||
await storage.putObject({
|
||||
key: variant.key,
|
||||
content: variant.content,
|
||||
contentType: variant.contentType,
|
||||
metadata: {
|
||||
'source-object': objectKey,
|
||||
},
|
||||
})
|
||||
if (variant.name === IMAGE_VARIANT_THUMB) {
|
||||
thumbnailObjectKey = variant.key
|
||||
}
|
||||
if (variant.name === IMAGE_VARIANT_MEDIUM) {
|
||||
mediumObjectKey = variant.key
|
||||
}
|
||||
} catch {
|
||||
// 缩略图失败不阻断原图上传;预览接口会回退到原图。
|
||||
}
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const fileUrl = buildFileUrl(objectKey)
|
||||
const thumbnailUrl = thumbnailObjectKey ? buildFileUrl(thumbnailObjectKey) : fileUrl
|
||||
const mediumUrl = mediumObjectKey ? buildFileUrl(mediumObjectKey) : fileUrl
|
||||
const asset = await createFileAsset({
|
||||
objectKey,
|
||||
scene,
|
||||
originalFilename: file.originalname,
|
||||
contentType,
|
||||
sizeBytes: file.buffer.length,
|
||||
thumbnailObjectKey,
|
||||
mediumObjectKey,
|
||||
url: fileUrl,
|
||||
thumbnailUrl,
|
||||
mediumUrl,
|
||||
uploaderType: String(input.uploaderType || '').trim(),
|
||||
uploaderId: String(input.uploaderId || '').trim(),
|
||||
metadataJson: JSON.stringify({
|
||||
originalMimeType: file.mimetype,
|
||||
originalSize: file.size,
|
||||
}),
|
||||
now,
|
||||
})
|
||||
|
||||
return {
|
||||
file: mapUploadedFile(asset, {
|
||||
objectKey,
|
||||
fileUrl,
|
||||
thumbnailUrl,
|
||||
mediumUrl,
|
||||
filename: file.originalname,
|
||||
contentType,
|
||||
size: file.buffer.length,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStoredFileObject(key: unknown, variant: unknown = '') {
|
||||
const objectKey = normalizeObjectKey(key)
|
||||
const variantName = normalizeVariant(variant)
|
||||
const candidates = variantName ? imageVariantFallbackKeys(objectKey, variantName) : [objectKey]
|
||||
let lastError: unknown = null
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
return await getObjectStorage().getObject(candidate)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
|
||||
throw createHttpError('文件不存在或暂不可访问', {
|
||||
statusCode: 404,
|
||||
errorCode: 'file_not_found',
|
||||
cause: lastError,
|
||||
})
|
||||
}
|
||||
|
||||
export function getMaxUploadSizeBytes() {
|
||||
return Math.max(1, Number(runtimeConfig.storage.maxUploadSizeMb || 10)) * 1024 * 1024
|
||||
}
|
||||
|
||||
export function buildFileUrl(objectKey: string) {
|
||||
return `/api/v1/files/object?key=${encodeURIComponent(objectKey)}`
|
||||
}
|
||||
|
||||
export function imageVariantKey(objectKey: string, variant: string) {
|
||||
const ext = path.extname(objectKey)
|
||||
const base = ext ? objectKey.slice(0, -ext.length) : objectKey
|
||||
return `${base}.${variant}.jpg`
|
||||
}
|
||||
|
||||
function mapUploadedFile(
|
||||
asset: FileAssetRow | null,
|
||||
fallback: {
|
||||
objectKey: string
|
||||
fileUrl: string
|
||||
thumbnailUrl: string
|
||||
mediumUrl: string
|
||||
filename: string
|
||||
contentType: string
|
||||
size: number
|
||||
},
|
||||
): UploadedFileDto {
|
||||
return {
|
||||
objectKey: asset?.object_key || fallback.objectKey,
|
||||
url: asset?.url || fallback.fileUrl,
|
||||
thumbnailUrl: asset?.thumbnail_url || fallback.thumbnailUrl,
|
||||
mediumUrl: asset?.medium_url || fallback.mediumUrl,
|
||||
filename: asset?.original_filename || fallback.filename,
|
||||
contentType: asset?.content_type || fallback.contentType,
|
||||
size: Number(asset?.size_bytes || fallback.size || 0),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContentType(mimeType: unknown, data: Buffer) {
|
||||
const detected = detectContentType(data)
|
||||
if (detected) {
|
||||
return detected
|
||||
}
|
||||
|
||||
return (
|
||||
String(mimeType || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(';')[0] || 'application/octet-stream'
|
||||
)
|
||||
}
|
||||
|
||||
function detectContentType(data: Buffer) {
|
||||
if (data.length >= 4 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
|
||||
return 'image/jpeg'
|
||||
}
|
||||
if (
|
||||
data.length >= 8 &&
|
||||
data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||
) {
|
||||
return 'image/png'
|
||||
}
|
||||
if (
|
||||
data.length >= 12 &&
|
||||
data.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
data.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return 'image/webp'
|
||||
}
|
||||
if (data.length >= 4 && data.subarray(0, 4).toString('ascii') === '%PDF') {
|
||||
return 'application/pdf'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeScene(value: unknown) {
|
||||
const scene = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return scene || 'misc'
|
||||
}
|
||||
|
||||
function createObjectKey(scene: string, filename: string, contentType: string) {
|
||||
const now = new Date()
|
||||
const token = crypto.randomBytes(12).toString('hex')
|
||||
const ext = extensionForContentType(contentType) || path.extname(filename).toLowerCase() || '.bin'
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
return `${scene}/${now.getFullYear()}/${month}/${day}/${token}${ext}`
|
||||
}
|
||||
|
||||
function extensionForContentType(contentType: string) {
|
||||
if (contentType === 'image/jpeg') return '.jpg'
|
||||
if (contentType === 'image/png') return '.png'
|
||||
if (contentType === 'image/webp') return '.webp'
|
||||
if (contentType === 'application/pdf') return '.pdf'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function generateImageVariants(objectKey: string, data: Buffer, contentType: string) {
|
||||
if (!IMAGE_CONTENT_TYPES.has(contentType)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const configs = [
|
||||
{ name: IMAGE_VARIANT_THUMB, maxSide: THUMB_MAX_SIDE, quality: 76 },
|
||||
{ name: IMAGE_VARIANT_MEDIUM, maxSide: MEDIUM_MAX_SIDE, quality: 82 },
|
||||
]
|
||||
const variants: Array<{ name: string; key: string; content: Buffer; contentType: string }> = []
|
||||
|
||||
for (const config of configs) {
|
||||
try {
|
||||
const content = await sharp(data)
|
||||
.rotate()
|
||||
.resize({
|
||||
width: config.maxSide,
|
||||
height: config.maxSide,
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.flatten({ background: '#ffffff' })
|
||||
.jpeg({ quality: config.quality, mozjpeg: true })
|
||||
.toBuffer()
|
||||
variants.push({
|
||||
name: config.name,
|
||||
key: imageVariantKey(objectKey, config.name),
|
||||
content,
|
||||
contentType: 'image/jpeg',
|
||||
})
|
||||
} catch {
|
||||
return variants
|
||||
}
|
||||
}
|
||||
|
||||
return variants
|
||||
}
|
||||
|
||||
function imageVariantFallbackKeys(objectKey: string, variant: string) {
|
||||
const keys = [imageVariantKey(objectKey, variant), objectKey]
|
||||
return [...new Set(keys)]
|
||||
}
|
||||
|
||||
function normalizeVariant(value: unknown) {
|
||||
const variant = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (variant === IMAGE_VARIANT_THUMB || variant === IMAGE_VARIANT_MEDIUM) {
|
||||
return variant
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
import * as Minio from 'minio'
|
||||
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
|
||||
type ObjectStorageConfig = {
|
||||
endpoint: string
|
||||
bucket: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
region: string
|
||||
}
|
||||
|
||||
type StoredObject = {
|
||||
reader: Readable
|
||||
contentType: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export class ObjectStorage {
|
||||
private readonly client: Minio.Client
|
||||
private readonly bucket: string
|
||||
private readonly region: string
|
||||
private bucketReady = false
|
||||
|
||||
constructor(config: ObjectStorageConfig) {
|
||||
const endpoint = normalizeStorageEndpoint(config.endpoint)
|
||||
this.bucket = String(config.bucket || '').trim()
|
||||
this.region = String(config.region || 'us-east-1').trim() || 'us-east-1'
|
||||
this.client = new Minio.Client({
|
||||
endPoint: endpoint.endPoint,
|
||||
port: endpoint.port,
|
||||
useSSL: endpoint.useSSL,
|
||||
accessKey: String(config.accessKeyId || '').trim(),
|
||||
secretKey: String(config.secretAccessKey || '').trim(),
|
||||
})
|
||||
}
|
||||
|
||||
async putObject(input: {
|
||||
key: string
|
||||
content: Buffer
|
||||
contentType: string
|
||||
metadata?: Record<string, string>
|
||||
}): Promise<void> {
|
||||
await this.ensureBucketReady()
|
||||
await this.client.putObject(this.bucket, input.key, input.content, input.content.length, {
|
||||
'Content-Type': input.contentType,
|
||||
...sanitizeObjectMetadata(input.metadata),
|
||||
})
|
||||
}
|
||||
|
||||
async getObject(key: string): Promise<StoredObject> {
|
||||
await this.ensureBucketReady()
|
||||
const objectKey = normalizeObjectKey(key)
|
||||
const stat = await this.client.statObject(this.bucket, objectKey)
|
||||
const reader = await this.client.getObject(this.bucket, objectKey)
|
||||
const metadata = stat.metaData || {}
|
||||
const contentType = String(
|
||||
metadata['content-type'] ||
|
||||
metadata['Content-Type'] ||
|
||||
metadata.contentType ||
|
||||
'application/octet-stream',
|
||||
)
|
||||
|
||||
return {
|
||||
reader,
|
||||
contentType,
|
||||
size: Number(stat.size || 0),
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureBucketReady(): Promise<void> {
|
||||
if (this.bucketReady) {
|
||||
return
|
||||
}
|
||||
|
||||
const exists = await this.client.bucketExists(this.bucket)
|
||||
if (!exists) {
|
||||
await this.client.makeBucket(this.bucket, this.region)
|
||||
}
|
||||
this.bucketReady = true
|
||||
}
|
||||
}
|
||||
|
||||
let storageInstance: ObjectStorage | null = null
|
||||
|
||||
export function getObjectStorage(): ObjectStorage {
|
||||
if (!storageInstance) {
|
||||
storageInstance = new ObjectStorage({
|
||||
endpoint: runtimeConfig.storage.endpoint,
|
||||
bucket: runtimeConfig.storage.bucket,
|
||||
accessKeyId: runtimeConfig.storage.accessKeyId,
|
||||
secretAccessKey: runtimeConfig.storage.secretAccessKey,
|
||||
region: runtimeConfig.storage.region,
|
||||
})
|
||||
}
|
||||
|
||||
return storageInstance
|
||||
}
|
||||
|
||||
export function normalizeStorageEndpoint(rawEndpoint: unknown) {
|
||||
const endpoint = String(rawEndpoint || '').trim()
|
||||
const parsed = new URL(/^https?:\/\//i.test(endpoint) ? endpoint : `http://${endpoint}`)
|
||||
const port = parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80
|
||||
|
||||
return {
|
||||
endPoint: parsed.hostname,
|
||||
port,
|
||||
useSSL: parsed.protocol === 'https:',
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeObjectKey(rawKey: unknown): string {
|
||||
const key = String(rawKey || '')
|
||||
.trim()
|
||||
.replace(/^\/+/, '')
|
||||
if (!key || key.includes('..') || key.includes('\\')) {
|
||||
throw createHttpError('文件 key 不正确', {
|
||||
statusCode: 400,
|
||||
errorCode: 'file_key_invalid',
|
||||
})
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
function sanitizeObjectMetadata(metadata: Record<string, string> | undefined) {
|
||||
if (!metadata) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(metadata)
|
||||
.map(([key, value]) => [String(key || '').trim(), encodeURIComponent(String(value || ''))])
|
||||
.filter(([key]) => Boolean(key)),
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveOrderFulfillmentReadiness,
|
||||
syncOrderFulfillmentAttachments,
|
||||
} from '../fulfillment/order-fulfillment-readiness-service.js'
|
||||
import { syncWorkerOrdersForSourceOrder } from '../worker-platform/worker-platform-service.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { logIntegration } from '../../utils/logger.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
@@ -61,13 +62,15 @@ type UpsertOrderIgnoredResult = {
|
||||
tasks: []
|
||||
}
|
||||
|
||||
type UpsertOrderResult = {
|
||||
ignored?: false
|
||||
ignoreReason?: string
|
||||
order: OrderRow
|
||||
orderItems: OrderItemRow[]
|
||||
tasks: TaskRow[]
|
||||
} | UpsertOrderIgnoredResult
|
||||
type UpsertOrderResult =
|
||||
| {
|
||||
ignored?: false
|
||||
ignoreReason?: string
|
||||
order: OrderRow
|
||||
orderItems: OrderItemRow[]
|
||||
tasks: TaskRow[]
|
||||
}
|
||||
| UpsertOrderIgnoredResult
|
||||
|
||||
type OrderStateInput = {
|
||||
order_status?: string
|
||||
@@ -108,13 +111,17 @@ export async function upsertOrderFromSource(
|
||||
})
|
||||
|
||||
const resolvedItems = await Promise.all(
|
||||
event.items.map((item) => resolveOrderItemForFulfillment({
|
||||
provider: event.provider,
|
||||
platform: event.platform,
|
||||
item,
|
||||
})),
|
||||
event.items.map((item) =>
|
||||
resolveOrderItemForFulfillment({
|
||||
provider: event.provider,
|
||||
platform: event.platform,
|
||||
item,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const configuredItems = (resolvedItems as FulfillmentOrderItem[]).filter(
|
||||
(item) => item.isConfigured,
|
||||
)
|
||||
const configuredItems = (resolvedItems as FulfillmentOrderItem[]).filter((item) => item.isConfigured)
|
||||
|
||||
if (configuredItems.length === 0) {
|
||||
logIntegration('[order-service]', `${sourceLabel} 订单已忽略:未命中任何已配置履约商品`, {
|
||||
@@ -156,16 +163,16 @@ export async function upsertOrderFromSource(
|
||||
|
||||
const order = existing
|
||||
? await updateOrder(existing.id, {
|
||||
...basePayload,
|
||||
...mergedPayload,
|
||||
updatedAt: now,
|
||||
})
|
||||
...basePayload,
|
||||
...mergedPayload,
|
||||
updatedAt: now,
|
||||
})
|
||||
: await createOrder({
|
||||
...basePayload,
|
||||
...mergedPayload,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
...basePayload,
|
||||
...mergedPayload,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
throw createHttpError('订单写入失败', {
|
||||
@@ -189,14 +196,19 @@ export async function upsertOrderFromSource(
|
||||
|
||||
const readiness = await resolveOrderFulfillmentReadiness(order)
|
||||
if (!readiness.allowed) {
|
||||
logIntegration('[order-service]', `${sourceLabel} 订单暂停履约:电子凭证发码回调未确认`, {
|
||||
orderId: order.id,
|
||||
provider: order.provider,
|
||||
platform: order.platform,
|
||||
platformOrderId: order.platform_order_id,
|
||||
voucherCount: readiness.voucherCount,
|
||||
reason: readiness.reason,
|
||||
}, { level: 'warn' })
|
||||
logIntegration(
|
||||
'[order-service]',
|
||||
`${sourceLabel} 订单暂停履约:电子凭证发码回调未确认`,
|
||||
{
|
||||
orderId: order.id,
|
||||
provider: order.provider,
|
||||
platform: order.platform,
|
||||
platformOrderId: order.platform_order_id,
|
||||
voucherCount: readiness.voucherCount,
|
||||
reason: readiness.reason,
|
||||
},
|
||||
{ level: 'warn' },
|
||||
)
|
||||
|
||||
return {
|
||||
ignoreReason: readiness.blockReason || 'fulfillment_not_ready',
|
||||
@@ -211,6 +223,10 @@ export async function upsertOrderFromSource(
|
||||
source: `${sourceLabel}_order_upsert`,
|
||||
now,
|
||||
})
|
||||
await syncWorkerOrdersForSourceOrder(order, orderItems, {
|
||||
source: `${sourceLabel}_order_upsert`,
|
||||
autoOnly: true,
|
||||
})
|
||||
|
||||
logIntegration('[order-service]', `${sourceLabel} 订单 upsert 完成`, {
|
||||
orderId: order.id,
|
||||
@@ -249,17 +265,23 @@ export function mergeSourceOrderState(
|
||||
existing: OrderStateInput | null | undefined,
|
||||
incoming: OrderStateInput | null | undefined,
|
||||
): OrderStateMergeResult {
|
||||
const orderStatus = chooseHigherPriorityStatus(
|
||||
normalizeStateValue(existing?.order_status ?? existing?.orderStatus),
|
||||
normalizeStateValue(incoming?.orderStatus ?? incoming?.order_status),
|
||||
ORDER_STATUS_PRIORITY,
|
||||
) || normalizeStateValue(incoming?.orderStatus ?? incoming?.order_status) || 'created'
|
||||
const orderStatus =
|
||||
chooseHigherPriorityStatus(
|
||||
normalizeStateValue(existing?.order_status ?? existing?.orderStatus),
|
||||
normalizeStateValue(incoming?.orderStatus ?? incoming?.order_status),
|
||||
ORDER_STATUS_PRIORITY,
|
||||
) ||
|
||||
normalizeStateValue(incoming?.orderStatus ?? incoming?.order_status) ||
|
||||
'created'
|
||||
|
||||
const payStatus = chooseHigherPriorityStatus(
|
||||
normalizeStateValue(existing?.pay_status ?? existing?.payStatus),
|
||||
normalizeStateValue(incoming?.payStatus ?? incoming?.pay_status),
|
||||
PAY_STATUS_PRIORITY,
|
||||
) || normalizeStateValue(incoming?.payStatus ?? incoming?.pay_status) || 'unpaid'
|
||||
const payStatus =
|
||||
chooseHigherPriorityStatus(
|
||||
normalizeStateValue(existing?.pay_status ?? existing?.payStatus),
|
||||
normalizeStateValue(incoming?.payStatus ?? incoming?.pay_status),
|
||||
PAY_STATUS_PRIORITY,
|
||||
) ||
|
||||
normalizeStateValue(incoming?.payStatus ?? incoming?.pay_status) ||
|
||||
'unpaid'
|
||||
|
||||
return {
|
||||
orderStatus,
|
||||
@@ -296,7 +318,9 @@ function resolveStatusPriority(value: string, priorityMap: StatusPriorityMap): n
|
||||
}
|
||||
|
||||
function normalizeStateValue(value: unknown): string {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function resolveMergedPaidAt({
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { query } from '../../../db/client.js'
|
||||
import { createOrder, findOrderByPlatformOrderId, getOrderById, updateOrder } from '../../../repositories/order-repo.js'
|
||||
import { listOrderItemsByOrderId, replaceOrderItems } from '../../../repositories/order-item-repo.js'
|
||||
import {
|
||||
createOrder,
|
||||
findOrderByPlatformOrderId,
|
||||
getOrderById,
|
||||
updateOrder,
|
||||
} from '../../../repositories/order-repo.js'
|
||||
import {
|
||||
listOrderItemsByOrderId,
|
||||
replaceOrderItems,
|
||||
} from '../../../repositories/order-item-repo.js'
|
||||
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
|
||||
import { upsertOrderFromSource } from '../../order/order-service.js'
|
||||
import { syncWorkerOrdersForSourceOrder } from '../../worker-platform/worker-platform-service.js'
|
||||
import { bindKuaishouIndustryVouchersToOrderTasks } from '../kuaishou-industry/voucher-binding-service.js'
|
||||
import { asJsonObject, type JsonObject } from '../../../types/json.js'
|
||||
import {
|
||||
OPEN_91_PLATFORM,
|
||||
OPEN_91_PROVIDER,
|
||||
} from '../../open-91/config.js'
|
||||
import { asJsonObject, type JsonObject } from '../../../types/json.js'
|
||||
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../../open-91/config.js'
|
||||
import { normalizeOpen91CreatePayload } from '../../open-91/payload.js'
|
||||
import { buildOpen91OutTradeNo } from '../../open-91/response.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
@@ -179,6 +185,10 @@ export async function upsertOpen91PendingOrder(payload: JsonObject = {}, config:
|
||||
source: 'open91_pending_order',
|
||||
now,
|
||||
})
|
||||
await syncWorkerOrdersForSourceOrder(order, orderItems, {
|
||||
source: 'open91_pending_order',
|
||||
autoOnly: true,
|
||||
})
|
||||
|
||||
return {
|
||||
order,
|
||||
@@ -243,14 +253,15 @@ export async function failOpen91Order(orderId: string | number, reason = '') {
|
||||
}
|
||||
}
|
||||
|
||||
export async function listOpen91Orders({ page = 1, pageSize = 20, status = 'pending_config' } = {}) {
|
||||
export async function listOpen91Orders({
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
status = 'pending_config',
|
||||
} = {}) {
|
||||
const normalizedPage = Math.max(1, Number(page) || 1)
|
||||
const normalizedPageSize = Math.min(100, Math.max(1, Number(pageSize) || 20))
|
||||
const normalizedStatus = String(status || 'pending_config').trim()
|
||||
const filters = [
|
||||
'o.provider = $1',
|
||||
'o.platform = $2',
|
||||
]
|
||||
const filters = ['o.provider = $1', 'o.platform = $2']
|
||||
const params: Array<string | number> = [OPEN_91_PROVIDER, OPEN_91_PLATFORM]
|
||||
|
||||
if (normalizedStatus && normalizedStatus !== 'all') {
|
||||
@@ -259,7 +270,10 @@ export async function listOpen91Orders({ page = 1, pageSize = 20, status = 'pend
|
||||
}
|
||||
|
||||
const whereClause = `WHERE ${filters.join(' AND ')}`
|
||||
const totalResult = await query(`SELECT COUNT(*)::int AS total FROM orders o ${whereClause}`, params)
|
||||
const totalResult = await query(
|
||||
`SELECT COUNT(*)::int AS total FROM orders o ${whereClause}`,
|
||||
params,
|
||||
)
|
||||
|
||||
params.push(normalizedPageSize)
|
||||
params.push((normalizedPage - 1) * normalizedPageSize)
|
||||
@@ -320,7 +334,9 @@ function buildOpen91SourceEventFromOrder(order: OrderRow, items: OrderItemRow[]
|
||||
const normalizedItems = Array.isArray(items) ? items : []
|
||||
const eventItems = normalizedItems.map((item: OrderItemRow) => {
|
||||
const snapshot = parseJsonObject(item.item_snapshot_json)
|
||||
const productNo = String(snapshot.productNo || snapshot.externalSkuCode || item.sku_code || '').trim()
|
||||
const productNo = String(
|
||||
snapshot.productNo || snapshot.externalSkuCode || item.sku_code || '',
|
||||
).trim()
|
||||
|
||||
return {
|
||||
itemId: productNo,
|
||||
@@ -339,9 +355,7 @@ function buildOpen91SourceEventFromOrder(order: OrderRow, items: OrderItemRow[]
|
||||
provider: order.provider,
|
||||
platform: order.platform,
|
||||
shopId: OPEN_91_PROVIDER,
|
||||
shopName: String(order.shop_id || '').trim() === OPEN_91_PROVIDER
|
||||
? order.shop_name
|
||||
: '91卡券',
|
||||
shopName: String(order.shop_id || '').trim() === OPEN_91_PROVIDER ? order.shop_name : '91卡券',
|
||||
platformOrderId: order.platform_order_id,
|
||||
orderStatus: 'paid',
|
||||
payStatus: 'paid',
|
||||
@@ -374,7 +388,9 @@ function mapOpen91AdminOrderRow(row: JsonObject) {
|
||||
payStatus: String(row.pay_status || '').trim(),
|
||||
shopId: String(row.shop_id || '').trim(),
|
||||
shopName: String(row.shop_name || '').trim(),
|
||||
productNo: String(firstSnapshot.productNo || firstSnapshot.externalSkuCode || firstItem.skuCode || '').trim(),
|
||||
productNo: String(
|
||||
firstSnapshot.productNo || firstSnapshot.externalSkuCode || firstItem.skuCode || '',
|
||||
).trim(),
|
||||
productName: String(firstSnapshot.externalSkuName || firstItem.skuName || '').trim(),
|
||||
buyNum: Number(firstItem.quantity || 0),
|
||||
taskCount: Number(row.task_count || 0),
|
||||
|
||||
@@ -2,6 +2,8 @@ import crypto from 'node:crypto'
|
||||
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
|
||||
import { findLatestOrderByAnyPlatformOrderId, getOrderById } from '../../repositories/order-repo.js'
|
||||
import {
|
||||
acceptWorkOrderAndSettle,
|
||||
addWorkerWalletCredit,
|
||||
@@ -12,32 +14,35 @@ import {
|
||||
findPendingMaterialWorkOrderByPlatformOrderId,
|
||||
getWorkCategoryByKey,
|
||||
getWorkOrderById,
|
||||
getWorkOrderByOrderItemId,
|
||||
getWorkerLevelById,
|
||||
getWorkerLevelByKey,
|
||||
getWorkerUserById,
|
||||
getWorkerUserByUsername,
|
||||
listAllWorkCategories,
|
||||
grabWorkOrder,
|
||||
listWorkCategories,
|
||||
listWorkOrders,
|
||||
listWorkProductRules,
|
||||
listWorkerLevels,
|
||||
listWorkerUsers,
|
||||
updateWorkOrder,
|
||||
updateWorkerUser,
|
||||
upsertWorkCategory,
|
||||
upsertWorkProductRule,
|
||||
upsertWorkerLevel,
|
||||
type WorkCategoryRow,
|
||||
type WorkOrderRow,
|
||||
type WorkProductRuleRow,
|
||||
type WorkerLevelRow,
|
||||
type WorkerUserRow,
|
||||
} from '../../repositories/worker-platform-repo.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { randomId } from '../../utils/random.js'
|
||||
import { nowIso, addHours } from '../../utils/time.js'
|
||||
import {
|
||||
normalizePage,
|
||||
normalizePageSize,
|
||||
safeParseJson,
|
||||
} from '../admin/admin-query-utils.js'
|
||||
import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js'
|
||||
|
||||
export type WorkerSession = {
|
||||
sessionId: string
|
||||
@@ -340,7 +345,9 @@ export async function submitWorkerOrderAcceptance(
|
||||
errorCode: 'work_order_owner_required',
|
||||
})
|
||||
}
|
||||
if (![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes(workOrder.status as never)) {
|
||||
if (
|
||||
![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes(workOrder.status as never)
|
||||
) {
|
||||
throw createHttpError('当前状态不能提交验收', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_submit_status_invalid',
|
||||
@@ -348,9 +355,21 @@ export async function submitWorkerOrderAcceptance(
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const files = normalizeUploadedFiles(payload.files)
|
||||
const imageUrls = [
|
||||
...files.map((file) => file.url || file.mediumUrl || file.thumbnailUrl).filter(Boolean),
|
||||
...normalizeStringArray(payload.imageUrls),
|
||||
]
|
||||
if (imageUrls.length === 0) {
|
||||
throw createHttpError('请上传验收图片', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_acceptance_image_required',
|
||||
})
|
||||
}
|
||||
const acceptance = {
|
||||
note: String(payload.note || '').trim(),
|
||||
imageUrls: normalizeStringArray(payload.imageUrls),
|
||||
files,
|
||||
imageUrls: [...new Set(imageUrls)],
|
||||
submittedAt: now,
|
||||
}
|
||||
const updated = await updateWorkOrder(workOrder.id, {
|
||||
@@ -369,7 +388,12 @@ export async function submitWorkerOrderAcceptance(
|
||||
payloadJson: JSON.stringify(acceptance),
|
||||
now,
|
||||
})
|
||||
return { order: mapWorkOrderForWorker(updated || workOrder, resolveWorkerPermissions(await getRequiredWorker(session.workerId))) }
|
||||
return {
|
||||
order: mapWorkOrderForWorker(
|
||||
updated || workOrder,
|
||||
resolveWorkerPermissions(await getRequiredWorker(session.workerId)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectLookupWorkOrder(payload: JsonObject = {}) {
|
||||
@@ -411,7 +435,9 @@ export async function collectSubmitWorkOrder(payload: JsonObject = {}) {
|
||||
|
||||
const fields = resolveRequirementFields(workOrder)
|
||||
const submittedFields = normalizeSubmittedFields(payload.fields || payload.material || payload)
|
||||
const complete = fields.every((field) => !field.required || String(submittedFields[field.key] || '').trim())
|
||||
const complete = fields.every(
|
||||
(field) => !field.required || String(submittedFields[field.key] || '').trim(),
|
||||
)
|
||||
const now = nowIso()
|
||||
const material = {
|
||||
...safeParseJson(workOrder.material_json),
|
||||
@@ -448,12 +474,37 @@ export async function listAdminWorkerLevels() {
|
||||
return { items: (await listWorkerLevels()).map(mapWorkerLevel) }
|
||||
}
|
||||
|
||||
export async function listAdminWorkCategories() {
|
||||
await ensureWorkerPlatformDefaults()
|
||||
return { items: (await listAllWorkCategories()).map(mapWorkCategory) }
|
||||
}
|
||||
|
||||
export async function saveAdminWorkCategory(payload: JsonObject = {}) {
|
||||
const now = nowIso()
|
||||
const categoryKey = normalizeSlugKey(
|
||||
payload.categoryKey || payload.category_key || payload.name,
|
||||
DEFAULT_CATEGORY_KEY,
|
||||
)
|
||||
const name = String(payload.name || '').trim() || '默认分类'
|
||||
const category = await upsertWorkCategory({
|
||||
categoryKey,
|
||||
name,
|
||||
sortOrder: normalizeInteger(payload.sortOrder, 100),
|
||||
status: normalizeEnabledStatus(payload.status),
|
||||
now,
|
||||
})
|
||||
return { category: mapWorkCategory(category) }
|
||||
}
|
||||
|
||||
export async function saveAdminWorkerLevel(payload: JsonObject = {}) {
|
||||
const now = nowIso()
|
||||
const levelKey = String(payload.levelKey || payload.level_key || '').trim() || DEFAULT_LEVEL_KEY
|
||||
const name = String(payload.name || '').trim() || '普通接单员'
|
||||
const permissions = {
|
||||
depositFreeAmount: normalizeAmountFen(payload.depositFreeAmount ?? payload.depositFreeAmountYuan, 0),
|
||||
depositFreeAmount: normalizeAmountFen(
|
||||
payload.depositFreeAmount ?? payload.depositFreeAmountYuan,
|
||||
0,
|
||||
),
|
||||
maxActiveOrders: normalizePositiveInteger(payload.maxActiveOrders, 3),
|
||||
}
|
||||
const level = await upsertWorkerLevel({
|
||||
@@ -467,6 +518,78 @@ export async function saveAdminWorkerLevel(payload: JsonObject = {}) {
|
||||
return { level: mapWorkerLevel(level) }
|
||||
}
|
||||
|
||||
export async function listAdminWorkProductRules(query: JsonObject = {}) {
|
||||
await ensureWorkerPlatformDefaults()
|
||||
const enabledValue = String(query.enabled ?? '').trim()
|
||||
const enabled = enabledValue
|
||||
? ['true', '1', 'enabled', 'active'].includes(enabledValue.toLowerCase())
|
||||
: null
|
||||
const items = await listWorkProductRules({
|
||||
enabled,
|
||||
keyword: String(query.keyword || '').trim(),
|
||||
})
|
||||
return { items: items.map(mapWorkProductRule) }
|
||||
}
|
||||
|
||||
export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||
const defaults = await ensureWorkerPlatformDefaults()
|
||||
const productName = String(
|
||||
payload.productName || payload.product_name || payload.skuName || '',
|
||||
).trim()
|
||||
const skuCode = String(payload.skuCode || payload.sku_code || '').trim()
|
||||
if (!productName && !skuCode) {
|
||||
throw createHttpError('请填写商品名或 SKU', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_product_rule_target_required',
|
||||
})
|
||||
}
|
||||
|
||||
const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 0)
|
||||
if (rewardAmount <= 0) {
|
||||
throw createHttpError('接单金额必须大于 0', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_product_rule_reward_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const depositThresholdAmount = normalizeAmountFen(
|
||||
payload.depositThresholdAmount ?? payload.depositThresholdAmountYuan,
|
||||
DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
|
||||
)
|
||||
const requiredDepositAmount =
|
||||
payload.requiredDepositAmount === undefined && payload.requiredDepositAmountYuan === undefined
|
||||
? rewardAmount > depositThresholdAmount
|
||||
? rewardAmount
|
||||
: 0
|
||||
: normalizeAmountFen(payload.requiredDepositAmount ?? payload.requiredDepositAmountYuan, 0)
|
||||
const fields = normalizeRequirementFieldsFromPayload(payload)
|
||||
const rule = await upsertWorkProductRule({
|
||||
ruleKey: normalizeSlugKey(
|
||||
payload.ruleKey || payload.rule_key || skuCode || productName,
|
||||
`rule-${Date.now()}`,
|
||||
),
|
||||
provider: String(payload.provider || '').trim(),
|
||||
platform: String(payload.platform || '').trim(),
|
||||
shopId: String(payload.shopId || payload.shop_id || '').trim(),
|
||||
skuCode,
|
||||
productName,
|
||||
matchType: normalizeMatchType(payload.matchType || payload.match_type),
|
||||
categoryId:
|
||||
normalizeOptionalId(payload.categoryId || payload.category_id) ||
|
||||
defaults.category?.id ||
|
||||
null,
|
||||
enabled: normalizeBoolean(payload.enabled, true),
|
||||
autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, false),
|
||||
rewardAmount,
|
||||
requiredDepositAmount,
|
||||
depositThresholdAmount,
|
||||
requirementJson: JSON.stringify({ fields }),
|
||||
sortOrder: normalizeInteger(payload.sortOrder, 100),
|
||||
now: nowIso(),
|
||||
})
|
||||
return { rule: mapWorkProductRule(rule) }
|
||||
}
|
||||
|
||||
export async function listAdminWorkerUsers(query: JsonObject = {}) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
@@ -485,7 +608,11 @@ export async function listAdminWorkerUsers(query: JsonObject = {}) {
|
||||
export async function reviewAdminWorkerUser(workerId: number | string, payload: JsonObject = {}) {
|
||||
const worker = await getRequiredWorker(workerId)
|
||||
const status = normalizeReviewStatus(payload.status)
|
||||
const levelId = normalizeOptionalId(payload.levelId) || worker.level_id || (await ensureWorkerPlatformDefaults()).level?.id || null
|
||||
const levelId =
|
||||
normalizeOptionalId(payload.levelId) ||
|
||||
worker.level_id ||
|
||||
(await ensureWorkerPlatformDefaults()).level?.id ||
|
||||
null
|
||||
const now = nowIso()
|
||||
const updated = await updateWorkerUser(worker.id, {
|
||||
status,
|
||||
@@ -532,6 +659,66 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitAdminWorkOrderMaterial(
|
||||
workOrderId: number | string,
|
||||
payload: JsonObject = {},
|
||||
) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
if (
|
||||
![WORK_ORDER_STATUS.PENDING_MATERIAL, WORK_ORDER_STATUS.UNASSIGNED].includes(
|
||||
workOrder.status as never,
|
||||
)
|
||||
) {
|
||||
throw createHttpError('当前状态不能补充资料', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_material_status_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const fields = resolveRequirementFields(workOrder)
|
||||
const submittedFields = normalizeSubmittedFields(payload.fields || payload.material || payload)
|
||||
const complete = fields.every(
|
||||
(field) => !field.required || String(submittedFields[field.key] || '').trim(),
|
||||
)
|
||||
const now = nowIso()
|
||||
const material = {
|
||||
...safeParseJson(workOrder.material_json),
|
||||
collect: {
|
||||
fields: submittedFields,
|
||||
submittedAt: now,
|
||||
complete,
|
||||
source: 'admin',
|
||||
},
|
||||
}
|
||||
const nextStatus = complete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL
|
||||
const updated = await updateWorkOrder(workOrder.id, {
|
||||
status: nextStatus,
|
||||
material_json: material,
|
||||
updated_at: now,
|
||||
})
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: workOrder.id,
|
||||
actorType: 'admin',
|
||||
actorId: '',
|
||||
eventType: 'material_completed',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: nextStatus,
|
||||
payloadJson: JSON.stringify({ fields: submittedFields, complete }),
|
||||
now,
|
||||
})
|
||||
return { order: mapWorkOrderAdmin(updated || workOrder), complete }
|
||||
}
|
||||
|
||||
export async function syncAdminWorkerOrdersFromSource(payload: JsonObject = {}) {
|
||||
const order = await resolveSourceOrder(payload)
|
||||
const orderItems = await listOrderItemsByOrderId(order.id)
|
||||
const result = await syncWorkerOrdersForSourceOrder(order, orderItems, {
|
||||
source: 'admin_manual_sync',
|
||||
autoOnly: false,
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
export async function createAdminMockWorkOrder(payload: JsonObject = {}) {
|
||||
const defaults = await ensureWorkerPlatformDefaults()
|
||||
const now = nowIso()
|
||||
@@ -540,12 +727,17 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) {
|
||||
payload.depositThresholdAmount ?? payload.depositThresholdAmountYuan,
|
||||
DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
|
||||
)
|
||||
const requiredDepositAmount = payload.requiredDepositAmount === undefined
|
||||
? (rewardAmount > depositThresholdAmount ? rewardAmount : 0)
|
||||
: normalizeAmountFen(payload.requiredDepositAmount, 0)
|
||||
const requiredDepositAmount =
|
||||
payload.requiredDepositAmount === undefined
|
||||
? rewardAmount > depositThresholdAmount
|
||||
? rewardAmount
|
||||
: 0
|
||||
: normalizeAmountFen(payload.requiredDepositAmount, 0)
|
||||
const materialComplete = payload.materialComplete === true || payload.materialComplete === 'true'
|
||||
const fields = normalizeRequirementFields(payload.fields)
|
||||
const platformOrderId = String(payload.platformOrderId || payload.orderNo || `MOCKWORK${Date.now()}`).trim()
|
||||
const platformOrderId = String(
|
||||
payload.platformOrderId || payload.orderNo || `MOCKWORK${Date.now()}`,
|
||||
).trim()
|
||||
const productName = String(payload.productName || '指挥官秘钥1个').trim()
|
||||
const material = materialComplete
|
||||
? {
|
||||
@@ -617,9 +809,16 @@ export async function publishAdminWorkOrder(workOrderId: number | string) {
|
||||
return { order: mapWorkOrderAdmin(updated || workOrder) }
|
||||
}
|
||||
|
||||
export async function markAdminWorkOrderProblem(workOrderId: number | string, payload: JsonObject = {}) {
|
||||
export async function markAdminWorkOrderProblem(
|
||||
workOrderId: number | string,
|
||||
payload: JsonObject = {},
|
||||
) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
if (![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PENDING_ACCEPTANCE].includes(workOrder.status as never)) {
|
||||
if (
|
||||
![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PENDING_ACCEPTANCE].includes(
|
||||
workOrder.status as never,
|
||||
)
|
||||
) {
|
||||
throw createHttpError('当前状态不能标记问题单', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_problem_status_invalid',
|
||||
@@ -679,6 +878,96 @@ export async function getAdminWorkerPlatformSummary() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncWorkerOrdersForSourceOrder(
|
||||
order: OrderRow,
|
||||
orderItems: OrderItemRow[],
|
||||
options: { source?: string; autoOnly?: boolean } = {},
|
||||
) {
|
||||
const rules = await listWorkProductRules({ enabled: true })
|
||||
const created: WorkOrderRow[] = []
|
||||
const skipped: Array<{ orderItemId: number; reason: string }> = []
|
||||
|
||||
for (const item of orderItems) {
|
||||
const rule = resolveMatchingProductRule(order, item, rules)
|
||||
if (!rule) {
|
||||
skipped.push({ orderItemId: Number(item.id), reason: 'rule_not_matched' })
|
||||
continue
|
||||
}
|
||||
if (options.autoOnly && !rule.auto_create) {
|
||||
skipped.push({ orderItemId: Number(item.id), reason: 'auto_create_disabled' })
|
||||
continue
|
||||
}
|
||||
if (await getWorkOrderByOrderItemId(item.id)) {
|
||||
skipped.push({ orderItemId: Number(item.id), reason: 'already_exists' })
|
||||
continue
|
||||
}
|
||||
|
||||
const fields = normalizeRequirementFields(safeParseJson(rule.requirement_json).fields)
|
||||
const rewardAmount = Number(rule.reward_amount || 0)
|
||||
const depositThresholdAmount = Number(
|
||||
rule.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
|
||||
)
|
||||
const requiredDepositAmount =
|
||||
Number(rule.required_deposit_amount || 0) ||
|
||||
(rewardAmount > depositThresholdAmount ? rewardAmount : 0)
|
||||
const now = nowIso()
|
||||
const materialComplete = fields.every((field) => !field.required)
|
||||
const workOrder = await createWorkOrder({
|
||||
workOrderNo: randomId('WO'),
|
||||
orderId: Number(order.id),
|
||||
orderItemId: Number(item.id),
|
||||
taskId: null,
|
||||
platformOrderId: order.platform_order_id,
|
||||
productName: item.sku_name || item.sku_code,
|
||||
categoryId: rule.category_id,
|
||||
status: materialComplete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL,
|
||||
rewardAmount,
|
||||
requiredDepositAmount,
|
||||
depositThresholdAmount,
|
||||
materialJson: JSON.stringify({
|
||||
source: {
|
||||
orderId: Number(order.id),
|
||||
orderItemId: Number(item.id),
|
||||
provider: order.provider,
|
||||
platform: order.platform,
|
||||
shopId: order.shop_id,
|
||||
skuCode: item.sku_code,
|
||||
skuName: item.sku_name,
|
||||
quantity: Number(item.quantity || 1),
|
||||
syncSource: options.source || 'source_order',
|
||||
},
|
||||
}),
|
||||
requirementJson: JSON.stringify({ fields }),
|
||||
now,
|
||||
})
|
||||
|
||||
if (workOrder) {
|
||||
created.push(workOrder)
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: workOrder.id,
|
||||
actorType: 'system',
|
||||
actorId: options.source || 'source_order',
|
||||
eventType: 'source_synced',
|
||||
fromStatus: '',
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: JSON.stringify({
|
||||
ruleKey: rule.rule_key,
|
||||
orderId: order.id,
|
||||
orderItemId: item.id,
|
||||
}),
|
||||
now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
created: created.map(mapWorkOrderAdmin),
|
||||
skipped,
|
||||
createdCount: created.length,
|
||||
skippedCount: skipped.length,
|
||||
}
|
||||
}
|
||||
|
||||
function createWorkerSession(worker: WorkerUserRow) {
|
||||
const issuedAt = nowIso()
|
||||
const expiresAt = addHours(issuedAt, Number(runtimeConfig.admin?.sessionTtlHours || 12))
|
||||
@@ -759,6 +1048,37 @@ async function getRequiredWorkOrder(workOrderId: number | string): Promise<WorkO
|
||||
return workOrder
|
||||
}
|
||||
|
||||
async function resolveSourceOrder(payload: JsonObject): Promise<OrderRow> {
|
||||
const orderId = normalizeOptionalId(payload.orderId || payload.sourceOrderId)
|
||||
if (orderId) {
|
||||
const order = await getOrderById(orderId)
|
||||
if (order) {
|
||||
return order
|
||||
}
|
||||
throw createHttpError('源订单不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'source_order_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const platformOrderId = String(payload.platformOrderId || payload.orderNo || '').trim()
|
||||
if (!platformOrderId) {
|
||||
throw createHttpError('请填写源订单号', {
|
||||
statusCode: 400,
|
||||
errorCode: 'source_order_no_required',
|
||||
})
|
||||
}
|
||||
|
||||
const order = await findLatestOrderByAnyPlatformOrderId(platformOrderId)
|
||||
if (!order) {
|
||||
throw createHttpError('未找到源订单,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'source_order_not_found',
|
||||
})
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
function resolveWorkerPermissions(worker: WorkerUserRow) {
|
||||
const permission = safeParseJson(worker.level_permission_json)
|
||||
return {
|
||||
@@ -767,8 +1087,14 @@ function resolveWorkerPermissions(worker: WorkerUserRow) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFreezeDepositAmount(workOrder: WorkOrderRow, permissions: { depositFreeAmount: number }) {
|
||||
return Math.max(0, Number(workOrder.required_deposit_amount || 0) - Number(permissions.depositFreeAmount || 0))
|
||||
function resolveFreezeDepositAmount(
|
||||
workOrder: WorkOrderRow,
|
||||
permissions: { depositFreeAmount: number },
|
||||
) {
|
||||
return Math.max(
|
||||
0,
|
||||
Number(workOrder.required_deposit_amount || 0) - Number(permissions.depositFreeAmount || 0),
|
||||
)
|
||||
}
|
||||
|
||||
function mapWorkerLevel(level: WorkerLevelRow | null | undefined) {
|
||||
@@ -787,6 +1113,49 @@ function mapWorkerLevel(level: WorkerLevelRow | null | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
function mapWorkCategory(category: WorkCategoryRow | null | undefined) {
|
||||
if (!category) return null
|
||||
return {
|
||||
categoryId: Number(category.id),
|
||||
categoryKey: category.category_key,
|
||||
name: category.name,
|
||||
sortOrder: Number(category.sort_order || 100),
|
||||
status: category.status,
|
||||
createdAt: category.created_at,
|
||||
updatedAt: category.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined) {
|
||||
if (!rule) return null
|
||||
const requirement = safeParseJson(rule.requirement_json)
|
||||
return {
|
||||
ruleId: Number(rule.id),
|
||||
ruleKey: rule.rule_key,
|
||||
provider: rule.provider || '',
|
||||
platform: rule.platform || '',
|
||||
shopId: rule.shop_id || '',
|
||||
skuCode: rule.sku_code || '',
|
||||
productName: rule.product_name || '',
|
||||
matchType: rule.match_type || 'contains',
|
||||
categoryId: rule.category_id ? Number(rule.category_id) : null,
|
||||
categoryName: rule.category_name || '',
|
||||
enabled: Boolean(rule.enabled),
|
||||
autoCreate: Boolean(rule.auto_create),
|
||||
rewardAmount: Number(rule.reward_amount || 0),
|
||||
requiredDepositAmount: Number(rule.required_deposit_amount || 0),
|
||||
depositThresholdAmount: Number(
|
||||
rule.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
|
||||
),
|
||||
requirement: {
|
||||
fields: normalizeRequirementFields(requirement.fields),
|
||||
},
|
||||
sortOrder: Number(rule.sort_order || 100),
|
||||
createdAt: rule.created_at,
|
||||
updatedAt: rule.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function mapWorkerUser(worker: WorkerUserRow) {
|
||||
return {
|
||||
workerId: Number(worker.id),
|
||||
@@ -823,7 +1192,9 @@ function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
|
||||
status: workOrder.status,
|
||||
rewardAmount: Number(workOrder.reward_amount || 0),
|
||||
requiredDepositAmount: Number(workOrder.required_deposit_amount || 0),
|
||||
depositThresholdAmount: Number(workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT),
|
||||
depositThresholdAmount: Number(
|
||||
workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
|
||||
),
|
||||
material: safeParseJson(workOrder.material_json),
|
||||
requirement: safeParseJson(workOrder.requirement_json),
|
||||
acceptance: safeParseJson(workOrder.acceptance_json),
|
||||
@@ -844,7 +1215,10 @@ function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
|
||||
}
|
||||
}
|
||||
|
||||
function mapWorkOrderForWorker(workOrder: WorkOrderRow, permissions: { depositFreeAmount: number }) {
|
||||
function mapWorkOrderForWorker(
|
||||
workOrder: WorkOrderRow,
|
||||
permissions: { depositFreeAmount: number },
|
||||
) {
|
||||
return {
|
||||
...mapWorkOrderAdmin(workOrder),
|
||||
freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions),
|
||||
@@ -878,7 +1252,8 @@ function normalizeRequirementFields(value: unknown) {
|
||||
const rawFields = Array.isArray(value) ? value : []
|
||||
const fields = rawFields
|
||||
.map((item) => {
|
||||
const source = item && typeof item === 'object' && !Array.isArray(item) ? item as JsonObject : {}
|
||||
const source =
|
||||
item && typeof item === 'object' && !Array.isArray(item) ? (item as JsonObject) : {}
|
||||
const key = String(source.key || '').trim()
|
||||
if (!key) return null
|
||||
return {
|
||||
@@ -888,7 +1263,9 @@ function normalizeRequirementFields(value: unknown) {
|
||||
mockValue: String(source.mockValue || '').trim(),
|
||||
}
|
||||
})
|
||||
.filter((item): item is { key: string; label: string; required: boolean; mockValue: string } => Boolean(item))
|
||||
.filter((item): item is { key: string; label: string; required: boolean; mockValue: string } =>
|
||||
Boolean(item),
|
||||
)
|
||||
|
||||
return fields.length > 0
|
||||
? fields
|
||||
@@ -899,8 +1276,113 @@ function normalizeRequirementFields(value: unknown) {
|
||||
]
|
||||
}
|
||||
|
||||
function normalizeRequirementFieldsFromPayload(payload: JsonObject) {
|
||||
const directFields = Array.isArray(payload.fields) ? payload.fields : null
|
||||
const requirement =
|
||||
payload.requirement &&
|
||||
typeof payload.requirement === 'object' &&
|
||||
!Array.isArray(payload.requirement)
|
||||
? (payload.requirement as JsonObject)
|
||||
: {}
|
||||
if (directFields) {
|
||||
return normalizeRequirementFields(directFields)
|
||||
}
|
||||
if (Array.isArray(requirement.fields)) {
|
||||
return normalizeRequirementFields(requirement.fields)
|
||||
}
|
||||
|
||||
const textFields = normalizeRequirementFieldsText(
|
||||
payload.fieldsText || payload.requiredFieldsText,
|
||||
)
|
||||
return textFields.length > 0 ? textFields : normalizeRequirementFields([])
|
||||
}
|
||||
|
||||
function normalizeRequirementFieldsText(value: unknown) {
|
||||
return String(value || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line, index) => {
|
||||
const text = line.trim()
|
||||
if (!text) return null
|
||||
const delimiter = text.includes(':') ? ':' : ':'
|
||||
if (text.includes(delimiter)) {
|
||||
const [rawKey, ...labelParts] = text.split(delimiter)
|
||||
const key = String(rawKey || '').trim()
|
||||
if (!key) return null
|
||||
return {
|
||||
key,
|
||||
label: labelParts.join(delimiter).trim() || key,
|
||||
required: true,
|
||||
mockValue: '',
|
||||
}
|
||||
}
|
||||
return {
|
||||
key: `field${index + 1}`,
|
||||
label: text,
|
||||
required: true,
|
||||
mockValue: '',
|
||||
}
|
||||
})
|
||||
.filter((item): item is { key: string; label: string; required: boolean; mockValue: string } =>
|
||||
Boolean(item),
|
||||
)
|
||||
}
|
||||
|
||||
function resolveMatchingProductRule(
|
||||
order: OrderRow,
|
||||
item: OrderItemRow,
|
||||
rules: WorkProductRuleRow[],
|
||||
) {
|
||||
return (
|
||||
rules.find((rule) => {
|
||||
if (!matchesOptionalText(rule.provider, order.provider)) return false
|
||||
if (!matchesOptionalText(rule.platform, order.platform)) return false
|
||||
if (!matchesOptionalText(rule.shop_id, order.shop_id)) return false
|
||||
|
||||
const skuCode = String(item.sku_code || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const skuName = String(item.sku_name || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const ruleSkuCode = String(rule.sku_code || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const ruleProductName = String(rule.product_name || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
if (ruleSkuCode && ruleSkuCode !== skuCode) {
|
||||
return false
|
||||
}
|
||||
if (!ruleProductName) {
|
||||
return Boolean(ruleSkuCode)
|
||||
}
|
||||
if (rule.match_type === 'exact') {
|
||||
return ruleProductName === skuName || ruleProductName === skuCode
|
||||
}
|
||||
return skuName.includes(ruleProductName) || skuCode.includes(ruleProductName)
|
||||
}) || null
|
||||
)
|
||||
}
|
||||
|
||||
function matchesOptionalText(ruleValue: unknown, sourceValue: unknown) {
|
||||
const ruleText = String(ruleValue || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!ruleText) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
ruleText ===
|
||||
String(sourceValue || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeSubmittedFields(value: unknown) {
|
||||
const source = value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : {}
|
||||
const source =
|
||||
value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(source)
|
||||
.filter(([key]) => !['orderNo', 'platformOrderId', 'fields', 'material'].includes(key))
|
||||
@@ -913,14 +1395,80 @@ function normalizeStringArray(value: unknown): string[] {
|
||||
return value.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
function normalizeUploadedFiles(value: unknown) {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value
|
||||
.map((item) => {
|
||||
const source =
|
||||
item && typeof item === 'object' && !Array.isArray(item) ? (item as JsonObject) : {}
|
||||
const objectKey = String(source.objectKey || source.object_key || '').trim()
|
||||
const url = String(source.url || '').trim()
|
||||
if (!objectKey && !url) return null
|
||||
return {
|
||||
objectKey,
|
||||
url,
|
||||
thumbnailUrl: String(source.thumbnailUrl || source.thumbnail_url || url).trim(),
|
||||
mediumUrl: String(source.mediumUrl || source.medium_url || url).trim(),
|
||||
filename: String(source.filename || '').trim(),
|
||||
contentType: String(source.contentType || source.content_type || '').trim(),
|
||||
size: normalizeInteger(source.size, 0),
|
||||
}
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
item,
|
||||
): item is {
|
||||
objectKey: string
|
||||
url: string
|
||||
thumbnailUrl: string
|
||||
mediumUrl: string
|
||||
filename: string
|
||||
contentType: string
|
||||
size: number
|
||||
} => Boolean(item),
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeReviewStatus(value: unknown) {
|
||||
const status = String(value || '').trim()
|
||||
if (['active', 'rejected', 'disabled', 'pending_review'].includes(status)) return status
|
||||
return 'pending_review'
|
||||
}
|
||||
|
||||
function normalizeEnabledStatus(value: unknown) {
|
||||
return String(value || 'active').trim() === 'disabled' ? 'disabled' : 'active'
|
||||
}
|
||||
|
||||
function normalizeMatchType(value: unknown) {
|
||||
const matchType = String(value || '').trim()
|
||||
if (matchType === 'exact') return 'exact'
|
||||
return 'contains'
|
||||
}
|
||||
|
||||
function normalizeBoolean(value: unknown, fallback: boolean) {
|
||||
if (typeof value === 'boolean') return value
|
||||
const text = String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!text) return fallback
|
||||
if (['true', '1', 'yes', 'enabled', 'active', 'on'].includes(text)) return true
|
||||
if (['false', '0', 'no', 'disabled', 'off'].includes(text)) return false
|
||||
return fallback
|
||||
}
|
||||
|
||||
function normalizeSlugKey(value: unknown, fallback: string) {
|
||||
const key = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return key || fallback
|
||||
}
|
||||
|
||||
function normalizeUsername(value: unknown): string {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function normalizePassword(value: unknown): string {
|
||||
|
||||
Reference in New Issue
Block a user