临时易耗品申请与审批
This commit is contained in:
parent
bf4c6a0528
commit
7ffedfbaf0
3
bun.lock
3
bun.lock
@ -17,6 +17,7 @@
|
||||
"@types/bun": "latest",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
@ -72,6 +73,8 @@
|
||||
|
||||
"@types/serve-static": ["@types/serve-static@1.15.9", "https://registry.npmmirror.com/@types/serve-static/-/serve-static-1.15.9.tgz", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA=="],
|
||||
|
||||
"@types/swagger-ui-express": ["@types/swagger-ui-express@4.1.8", "", { "dependencies": { "@types/express": "*", "@types/serve-static": "*" } }, "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.0", "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.0.tgz", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="],
|
||||
|
||||
305
index.ts
305
index.ts
@ -1,299 +1,48 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import type { 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();
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import { prisma } from './src/utils/prisma.js';
|
||||
import { createOpenAPIDocument } from './src/openapi/registry.js';
|
||||
import requestsRouter from './src/routes/requests.routes.js';
|
||||
import itemsRouter from './src/routes/items.routes.js';
|
||||
import catalogRouter from './src/routes/catalog.routes.js';
|
||||
|
||||
// ---------- 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 ----------
|
||||
// ---------- Health Check ----------
|
||||
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 });
|
||||
}));
|
||||
// ---------- Routes ----------
|
||||
app.use('/api/consumable-temp-requests', requestsRouter);
|
||||
app.use('/api/consumable-temp-items', itemsRouter);
|
||||
app.use('/api/consumable-temp-catalog', catalogRouter);
|
||||
|
||||
// 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));
|
||||
// ---------- OpenAPI Documentation ----------
|
||||
const openApiDocument = createOpenAPIDocument();
|
||||
|
||||
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(openApiDocument, {
|
||||
customSiteTitle: '智能配送 API 文档',
|
||||
customCss: '.swagger-ui .topbar { display: none }',
|
||||
swaggerOptions: {
|
||||
persistAuthorization: true,
|
||||
displayRequestDuration: true
|
||||
}
|
||||
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();
|
||||
}));
|
||||
app.get('/openapi.json', (_req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.send(JSON.stringify(openApiDocument, null, 2));
|
||||
});
|
||||
|
||||
// ---------- Error Handler ----------
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
|
||||
app.use((err: any, _req: any, res: Response, _next: NextFunction) => {
|
||||
if (err instanceof z.ZodError) {
|
||||
return res.status(400).json({ error: 'VALIDATION_ERROR', details: err.flatten() });
|
||||
}
|
||||
@ -308,6 +57,6 @@ app.listen(PORT, () => {
|
||||
console.log(`Consumable Temp Request API listening on :${PORT}`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
// ---------- Graceful Shutdown ----------
|
||||
process.on('SIGINT', async () => { await prisma.$disconnect(); process.exit(0); });
|
||||
process.on('SIGTERM', async () => { await prisma.$disconnect(); process.exit(0); });
|
||||
@ -6,7 +6,8 @@
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3"
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/swagger-ui-express": "^4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `易耗品临时申请` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`applicantName` VARCHAR(191) NULL,
|
||||
`applicantPhone` VARCHAR(191) NOT NULL,
|
||||
`department` VARCHAR(191) NULL,
|
||||
`reason` VARCHAR(191) NULL,
|
||||
`neededAt` DATETIME(3) NULL,
|
||||
`status` ENUM('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED', 'COMPLETED') NOT NULL DEFAULT 'PENDING',
|
||||
`approverId` VARCHAR(191) NULL,
|
||||
`approvedAt` DATETIME(3) NULL,
|
||||
`remark` VARCHAR(191) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
INDEX `易耗品临时申请_applicantPhone_createdAt_idx`(`applicantPhone`, `createdAt`),
|
||||
INDEX `易耗品临时申请_status_createdAt_idx`(`status`, `createdAt`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `v2_ConsumableTempRequestItem` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`requestId` VARCHAR(191) NOT NULL,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`spec` VARCHAR(191) NULL,
|
||||
`quantity` INTEGER NOT NULL,
|
||||
`unit` VARCHAR(191) NULL,
|
||||
`estimatedUnitCost` DECIMAL(65, 30) NULL,
|
||||
`remark` VARCHAR(191) NULL,
|
||||
`catalogId` VARCHAR(191) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
INDEX `v2_ConsumableTempRequestItem_requestId_idx`(`requestId`),
|
||||
INDEX `v2_ConsumableTempRequestItem_name_idx`(`name`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `v2_ConsumableTempCatalog` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`spec` VARCHAR(191) NULL,
|
||||
`unit` VARCHAR(191) NULL,
|
||||
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
INDEX `v2_ConsumableTempCatalog_isActive_idx`(`isActive`),
|
||||
UNIQUE INDEX `v2_ConsumableTempCatalog_name_spec_unit_key`(`name`, `spec`, `unit`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `v2_ConsumableTempRequestItem` ADD CONSTRAINT `v2_ConsumableTempRequestItem_requestId_fkey` FOREIGN KEY (`requestId`) REFERENCES `易耗品临时申请`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `v2_ConsumableTempRequestItem` ADD CONSTRAINT `v2_ConsumableTempRequestItem_catalogId_fkey` FOREIGN KEY (`catalogId`) REFERENCES `v2_ConsumableTempCatalog`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,3 +0,0 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "mysql"
|
||||
@ -1,78 +0,0 @@
|
||||
|
||||
/// 申请单(“易耗品临时申请”)
|
||||
model v2_ConsumableTempRequest {
|
||||
id String @id @default(cuid())
|
||||
applicantName String? // 申请人姓名(可选)
|
||||
applicantPhone String // 申请人手机号
|
||||
department String? // 部门(可选)
|
||||
reason String? // 申请事由(可选)
|
||||
neededAt DateTime? // 期望领用时间(可选)
|
||||
|
||||
status v2_ConsumableTempRequestStatus @default(PENDING)
|
||||
approverId String? // 审批人(可选:员工ID/账号)
|
||||
approvedAt DateTime? // 审批时间(可选)
|
||||
remark String? // 备注(可选)
|
||||
|
||||
// 一对多:申请单包含多条物料明细
|
||||
items v2_ConsumableTempRequestItem[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([applicantPhone, createdAt])
|
||||
@@index([status, createdAt])
|
||||
|
||||
// 如需将表名映射为中文(或你既有库的真实表名),取消下一行注释:
|
||||
@@map("易耗品临时申请")
|
||||
}
|
||||
|
||||
/// 申请明细(易耗品条目)
|
||||
model v2_ConsumableTempRequestItem {
|
||||
id String @id @default(cuid())
|
||||
|
||||
requestId String
|
||||
request v2_ConsumableTempRequest @relation(fields: [requestId], references: [id], onDelete: Cascade)
|
||||
|
||||
name String // 物料名称(如“打印纸”)
|
||||
spec String? // 规格/型号(如“A4 70g”)
|
||||
quantity Int // 数量
|
||||
unit String? // 单位(如“包/箱/个”)
|
||||
estimatedUnitCost Decimal? // 预估单价(可选)
|
||||
remark String? // 明细备注(可选)
|
||||
|
||||
// 如果你有物料目录表,可在此做可选关联(下面给了 Catalog 模型)
|
||||
catalogId String?
|
||||
catalog v2_ConsumableTempCatalog? @relation(fields: [catalogId], references: [id])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([requestId])
|
||||
@@index([name])
|
||||
// 如需把表名映射为既有库命名,取消注释并替换:
|
||||
// @@map("易耗品临时申请_明细")
|
||||
}
|
||||
|
||||
/// 可选:物料目录(如你已有标准物料清单,便于下拉选择)
|
||||
model v2_ConsumableTempCatalog {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
spec String?
|
||||
unit String?
|
||||
isActive Boolean @default(true)
|
||||
|
||||
// 反向关系:哪些申请明细引用了该条目录
|
||||
requestItems v2_ConsumableTempRequestItem[]
|
||||
|
||||
@@unique([name, spec, unit])
|
||||
@@index([isActive])
|
||||
// @@map("易耗品目录")
|
||||
}
|
||||
|
||||
enum v2_ConsumableTempRequestStatus {
|
||||
PENDING // 待审批
|
||||
APPROVED // 通过
|
||||
REJECTED // 驳回
|
||||
CANCELLED // 取消
|
||||
COMPLETED // 已完成(已领用)
|
||||
}
|
||||
3286
prisma/schema.prisma
3286
prisma/schema.prisma
File diff suppressed because it is too large
Load Diff
146
src/openapi/catalog.openapi.ts
Normal file
146
src/openapi/catalog.openapi.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
|
||||
import { z } from 'zod';
|
||||
import { catalogCreateSchema, catalogUpdateSchema, catalogResponseSchema } from '../schemas/catalog.schemas.js';
|
||||
import { errorResponseSchema, paginatedResponseSchema } from '../schemas/common.js';
|
||||
|
||||
export function registerCatalogPaths(registry: OpenAPIRegistry) {
|
||||
// POST /api/consumable-temp-catalog
|
||||
registry.registerPath({
|
||||
method: 'post',
|
||||
path: '/api/consumable-temp-catalog',
|
||||
tags: ['Consumable Catalog'],
|
||||
summary: '创建耗材目录',
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: catalogCreateSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: '创建成功',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({ data: catalogResponseSchema })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/consumable-temp-catalog
|
||||
registry.registerPath({
|
||||
method: 'get',
|
||||
path: '/api/consumable-temp-catalog',
|
||||
tags: ['Consumable Catalog'],
|
||||
summary: '查询耗材目录列表',
|
||||
request: {
|
||||
query: z.object({
|
||||
page: z.string().optional().openapi({ description: '页码' }),
|
||||
pageSize: z.string().optional().openapi({ description: '每页数量' }),
|
||||
isActive: z.string().optional().openapi({ description: '是否激活' }),
|
||||
q: z.string().optional().openapi({ description: '搜索关键词' })
|
||||
})
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: '查询成功',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: paginatedResponseSchema(catalogResponseSchema)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/consumable-temp-catalog/:id
|
||||
registry.registerPath({
|
||||
method: 'get',
|
||||
path: '/api/consumable-temp-catalog/{id}',
|
||||
tags: ['Consumable Catalog'],
|
||||
summary: '获取耗材目录详情',
|
||||
request: {
|
||||
params: z.object({
|
||||
id: z.string().openapi({ description: '目录ID' })
|
||||
})
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: '查询成功',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({ data: catalogResponseSchema })
|
||||
}
|
||||
}
|
||||
},
|
||||
404: {
|
||||
description: '未找到',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: errorResponseSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/consumable-temp-catalog/:id
|
||||
registry.registerPath({
|
||||
method: 'put',
|
||||
path: '/api/consumable-temp-catalog/{id}',
|
||||
tags: ['Consumable Catalog'],
|
||||
summary: '更新耗材目录',
|
||||
request: {
|
||||
params: z.object({
|
||||
id: z.string().openapi({ description: '目录ID' })
|
||||
}),
|
||||
body: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: catalogUpdateSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: '更新成功',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({ data: catalogResponseSchema })
|
||||
}
|
||||
}
|
||||
},
|
||||
404: {
|
||||
description: '未找到',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: errorResponseSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/consumable-temp-catalog/:id
|
||||
registry.registerPath({
|
||||
method: 'delete',
|
||||
path: '/api/consumable-temp-catalog/{id}',
|
||||
tags: ['Consumable Catalog'],
|
||||
summary: '停用耗材目录',
|
||||
request: {
|
||||
params: z.object({
|
||||
id: z.string().openapi({ description: '目录ID' })
|
||||
})
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: '停用成功'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
62
src/openapi/item.openapi.ts
Normal file
62
src/openapi/item.openapi.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
|
||||
import { z } from 'zod';
|
||||
import { requestItemInputSchema, requestItemResponseSchema } from '../schemas/request.schemas.js';
|
||||
import { errorResponseSchema } from '../schemas/common.js';
|
||||
|
||||
export function registerItemPaths(registry: OpenAPIRegistry) {
|
||||
// PUT /api/consumable-temp-items/:itemId
|
||||
registry.registerPath({
|
||||
method: 'put',
|
||||
path: '/api/consumable-temp-items/{itemId}',
|
||||
tags: ['Consumable Request Items'],
|
||||
summary: '更新物料明细',
|
||||
request: {
|
||||
params: z.object({
|
||||
itemId: z.string().openapi({ description: '物料明细ID' })
|
||||
}),
|
||||
body: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: requestItemInputSchema.partial()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: '更新成功',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({ data: requestItemResponseSchema })
|
||||
}
|
||||
}
|
||||
},
|
||||
404: {
|
||||
description: '未找到',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: errorResponseSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/consumable-temp-items/:itemId
|
||||
registry.registerPath({
|
||||
method: 'delete',
|
||||
path: '/api/consumable-temp-items/{itemId}',
|
||||
tags: ['Consumable Request Items'],
|
||||
summary: '删除物料明细',
|
||||
request: {
|
||||
params: z.object({
|
||||
itemId: z.string().openapi({ description: '物料明细ID' })
|
||||
})
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: '删除成功'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
58
src/openapi/registry.ts
Normal file
58
src/openapi/registry.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi';
|
||||
import {
|
||||
createRequestSchema,
|
||||
updateRequestSchema,
|
||||
updateStatusSchema,
|
||||
requestItemInputSchema,
|
||||
requestItemResponseSchema,
|
||||
requestResponseSchema
|
||||
} from '../schemas/request.schemas.js';
|
||||
import {
|
||||
catalogCreateSchema,
|
||||
catalogUpdateSchema,
|
||||
catalogResponseSchema
|
||||
} from '../schemas/catalog.schemas.js';
|
||||
import { errorResponseSchema } from '../schemas/common.js';
|
||||
import { registerRequestPaths } from './request.openapi.js';
|
||||
import { registerItemPaths } from './item.openapi.js';
|
||||
import { registerCatalogPaths } from './catalog.openapi.js';
|
||||
|
||||
export function createOpenAPIDocument() {
|
||||
const registry = new OpenAPIRegistry();
|
||||
|
||||
// 注册 schemas
|
||||
registry.register('RequestItem', requestItemInputSchema);
|
||||
registry.register('CreateRequest', createRequestSchema);
|
||||
registry.register('UpdateRequest', updateRequestSchema);
|
||||
registry.register('UpdateStatus', updateStatusSchema);
|
||||
registry.register('CatalogCreate', catalogCreateSchema);
|
||||
registry.register('CatalogUpdate', catalogUpdateSchema);
|
||||
registry.register('RequestItemResponse', requestItemResponseSchema);
|
||||
registry.register('RequestResponse', requestResponseSchema);
|
||||
registry.register('CatalogResponse', catalogResponseSchema);
|
||||
registry.register('ErrorResponse', errorResponseSchema);
|
||||
|
||||
// 注册路由
|
||||
registerRequestPaths(registry);
|
||||
registerItemPaths(registry);
|
||||
registerCatalogPaths(registry);
|
||||
|
||||
// 生成文档
|
||||
const generator = new OpenApiGeneratorV3(registry.definitions);
|
||||
const document = generator.generateDocument({
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: '智能配送耗材临时申请 API',
|
||||
version: '1.0.0',
|
||||
description: '智能配送系统的耗材临时申请管理 API,支持申请创建、审批、物料明细管理和目录管理'
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: process.env.API_BASE_URL || 'http://localhost:3000',
|
||||
description: '开发服务器'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return document;
|
||||
}
|
||||
244
src/openapi/request.openapi.ts
Normal file
244
src/openapi/request.openapi.ts
Normal file
@ -0,0 +1,244 @@
|
||||
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
createRequestSchema,
|
||||
updateRequestSchema,
|
||||
updateStatusSchema,
|
||||
requestItemInputSchema,
|
||||
requestItemResponseSchema,
|
||||
requestResponseSchema
|
||||
} from '../schemas/request.schemas.js';
|
||||
import {
|
||||
catalogCreateSchema,
|
||||
catalogUpdateSchema,
|
||||
catalogResponseSchema
|
||||
} from '../schemas/catalog.schemas.js';
|
||||
import { errorResponseSchema, paginatedResponseSchema } from '../schemas/common.js';
|
||||
|
||||
export function registerRequestPaths(registry: OpenAPIRegistry) {
|
||||
// POST /api/consumable-temp-requests
|
||||
registry.registerPath({
|
||||
method: 'post',
|
||||
path: '/api/consumable-temp-requests',
|
||||
tags: ['Consumable Requests'],
|
||||
summary: '创建耗材临时申请',
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: createRequestSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: '创建成功',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({ data: requestResponseSchema })
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
description: '验证错误',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: errorResponseSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/consumable-temp-requests
|
||||
registry.registerPath({
|
||||
method: 'get',
|
||||
path: '/api/consumable-temp-requests',
|
||||
tags: ['Consumable Requests'],
|
||||
summary: '查询耗材临时申请列表',
|
||||
request: {
|
||||
query: z.object({
|
||||
page: z.string().optional().openapi({ description: '页码' }),
|
||||
pageSize: z.string().optional().openapi({ description: '每页数量' }),
|
||||
status: z.string().optional().openapi({ description: '状态筛选' }),
|
||||
applicantPhone: z.string().optional().openapi({ description: '申请人电话筛选' }),
|
||||
from: z.string().optional().openapi({ description: '开始时间' }),
|
||||
to: z.string().optional().openapi({ description: '结束时间' })
|
||||
})
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: '查询成功',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: paginatedResponseSchema(requestResponseSchema)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/consumable-temp-requests/:id
|
||||
registry.registerPath({
|
||||
method: 'get',
|
||||
path: '/api/consumable-temp-requests/{id}',
|
||||
tags: ['Consumable Requests'],
|
||||
summary: '获取耗材临时申请详情',
|
||||
request: {
|
||||
params: z.object({
|
||||
id: z.string().openapi({ description: '请求ID' })
|
||||
})
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: '查询成功',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({ data: requestResponseSchema })
|
||||
}
|
||||
}
|
||||
},
|
||||
404: {
|
||||
description: '未找到',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: errorResponseSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/consumable-temp-requests/:id
|
||||
registry.registerPath({
|
||||
method: 'put',
|
||||
path: '/api/consumable-temp-requests/{id}',
|
||||
tags: ['Consumable Requests'],
|
||||
summary: '更新耗材临时申请',
|
||||
request: {
|
||||
params: z.object({
|
||||
id: z.string().openapi({ description: '请求ID' })
|
||||
}),
|
||||
body: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: updateRequestSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: '更新成功',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({ data: requestResponseSchema })
|
||||
}
|
||||
}
|
||||
},
|
||||
404: {
|
||||
description: '未找到',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: errorResponseSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/consumable-temp-requests/:id/status
|
||||
registry.registerPath({
|
||||
method: 'patch',
|
||||
path: '/api/consumable-temp-requests/{id}/status',
|
||||
tags: ['Consumable Requests'],
|
||||
summary: '更新耗材临时申请状态',
|
||||
request: {
|
||||
params: z.object({
|
||||
id: z.string().openapi({ description: '请求ID' })
|
||||
}),
|
||||
body: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: updateStatusSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: '更新成功',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({ data: requestResponseSchema })
|
||||
}
|
||||
}
|
||||
},
|
||||
404: {
|
||||
description: '未找到',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: errorResponseSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/consumable-temp-requests/:id
|
||||
registry.registerPath({
|
||||
method: 'delete',
|
||||
path: '/api/consumable-temp-requests/{id}',
|
||||
tags: ['Consumable Requests'],
|
||||
summary: '删除耗材临时申请',
|
||||
request: {
|
||||
params: z.object({
|
||||
id: z.string().openapi({ description: '请求ID' })
|
||||
})
|
||||
},
|
||||
responses: {
|
||||
204: {
|
||||
description: '删除成功'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/consumable-temp-requests/:id/items
|
||||
registry.registerPath({
|
||||
method: 'post',
|
||||
path: '/api/consumable-temp-requests/{id}/items',
|
||||
tags: ['Consumable Request Items'],
|
||||
summary: '添加物料明细到请求',
|
||||
request: {
|
||||
params: z.object({
|
||||
id: z.string().openapi({ description: '请求ID' })
|
||||
}),
|
||||
body: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: requestItemInputSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: '创建成功',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({ data: requestItemResponseSchema })
|
||||
}
|
||||
}
|
||||
},
|
||||
404: {
|
||||
description: '请求未找到',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: errorResponseSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
50
src/routes/catalog.routes.ts
Normal file
50
src/routes/catalog.routes.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { Router } from 'express';
|
||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
import { prisma } from '../utils/prisma.js';
|
||||
import { paginateParamsSchema } from '../schemas/common.js';
|
||||
import { catalogCreateSchema, catalogUpdateSchema } from '../schemas/catalog.schemas.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Create catalog
|
||||
router.post('/', asyncHandler(async (req, res) => {
|
||||
const parsed = catalogCreateSchema.parse(req.body);
|
||||
const data = await prisma.v2_ConsumableTempCatalog.create({ data: parsed });
|
||||
res.status(201).json({ data });
|
||||
}));
|
||||
|
||||
// List catalogs
|
||||
router.get('/', 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 } });
|
||||
}));
|
||||
|
||||
// Get catalog detail
|
||||
router.get('/: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 });
|
||||
}));
|
||||
|
||||
// Update catalog
|
||||
router.put('/: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
|
||||
router.delete('/: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();
|
||||
}));
|
||||
|
||||
export default router;
|
||||
34
src/routes/items.routes.ts
Normal file
34
src/routes/items.routes.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { Router } from 'express';
|
||||
import { Prisma } from '../../generated/prisma/index.js';
|
||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
import { prisma } from '../utils/prisma.js';
|
||||
import { requestItemInputSchema } from '../schemas/request.schemas.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Update single item
|
||||
router.put('/: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
|
||||
router.delete('/: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();
|
||||
}));
|
||||
|
||||
export default router;
|
||||
177
src/routes/requests.routes.ts
Normal file
177
src/routes/requests.routes.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import { Router } from 'express';
|
||||
import { Prisma } from '../../generated/prisma/index.js';
|
||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
import { prisma } from '../utils/prisma.js';
|
||||
import { paginateParamsSchema } from '../schemas/common.js';
|
||||
import {
|
||||
createRequestSchema,
|
||||
updateRequestSchema,
|
||||
updateStatusSchema,
|
||||
requestItemInputSchema
|
||||
} from '../schemas/request.schemas.js';
|
||||
import { v2_ConsumableTempRequestStatus } from '../../generated/prisma/index.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Create request with items
|
||||
router.post('/', 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
|
||||
router.get('/', 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
|
||||
router.get('/: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)
|
||||
router.put('/: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
|
||||
router.patch('/: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)
|
||||
router.delete('/: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
|
||||
router.post('/: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 });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
25
src/schemas/catalog.schemas.ts
Normal file
25
src/schemas/catalog.schemas.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
// ---------- Input Schemas ----------
|
||||
export const catalogCreateSchema = z.object({
|
||||
name: z.string().min(1).openapi({ example: '办公用品', description: '目录名称' }),
|
||||
spec: z.string().optional().openapi({ example: '标准', description: '规格' }),
|
||||
unit: z.string().optional().openapi({ example: '个', description: '单位' }),
|
||||
isActive: z.boolean().optional().default(true).openapi({ description: '是否激活' })
|
||||
});
|
||||
|
||||
export const catalogUpdateSchema = catalogCreateSchema.partial();
|
||||
|
||||
// ---------- Response Schemas ----------
|
||||
export const catalogResponseSchema = z.object({
|
||||
id: z.string().openapi({ description: '目录ID' }),
|
||||
name: z.string().openapi({ description: '目录名称' }),
|
||||
spec: z.string().nullable().openapi({ description: '规格' }),
|
||||
unit: z.string().nullable().openapi({ description: '单位' }),
|
||||
isActive: z.boolean().openapi({ description: '是否激活' }),
|
||||
createdAt: z.string().openapi({ description: '创建时间' }),
|
||||
updatedAt: z.string().openapi({ description: '更新时间' })
|
||||
});
|
||||
23
src/schemas/common.ts
Normal file
23
src/schemas/common.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
export const paginateParamsSchema = z.object({
|
||||
page: z.coerce.number().int().positive().default(1).openapi({ example: 1, description: '页码' }),
|
||||
pageSize: z.coerce.number().int().positive().max(100).default(20).openapi({ example: 20, description: '每页数量' })
|
||||
});
|
||||
|
||||
export const errorResponseSchema = z.object({
|
||||
error: z.string().openapi({ description: '错误信息' }),
|
||||
details: z.any().optional().openapi({ description: '详细错误信息' })
|
||||
});
|
||||
|
||||
export const paginatedResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) => z.object({
|
||||
data: z.array(dataSchema),
|
||||
meta: z.object({
|
||||
total: z.number().int().openapi({ description: '总数' }),
|
||||
page: z.number().int().openapi({ description: '当前页码' }),
|
||||
pageSize: z.number().int().openapi({ description: '每页数量' })
|
||||
})
|
||||
});
|
||||
59
src/schemas/request.schemas.ts
Normal file
59
src/schemas/request.schemas.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { z } from 'zod';
|
||||
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
|
||||
import { v2_ConsumableTempRequestStatus } from '../../generated/prisma/index.js';
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
// ---------- Input Schemas ----------
|
||||
export const requestItemInputSchema = z.object({
|
||||
name: z.string().min(1).openapi({ example: '打印纸', description: '物料名称' }),
|
||||
spec: z.string().optional().openapi({ example: 'A4', description: '规格' }),
|
||||
quantity: z.number().int().positive().openapi({ example: 10, description: '数量' }),
|
||||
unit: z.string().optional().openapi({ example: '箱', description: '单位' }),
|
||||
estimatedUnitCost: z.number().positive().optional().openapi({ example: 25.5, description: '预估单价' }),
|
||||
remark: z.string().optional().openapi({ description: '备注' }),
|
||||
catalogId: z.string().optional().openapi({ description: '关联的目录ID' })
|
||||
});
|
||||
|
||||
export const createRequestSchema = z.object({
|
||||
applicantName: z.string().optional().openapi({ example: '张三', description: '申请人姓名' }),
|
||||
applicantPhone: z.string().min(3).openapi({ example: '13800138000', description: '申请人电话' }),
|
||||
department: z.string().optional().openapi({ example: '技术部', description: '部门' }),
|
||||
reason: z.string().optional().openapi({ example: '日常办公', description: '申请原因' }),
|
||||
neededAt: z.coerce.date().optional().openapi({ example: '2025-10-10T00:00:00Z', description: '需要时间' }),
|
||||
items: z.array(requestItemInputSchema).min(1, '必须至少包含一条物料明细').openapi({ description: '物料明细列表' })
|
||||
});
|
||||
|
||||
export const updateRequestSchema = createRequestSchema.partial().extend({
|
||||
items: z.array(requestItemInputSchema.extend({ id: z.string().optional() })).optional()
|
||||
});
|
||||
|
||||
export const updateStatusSchema = z.object({
|
||||
status: z.nativeEnum(v2_ConsumableTempRequestStatus).openapi({ description: '状态', example: 'APPROVED' }),
|
||||
approverId: z.string().optional().openapi({ description: '审批人ID' }),
|
||||
remark: z.string().optional().openapi({ description: '备注' })
|
||||
});
|
||||
|
||||
// ---------- Response Schemas ----------
|
||||
export const requestItemResponseSchema = requestItemInputSchema.extend({
|
||||
id: z.string().openapi({ description: '物料明细ID' }),
|
||||
requestId: z.string().openapi({ description: '关联的请求ID' }),
|
||||
createdAt: z.string().openapi({ description: '创建时间' }),
|
||||
updatedAt: z.string().openapi({ description: '更新时间' })
|
||||
});
|
||||
|
||||
export const requestResponseSchema = z.object({
|
||||
id: z.string().openapi({ description: '请求ID' }),
|
||||
applicantName: z.string().nullable().openapi({ description: '申请人姓名' }),
|
||||
applicantPhone: z.string().openapi({ description: '申请人电话' }),
|
||||
department: z.string().nullable().openapi({ description: '部门' }),
|
||||
reason: z.string().nullable().openapi({ description: '申请原因' }),
|
||||
status: z.nativeEnum(v2_ConsumableTempRequestStatus).openapi({ description: '状态' }),
|
||||
approverId: z.string().nullable().openapi({ description: '审批人ID' }),
|
||||
approvedAt: z.string().nullable().openapi({ description: '审批时间' }),
|
||||
neededAt: z.string().nullable().openapi({ description: '需要时间' }),
|
||||
remark: z.string().nullable().openapi({ description: '备注' }),
|
||||
createdAt: z.string().openapi({ description: '创建时间' }),
|
||||
updatedAt: z.string().openapi({ description: '更新时间' }),
|
||||
items: z.array(requestItemResponseSchema).openapi({ description: '物料明细列表' })
|
||||
});
|
||||
12
src/utils/asyncHandler.ts
Normal file
12
src/utils/asyncHandler.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import type { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
|
||||
export const asyncHandler = <T extends 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;
|
||||
};
|
||||
3
src/utils/prisma.ts
Normal file
3
src/utils/prisma.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { PrismaClient } from '../../generated/prisma/index.js';
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
Loading…
x
Reference in New Issue
Block a user