import 'dotenv/config'; import express from 'express'; import type { Request, Response, NextFunction } from 'express'; import cors from 'cors'; import { z } from 'zod'; import { PrismaClient, v2_ConsumableTempRequestStatus, Prisma } from './generated/prisma/index.js'; // ---------- Prisma ---------- const prisma = new PrismaClient(); // ---------- App ---------- const app = express(); app.use(cors()); app.use(express.json()); // ---------- Helpers ---------- const asyncHandler = (fn: T): T => { // @ts-ignore return (async (req: Request, res: Response, next: NextFunction) => { try { await fn(req, res, next); } catch (e) { next(e); } }) as T; }; const paginateParamsSchema = z.object({ page: z.coerce.number().int().positive().default(1), pageSize: z.coerce.number().int().positive().max(100).default(20) }); // ---------- Zod Schemas ---------- const requestItemInputSchema = z.object({ name: z.string().min(1), spec: z.string().optional(), quantity: z.number().int().positive(), unit: z.string().optional(), estimatedUnitCost: z.number().positive().optional(), remark: z.string().optional(), catalogId: z.string().optional() }); const createRequestSchema = z.object({ applicantName: z.string().optional(), applicantPhone: z.string().min(3), department: z.string().optional(), reason: z.string().optional(), neededAt: z.coerce.date().optional(), items: z.array(requestItemInputSchema).min(1, '必须至少包含一条物料明细') }); const updateRequestSchema = createRequestSchema.partial().extend({ items: z.array(requestItemInputSchema.extend({ id: z.string().optional() })).optional() }); const updateStatusSchema = z.object({ status: z.nativeEnum(v2_ConsumableTempRequestStatus), approverId: z.string().optional(), remark: z.string().optional() }); const catalogCreateSchema = z.object({ name: z.string().min(1), spec: z.string().optional(), unit: z.string().optional(), isActive: z.boolean().optional().default(true) }); const catalogUpdateSchema = catalogCreateSchema.partial(); // ---------- Routes ---------- app.get('/health', (_req, res) => res.json({ ok: true, time: new Date().toISOString() })); // Create request with items app.post('/api/consumable-temp-requests', asyncHandler(async (req, res) => { const parsed = createRequestSchema.parse(req.body); const result = await prisma.v2_ConsumableTempRequest.create({ data: { applicantName: parsed.applicantName, applicantPhone: parsed.applicantPhone, department: parsed.department, reason: parsed.reason, neededAt: parsed.neededAt, items: { create: parsed.items.map(i => ({ name: i.name, spec: i.spec, quantity: i.quantity, unit: i.unit, estimatedUnitCost: i.estimatedUnitCost !== undefined ? new Prisma.Decimal(i.estimatedUnitCost) : undefined, remark: i.remark, catalogId: i.catalogId ?? null })) } }, include: { items: true } }); res.status(201).json({ data: result }); })); // List requests with filters & pagination app.get('/api/consumable-temp-requests', asyncHandler(async (req, res) => { const { page, pageSize } = paginateParamsSchema.parse({ page: req.query.page, pageSize: req.query.pageSize }); const where: any = {}; if (req.query.status) where.status = req.query.status; if (req.query.applicantPhone) where.applicantPhone = req.query.applicantPhone; if (req.query.from || req.query.to) { where.createdAt = {}; if (req.query.from) where.createdAt.gte = new Date(String(req.query.from)); if (req.query.to) where.createdAt.lte = new Date(String(req.query.to)); } const [total, items] = await Promise.all([ prisma.v2_ConsumableTempRequest.count({ where }), prisma.v2_ConsumableTempRequest.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, include: { items: true } }) ]); res.json({ data: items, meta: { total, page, pageSize } }); })); // Get detail app.get('/api/consumable-temp-requests/:id', asyncHandler(async (req, res) => { const data = await prisma.v2_ConsumableTempRequest.findUnique({ where: { id: req.params.id }, include: { items: true } }); if (!data) return res.status(404).json({ error: 'Not Found' }); res.json({ data }); })); // Full update (basic fields + replace items if provided via upsert logic) app.put('/api/consumable-temp-requests/:id', asyncHandler(async (req, res) => { const parsed = updateRequestSchema.parse(req.body); // We will handle items manually in a transaction: update existing, create new, delete removed const result = await prisma.$transaction(async (tx) => { const existing = await tx.v2_ConsumableTempRequest.findUnique({ where: { id: req.params.id }, include: { items: true } }); if (!existing) throw new Error('NOT_FOUND'); // Update base await tx.v2_ConsumableTempRequest.update({ where: { id: existing.id }, data: { applicantName: parsed.applicantName ?? existing.applicantName, applicantPhone: parsed.applicantPhone ?? existing.applicantPhone, department: parsed.department ?? existing.department, reason: parsed.reason ?? existing.reason, neededAt: parsed.neededAt ?? existing.neededAt } }); if (parsed.items) { const incoming = parsed.items; const incomingIds = new Set(incoming.filter(i => i.id).map(i => i.id!)); // Delete removed const toDelete = existing.items.filter(i => !incomingIds.has(i.id)).map(i => i.id); if (toDelete.length) { await tx.v2_ConsumableTempRequestItem.deleteMany({ where: { id: { in: toDelete } } }); } // Upsert each incoming for (const item of incoming) { if (item.id) { await tx.v2_ConsumableTempRequestItem.update({ where: { id: item.id }, data: { name: item.name ?? undefined, spec: item.spec, quantity: item.quantity ?? undefined, unit: item.unit, estimatedUnitCost: item.estimatedUnitCost !== undefined ? new Prisma.Decimal(item.estimatedUnitCost) : undefined, remark: item.remark, catalogId: item.catalogId } }); } else { await tx.v2_ConsumableTempRequestItem.create({ data: { requestId: existing.id, name: item.name, spec: item.spec, quantity: item.quantity, unit: item.unit, estimatedUnitCost: item.estimatedUnitCost !== undefined ? new Prisma.Decimal(item.estimatedUnitCost) : undefined, remark: item.remark, catalogId: item.catalogId }}); } } } return tx.v2_ConsumableTempRequest.findUnique({ where: { id: existing.id }, include: { items: true } }); }); res.json({ data: result }); })); // Update status app.patch('/api/consumable-temp-requests/:id/status', asyncHandler(async (req, res) => { const parsed = updateStatusSchema.parse(req.body); const data = await prisma.v2_ConsumableTempRequest.update({ where: { id: req.params.id }, data: { status: parsed.status, approverId: parsed.approverId, approvedAt: (parsed.status === v2_ConsumableTempRequestStatus.APPROVED || parsed.status === v2_ConsumableTempRequestStatus.REJECTED) ? new Date() : undefined, remark: parsed.remark }, include: { items: true } }).catch(e => { if ((e as any).code === 'P2025') return null; throw e; }); if (!data) return res.status(404).json({ error: 'Not Found' }); res.json({ data }); })); // Delete request (cascade deletes items) app.delete('/api/consumable-temp-requests/:id', asyncHandler(async (req, res) => { await prisma.v2_ConsumableTempRequest.delete({ where: { id: req.params.id } }).catch(e => { if ((e as any).code === 'P2025') return null; throw e; }); res.status(204).end(); })); // Add item to request app.post('/api/consumable-temp-requests/:id/items', asyncHandler(async (req, res) => { const parsed = requestItemInputSchema.parse(req.body); // Ensure request exists const exists = await prisma.v2_ConsumableTempRequest.count({ where: { id: req.params.id } }); if (!exists) return res.status(404).json({ error: 'Request Not Found' }); const item = await prisma.v2_ConsumableTempRequestItem.create({ data: { requestId: req.params.id as string, name: parsed.name, spec: parsed.spec, quantity: parsed.quantity, unit: parsed.unit, estimatedUnitCost: parsed.estimatedUnitCost !== undefined ? new Prisma.Decimal(parsed.estimatedUnitCost) : undefined, remark: parsed.remark, catalogId: parsed.catalogId ?? null }}); res.status(201).json({ data: item }); })); // Update single item app.put('/api/consumable-temp-items/:itemId', asyncHandler(async (req, res) => { const parsed = requestItemInputSchema.partial().parse(req.body); const item = await prisma.v2_ConsumableTempRequestItem.update({ where: { id: req.params.itemId }, data: { name: parsed.name, spec: parsed.spec, quantity: parsed.quantity, unit: parsed.unit, estimatedUnitCost: parsed.estimatedUnitCost !== undefined ? new Prisma.Decimal(parsed.estimatedUnitCost) : undefined, remark: parsed.remark, catalogId: parsed.catalogId } }).catch(e => { if ((e as any).code === 'P2025') return null; throw e; }); if (!item) return res.status(404).json({ error: 'Not Found' }); res.json({ data: item }); })); // Delete item app.delete('/api/consumable-temp-items/:itemId', asyncHandler(async (req, res) => { await prisma.v2_ConsumableTempRequestItem.delete({ where: { id: req.params.itemId } }).catch(e => { if ((e as any).code === 'P2025') return null; throw e; }); res.status(204).end(); })); // Catalog CRUD app.post('/api/consumable-temp-catalog', asyncHandler(async (req, res) => { const parsed = catalogCreateSchema.parse(req.body); const data = await prisma.v2_ConsumableTempCatalog.create({ data: parsed }); res.status(201).json({ data }); })); app.get('/api/consumable-temp-catalog', asyncHandler(async (req, res) => { const { page, pageSize } = paginateParamsSchema.parse({ page: req.query.page, pageSize: req.query.pageSize }); const where: any = {}; if (req.query.isActive !== undefined) where.isActive = req.query.isActive === 'true'; if (req.query.q) where.name = { contains: String(req.query.q) }; const [total, rows] = await Promise.all([ prisma.v2_ConsumableTempCatalog.count({ where }), prisma.v2_ConsumableTempCatalog.findMany({ where, orderBy: { name: 'asc' }, skip: (page - 1) * pageSize, take: pageSize }) ]); res.json({ data: rows, meta: { total, page, pageSize } }); })); app.get('/api/consumable-temp-catalog/:id', asyncHandler(async (req, res) => { const data = await prisma.v2_ConsumableTempCatalog.findUnique({ where: { id: req.params.id } }); if (!data) return res.status(404).json({ error: 'Not Found' }); res.json({ data }); })); app.put('/api/consumable-temp-catalog/:id', asyncHandler(async (req, res) => { const parsed = catalogUpdateSchema.parse(req.body); const data = await prisma.v2_ConsumableTempCatalog.update({ where: { id: req.params.id }, data: parsed }).catch(e => { if ((e as any).code === 'P2025') return null; throw e; }); if (!data) return res.status(404).json({ error: 'Not Found' }); res.json({ data }); })); // Deactivate instead of delete app.delete('/api/consumable-temp-catalog/:id', asyncHandler(async (req, res) => { await prisma.v2_ConsumableTempCatalog.update({ where: { id: req.params.id }, data: { isActive: false } }).catch(e => { if ((e as any).code === 'P2025') return null; throw e; }); res.status(204).end(); })); // ---------- Error Handler ---------- // eslint-disable-next-line @typescript-eslint/no-unused-vars app.use((err: any, _req: Request, res: Response, _next: NextFunction) => { if (err instanceof z.ZodError) { return res.status(400).json({ error: 'VALIDATION_ERROR', details: err.flatten() }); } if (err.message === 'NOT_FOUND') return res.status(404).json({ error: 'Not Found' }); console.error('Unhandled Error:', err); res.status(500).json({ error: 'INTERNAL_ERROR' }); }); // ---------- Start Server ---------- const PORT = Number(process.env.PORT) || 3000; app.listen(PORT, () => { console.log(`Consumable Temp Request API listening on :${PORT}`); }); // Graceful shutdown process.on('SIGINT', async () => { await prisma.$disconnect(); process.exit(0); }); process.on('SIGTERM', async () => { await prisma.$disconnect(); process.exit(0); });