464 lines
14 KiB
TypeScript
464 lines
14 KiB
TypeScript
import { Router, type Request, type Response } from 'express'
|
|
import { z } from 'zod'
|
|
import {
|
|
addProgressEvent,
|
|
bulkCreateOrders,
|
|
bulkDeleteOrders,
|
|
clearOrders,
|
|
createOrder,
|
|
deleteOrder,
|
|
getOrder,
|
|
listFactories,
|
|
listOrders,
|
|
listProgressEvents,
|
|
updateOrder,
|
|
} from '../db.js'
|
|
import type { AuthedRequest } from '../middleware/requireAuth.js'
|
|
import { requireAuth } from '../middleware/requireAuth.js'
|
|
import type { Order } from '../../shared/types.js'
|
|
|
|
const router = Router()
|
|
|
|
const parseNumber = (v: unknown): number | undefined => {
|
|
if (typeof v === 'number' && Number.isFinite(v)) return v
|
|
if (typeof v === 'string' && v.trim().length > 0 && Number.isFinite(Number(v))) {
|
|
return Number(v)
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
const dateOrUndef = (v: unknown): string | undefined => {
|
|
if (typeof v !== 'string') return undefined
|
|
const s = v.trim()
|
|
return s.length > 0 ? s : undefined
|
|
}
|
|
|
|
const createOrderSchema = z.object({
|
|
orderNo: z.string().min(1),
|
|
poNo: z.string().optional(),
|
|
productName: z.string().min(1),
|
|
country: z.string().optional(),
|
|
orderAmount: z.union([z.number(), z.string()]).optional(),
|
|
orderDate: z.string().optional(),
|
|
customerDeliveryDate: z.string().optional(),
|
|
factoryId: z.string().optional(),
|
|
factoryDeliveryDate: z.string().optional(),
|
|
factoryContract: z.string().optional(),
|
|
packagingStatus: z.string().optional(),
|
|
stickerStatus: z.string().optional(),
|
|
shippingStatus: z.string().optional(),
|
|
inspectionStatus: z.string().optional(),
|
|
purchaseAmount: z.union([z.number(), z.string()]).optional(),
|
|
orderProgress: z.string().optional(),
|
|
remarks: z.string().optional(),
|
|
ciAmount: z.union([z.number(), z.string()]).optional(),
|
|
etd: z.string().optional(),
|
|
eta: z.string().optional(),
|
|
paymentDate: z.string().optional(),
|
|
paymentAmount: z.union([z.number(), z.string()]).optional(),
|
|
balance: z.union([z.number(), z.string()]).optional(),
|
|
})
|
|
|
|
const updateOrderSchema = createOrderSchema.partial()
|
|
|
|
const duplicatesSchema = z.object({
|
|
orderNos: z.array(z.string().min(1)).max(10000),
|
|
})
|
|
|
|
const bulkSchema = z.object({
|
|
items: z.array(createOrderSchema).min(1).max(5000),
|
|
skipDuplicates: z.boolean().optional(),
|
|
})
|
|
|
|
const deleteManySchema = z.object({
|
|
ids: z.array(z.string().min(1)).min(1).max(10000),
|
|
})
|
|
|
|
router.use(requireAuth)
|
|
|
|
router.get('/', async (req: Request, res: Response): Promise<void> => {
|
|
const q = req.query
|
|
const page = Math.max(1, Number(q.page ?? 1) || 1)
|
|
const pageSize = Math.min(100, Math.max(1, Number(q.pageSize ?? 20) || 20))
|
|
const orderNo = typeof q.orderNo === 'string' ? q.orderNo.trim() : ''
|
|
const poNo = typeof q.poNo === 'string' ? q.poNo.trim() : ''
|
|
const productName = typeof q.productName === 'string' ? q.productName.trim() : ''
|
|
const country = typeof q.country === 'string' ? q.country.trim() : ''
|
|
const factoryId = typeof q.factoryId === 'string' ? q.factoryId.trim() : ''
|
|
const progress = typeof q.orderProgress === 'string' ? q.orderProgress.trim() : ''
|
|
const overdue = q.overdue === 'true'
|
|
const dateFrom = typeof q.dateFrom === 'string' ? q.dateFrom.trim() : ''
|
|
const dateTo = typeof q.dateTo === 'string' ? q.dateTo.trim() : ''
|
|
|
|
const orders = await listOrders()
|
|
const now = new Date().toISOString().slice(0, 10)
|
|
|
|
const filtered = orders.filter((o) => {
|
|
if (orderNo && !o.orderNo.toLowerCase().includes(orderNo.toLowerCase())) return false
|
|
if (poNo && !(o.poNo ?? '').toLowerCase().includes(poNo.toLowerCase())) return false
|
|
if (
|
|
productName &&
|
|
!o.productName.toLowerCase().includes(productName.toLowerCase())
|
|
) {
|
|
return false
|
|
}
|
|
if (country && !(o.country ?? '').toLowerCase().includes(country.toLowerCase())) {
|
|
return false
|
|
}
|
|
if (factoryId && (o.factoryId ?? '') !== factoryId) return false
|
|
if (progress && o.orderProgress !== progress) return false
|
|
if (dateFrom && (o.orderDate ?? '') < dateFrom) return false
|
|
if (dateTo && (o.orderDate ?? '') > dateTo) return false
|
|
if (overdue) {
|
|
const due = o.customerDeliveryDate
|
|
if (!due) return false
|
|
if (o.orderProgress === '完成' || o.orderProgress === '取消') return false
|
|
if (due >= now) return false
|
|
}
|
|
return true
|
|
})
|
|
|
|
const total = filtered.length
|
|
const start = (page - 1) * pageSize
|
|
const items = filtered.slice(start, start + pageSize)
|
|
|
|
res.json({ success: true, data: { items, total, page, pageSize } })
|
|
})
|
|
|
|
router.post('/duplicates', async (req: Request, res: Response): Promise<void> => {
|
|
const parsed = duplicatesSchema.safeParse(req.body)
|
|
if (!parsed.success) {
|
|
res.status(400).json({ success: false, error: 'BAD_REQUEST' })
|
|
return
|
|
}
|
|
const orders = await listOrders()
|
|
const existing = new Set(orders.map((o) => o.orderNo))
|
|
const duplicates = parsed.data.orderNos.filter((x) => existing.has(x))
|
|
res.json({ success: true, data: { duplicates } })
|
|
})
|
|
|
|
router.post('/bulk', async (req: Request, res: Response): Promise<void> => {
|
|
const user = (req as AuthedRequest).user
|
|
if (!['sales', 'admin'].includes(user.role)) {
|
|
res.status(403).json({ success: false, error: 'FORBIDDEN' })
|
|
return
|
|
}
|
|
|
|
const parsed = bulkSchema.safeParse(req.body)
|
|
if (!parsed.success) {
|
|
res.status(400).json({ success: false, error: 'BAD_REQUEST' })
|
|
return
|
|
}
|
|
|
|
const items = parsed.data.items
|
|
const inputs: Omit<Order, 'id' | 'createdAt' | 'updatedAt'>[] = items.map((body) => {
|
|
const orderProgress = (body.orderProgress ?? '下单').trim() || '下单'
|
|
return {
|
|
orderNo: body.orderNo,
|
|
poNo: body.poNo,
|
|
productName: body.productName,
|
|
country: body.country,
|
|
orderAmount: parseNumber(body.orderAmount),
|
|
orderDate: dateOrUndef(body.orderDate),
|
|
customerDeliveryDate: dateOrUndef(body.customerDeliveryDate),
|
|
factoryId: body.factoryId,
|
|
factoryDeliveryDate: dateOrUndef(body.factoryDeliveryDate),
|
|
factoryContract: body.factoryContract,
|
|
packagingStatus: body.packagingStatus,
|
|
stickerStatus: body.stickerStatus,
|
|
shippingStatus: body.shippingStatus,
|
|
inspectionStatus: body.inspectionStatus,
|
|
purchaseAmount: parseNumber(body.purchaseAmount),
|
|
orderProgress,
|
|
remarks: body.remarks,
|
|
ciAmount: parseNumber(body.ciAmount),
|
|
etd: dateOrUndef(body.etd),
|
|
eta: dateOrUndef(body.eta),
|
|
paymentDate: dateOrUndef(body.paymentDate),
|
|
paymentAmount: parseNumber(body.paymentAmount),
|
|
balance: parseNumber(body.balance),
|
|
createdByUserId: user.id,
|
|
}
|
|
})
|
|
|
|
const result = await bulkCreateOrders({
|
|
inputs,
|
|
createdByUserId: user.id,
|
|
skipDuplicates: parsed.data.skipDuplicates ?? true,
|
|
})
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
createdCount: result.created.length,
|
|
skippedOrderNos: result.skippedOrderNos,
|
|
},
|
|
})
|
|
})
|
|
|
|
router.post('/deleteMany', async (req: Request, res: Response): Promise<void> => {
|
|
const user = (req as AuthedRequest).user
|
|
if (!['sales', 'admin'].includes(user.role)) {
|
|
res.status(403).json({ success: false, error: 'FORBIDDEN' })
|
|
return
|
|
}
|
|
const parsed = deleteManySchema.safeParse(req.body)
|
|
if (!parsed.success) {
|
|
res.status(400).json({ success: false, error: 'BAD_REQUEST' })
|
|
return
|
|
}
|
|
const removed = await bulkDeleteOrders({ ids: parsed.data.ids })
|
|
res.json({ success: true, data: { removed } })
|
|
})
|
|
|
|
router.post('/clear', async (req: Request, res: Response): Promise<void> => {
|
|
const user = (req as AuthedRequest).user
|
|
if (!['sales', 'admin'].includes(user.role)) {
|
|
res.status(403).json({ success: false, error: 'FORBIDDEN' })
|
|
return
|
|
}
|
|
const removed = await clearOrders()
|
|
res.json({ success: true, data: { removed } })
|
|
})
|
|
|
|
router.get('/:id', async (req: Request, res: Response): Promise<void> => {
|
|
const id = req.params.id
|
|
const order = await getOrder(id)
|
|
if (!order) {
|
|
res.status(404).json({ success: false, error: 'NOT_FOUND' })
|
|
return
|
|
}
|
|
const factories = await listFactories()
|
|
const factory = factories.find((f) => f.id === order.factoryId)
|
|
const progressEvents = await listProgressEvents(id)
|
|
res.json({ success: true, data: { order, factory, progressEvents } })
|
|
})
|
|
|
|
router.post('/', async (req: Request, res: Response): Promise<void> => {
|
|
const user = (req as AuthedRequest).user
|
|
if (!['sales', 'admin'].includes(user.role)) {
|
|
res.status(403).json({ success: false, error: 'FORBIDDEN' })
|
|
return
|
|
}
|
|
|
|
const parsed = createOrderSchema.safeParse(req.body)
|
|
if (!parsed.success) {
|
|
res.status(400).json({ success: false, error: 'BAD_REQUEST' })
|
|
return
|
|
}
|
|
|
|
const body = parsed.data
|
|
const orderProgress = (body.orderProgress ?? '下单').trim() || '下单'
|
|
|
|
const input: Omit<Order, 'id' | 'createdAt' | 'updatedAt'> = {
|
|
orderNo: body.orderNo,
|
|
poNo: body.poNo,
|
|
productName: body.productName,
|
|
country: body.country,
|
|
orderAmount: parseNumber(body.orderAmount),
|
|
orderDate: dateOrUndef(body.orderDate),
|
|
customerDeliveryDate: dateOrUndef(body.customerDeliveryDate),
|
|
factoryId: body.factoryId,
|
|
factoryDeliveryDate: dateOrUndef(body.factoryDeliveryDate),
|
|
factoryContract: body.factoryContract,
|
|
packagingStatus: body.packagingStatus,
|
|
stickerStatus: body.stickerStatus,
|
|
shippingStatus: body.shippingStatus,
|
|
inspectionStatus: body.inspectionStatus,
|
|
purchaseAmount: parseNumber(body.purchaseAmount),
|
|
orderProgress,
|
|
remarks: body.remarks,
|
|
ciAmount: parseNumber(body.ciAmount),
|
|
etd: dateOrUndef(body.etd),
|
|
eta: dateOrUndef(body.eta),
|
|
paymentDate: dateOrUndef(body.paymentDate),
|
|
paymentAmount: parseNumber(body.paymentAmount),
|
|
balance: parseNumber(body.balance),
|
|
createdByUserId: user.id,
|
|
}
|
|
|
|
const created = await createOrder({ input })
|
|
await addProgressEvent({
|
|
orderId: created.id,
|
|
status: created.orderProgress,
|
|
note: '创建订单',
|
|
createdByUserId: user.id,
|
|
})
|
|
|
|
res.json({ success: true, data: created })
|
|
})
|
|
|
|
router.put('/:id', async (req: Request, res: Response): Promise<void> => {
|
|
const user = (req as AuthedRequest).user
|
|
const id = req.params.id
|
|
const existed = await getOrder(id)
|
|
if (!existed) {
|
|
res.status(404).json({ success: false, error: 'NOT_FOUND' })
|
|
return
|
|
}
|
|
|
|
const parsed = updateOrderSchema.safeParse(req.body)
|
|
if (!parsed.success) {
|
|
res.status(400).json({ success: false, error: 'BAD_REQUEST' })
|
|
return
|
|
}
|
|
|
|
const body = parsed.data
|
|
|
|
const isAdmin = user.role === 'admin'
|
|
const isSales = user.role === 'sales'
|
|
const isPurchase = user.role === 'purchase'
|
|
if (!isAdmin && !isSales && !isPurchase) {
|
|
res.status(403).json({ success: false, error: 'FORBIDDEN' })
|
|
return
|
|
}
|
|
|
|
const allowedFields: (keyof Order)[] = isAdmin
|
|
? [
|
|
'orderNo',
|
|
'poNo',
|
|
'productName',
|
|
'country',
|
|
'orderAmount',
|
|
'orderDate',
|
|
'customerDeliveryDate',
|
|
'factoryId',
|
|
'factoryDeliveryDate',
|
|
'factoryContract',
|
|
'packagingStatus',
|
|
'stickerStatus',
|
|
'shippingStatus',
|
|
'inspectionStatus',
|
|
'purchaseAmount',
|
|
'orderProgress',
|
|
'remarks',
|
|
'ciAmount',
|
|
'etd',
|
|
'eta',
|
|
'paymentDate',
|
|
'paymentAmount',
|
|
'balance',
|
|
]
|
|
: isSales
|
|
? [
|
|
'orderNo',
|
|
'poNo',
|
|
'productName',
|
|
'country',
|
|
'orderAmount',
|
|
'orderDate',
|
|
'customerDeliveryDate',
|
|
'factoryId',
|
|
'remarks',
|
|
'ciAmount',
|
|
'paymentDate',
|
|
'paymentAmount',
|
|
'balance',
|
|
]
|
|
: [
|
|
'factoryDeliveryDate',
|
|
'factoryContract',
|
|
'packagingStatus',
|
|
'stickerStatus',
|
|
'shippingStatus',
|
|
'inspectionStatus',
|
|
'purchaseAmount',
|
|
'orderProgress',
|
|
'etd',
|
|
'eta',
|
|
]
|
|
|
|
const patch: Partial<Order> = {}
|
|
for (const key of allowedFields) {
|
|
if (!(key in body)) continue
|
|
const value = (body as Record<string, unknown>)[key]
|
|
if (key.endsWith('Amount') || key === 'balance') {
|
|
(patch as Record<string, unknown>)[key] = parseNumber(value)
|
|
continue
|
|
}
|
|
if (
|
|
key.endsWith('Date') ||
|
|
key === 'etd' ||
|
|
key === 'eta'
|
|
) {
|
|
(patch as Record<string, unknown>)[key] = dateOrUndef(value)
|
|
continue
|
|
}
|
|
(patch as Record<string, unknown>)[key] = value
|
|
}
|
|
|
|
const updated = await updateOrder({ id, patch })
|
|
if (!updated) {
|
|
res.status(404).json({ success: false, error: 'NOT_FOUND' })
|
|
return
|
|
}
|
|
|
|
if (patch.orderProgress && patch.orderProgress !== existed.orderProgress) {
|
|
await addProgressEvent({
|
|
orderId: updated.id,
|
|
status: patch.orderProgress,
|
|
note: '更新订单状态',
|
|
createdByUserId: user.id,
|
|
})
|
|
}
|
|
|
|
res.json({ success: true, data: updated })
|
|
})
|
|
|
|
router.delete('/:id', async (req: Request, res: Response): Promise<void> => {
|
|
const user = (req as AuthedRequest).user
|
|
if (!['sales', 'admin'].includes(user.role)) {
|
|
res.status(403).json({ success: false, error: 'FORBIDDEN' })
|
|
return
|
|
}
|
|
|
|
const ok = await deleteOrder(req.params.id)
|
|
if (!ok) {
|
|
res.status(404).json({ success: false, error: 'NOT_FOUND' })
|
|
return
|
|
}
|
|
res.json({ success: true })
|
|
})
|
|
|
|
const progressSchema = z.object({
|
|
status: z.string().min(1),
|
|
note: z.string().optional(),
|
|
})
|
|
|
|
router.post('/:id/progress', async (req: Request, res: Response): Promise<void> => {
|
|
const user = (req as AuthedRequest).user
|
|
if (!['sales', 'purchase', 'admin'].includes(user.role)) {
|
|
res.status(403).json({ success: false, error: 'FORBIDDEN' })
|
|
return
|
|
}
|
|
|
|
const orderId = req.params.id
|
|
const existed = await getOrder(orderId)
|
|
if (!existed) {
|
|
res.status(404).json({ success: false, error: 'NOT_FOUND' })
|
|
return
|
|
}
|
|
|
|
const parsed = progressSchema.safeParse(req.body)
|
|
if (!parsed.success) {
|
|
res.status(400).json({ success: false, error: 'BAD_REQUEST' })
|
|
return
|
|
}
|
|
|
|
const evt = await addProgressEvent({
|
|
orderId,
|
|
status: parsed.data.status,
|
|
note: parsed.data.note,
|
|
createdByUserId: user.id,
|
|
})
|
|
|
|
if (parsed.data.status !== existed.orderProgress) {
|
|
await updateOrder({ id: orderId, patch: { orderProgress: parsed.data.status } })
|
|
}
|
|
|
|
res.json({ success: true, data: evt })
|
|
})
|
|
|
|
export default router
|
|
|