2025-10-05 16:46:48 +08:00

313 lines
12 KiB
TypeScript

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 = <T extends express.RequestHandler>(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); });