临时易耗品申请与审批

This commit is contained in:
feie9454 2025-10-05 21:22:58 +08:00
parent bf4c6a0528
commit 7ffedfbaf0
20 changed files with 1048 additions and 7208 deletions

View File

@ -17,6 +17,7 @@
"@types/bun": "latest", "@types/bun": "latest",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/swagger-ui-express": "^4.1.8",
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5", "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/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=="], "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=="], "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=="],

307
index.ts
View File

@ -1,299 +1,48 @@
import 'dotenv/config'; import 'dotenv/config';
import express from 'express'; import express from 'express';
import type { Request, Response, NextFunction } from 'express'; import type { Response, NextFunction } from 'express';
import cors from 'cors'; import cors from 'cors';
import { z } from 'zod'; import { z } from 'zod';
import { PrismaClient, v2_ConsumableTempRequestStatus, Prisma } from './generated/prisma/index.js'; import swaggerUi from 'swagger-ui-express';
import { prisma } from './src/utils/prisma.js';
// ---------- Prisma ---------- import { createOpenAPIDocument } from './src/openapi/registry.js';
const prisma = new PrismaClient(); 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 ---------- // ---------- App ----------
const app = express(); const app = express();
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
// ---------- Helpers ---------- // ---------- Health Check ----------
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() })); app.get('/health', (_req, res) => res.json({ ok: true, time: new Date().toISOString() }));
// Create request with items // ---------- Routes ----------
app.post('/api/consumable-temp-requests', asyncHandler(async (req, res) => { app.use('/api/consumable-temp-requests', requestsRouter);
const parsed = createRequestSchema.parse(req.body); app.use('/api/consumable-temp-items', itemsRouter);
const result = await prisma.v2_ConsumableTempRequest.create({ app.use('/api/consumable-temp-catalog', catalogRouter);
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 // ---------- OpenAPI Documentation ----------
app.get('/api/consumable-temp-requests', asyncHandler(async (req, res) => { const openApiDocument = createOpenAPIDocument();
const { page, pageSize } = paginateParamsSchema.parse({ page: req.query.page, pageSize: req.query.pageSize });
const where: any = {}; app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(openApiDocument, {
if (req.query.status) where.status = req.query.status; customSiteTitle: '智能配送 API 文档',
if (req.query.applicantPhone) where.applicantPhone = req.query.applicantPhone; customCss: '.swagger-ui .topbar { display: none }',
if (req.query.from || req.query.to) { swaggerOptions: {
where.createdAt = {}; persistAuthorization: true,
if (req.query.from) where.createdAt.gte = new Date(String(req.query.from)); displayRequestDuration: true
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('/openapi.json', (_req, res) => {
app.get('/api/consumable-temp-requests/:id', asyncHandler(async (req, res) => { res.setHeader('Content-Type', 'application/json');
const data = await prisma.v2_ConsumableTempRequest.findUnique({ res.send(JSON.stringify(openApiDocument, null, 2));
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 ---------- // ---------- Error Handler ----------
// eslint-disable-next-line @typescript-eslint/no-unused-vars // 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) { if (err instanceof z.ZodError) {
return res.status(400).json({ error: 'VALIDATION_ERROR', details: err.flatten() }); 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}`); 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('SIGINT', async () => { await prisma.$disconnect(); process.exit(0); });
process.on('SIGTERM', async () => { await prisma.$disconnect(); process.exit(0); }); process.on('SIGTERM', async () => { await prisma.$disconnect(); process.exit(0); });

View File

@ -6,7 +6,8 @@
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.3" "@types/express": "^5.0.3",
"@types/swagger-ui-express": "^4.1.8"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5" "typescript": "^5"

View File

@ -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

View File

@ -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"

View File

@ -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 // 已完成(已领用)
}

File diff suppressed because it is too large Load Diff

View 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: '停用成功'
}
}
});
}

View 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
View 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;
}

View 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
}
}
}
}
});
}

View 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;

View 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;

View 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;

View 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
View 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: '每页数量' })
})
});

View 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
View 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
View File

@ -0,0 +1,3 @@
import { PrismaClient } from '../../generated/prisma/index.js';
export const prisma = new PrismaClient();