313 lines
12 KiB
TypeScript
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); }); |