const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); const BOOLEAN_QUERY_SCHEMA = z.enum(["0", "1", "true", "false"]).optional(); const UUID_SCHEMA = z.string().uuid(); const supplierListQuerySchema = z.object({ includeArchived: BOOLEAN_QUERY_SCHEMA, enabled: BOOLEAN_QUERY_SCHEMA, legalEntityId: UUID_SCHEMA.optional(), q: z.string().trim().max(120).optional(), }); const supplierCreateBodySchema = z .object({ code: z.string().trim().min(2).max(64), name: z.string().trim().min(1).max(120), shortName: z.string().trim().max(120).nullable().optional(), taxNo: z.string().trim().max(64).nullable().optional(), contactName: z.string().trim().max(120).nullable().optional(), contactPhone: z.string().trim().max(64).nullable().optional(), paymentTerms: z.string().trim().max(200).nullable().optional(), legalEntityId: UUID_SCHEMA.nullable().optional(), enabled: z.boolean().optional(), }) .strict(); const supplierPatchBodySchema = supplierCreateBodySchema.partial(); function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details, }); } function parseUuid(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { throw buildValidationError({ id: "invalid_uuid" }); } return parsed.data; } function parseBooleanQueryValue(rawValue) { if (rawValue == null) { return null; } return rawValue === "1" || rawValue === "true"; } function parseListQuery(query) { const parsed = supplierListQuerySchema.safeParse(query ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return { ...parsed.data, includeArchived: parseBooleanQueryValue(parsed.data.includeArchived) ?? false, enabled: parseBooleanQueryValue(parsed.data.enabled), q: parsed.data.q?.trim() || undefined, }; } function parseCreateBody(body) { const parsed = supplierCreateBodySchema.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return parsed.data; } function parsePatchBody(body) { const parsed = supplierPatchBodySchema.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } if (!Object.keys(parsed.data).length) { throw buildValidationError({ body: "empty" }); } return parsed.data; } function normalizeNullableString(value) { if (value === undefined) { return undefined; } return value?.trim() ? value.trim() : null; } function normalizeSupplierPayload(body) { return { code: typeof body.code === "string" ? body.code.trim().toUpperCase() : undefined, name: typeof body.name === "string" ? body.name.trim() : undefined, shortName: normalizeNullableString(body.shortName), taxNo: normalizeNullableString(body.taxNo), contactName: normalizeNullableString(body.contactName), contactPhone: normalizeNullableString(body.contactPhone), paymentTerms: normalizeNullableString(body.paymentTerms), legalEntityId: Object.prototype.hasOwnProperty.call(body, "legalEntityId") ? body.legalEntityId ?? null : undefined, enabled: body.enabled, }; } function mapSupplier(record) { return { id: record.id, code: record.code, name: record.name, shortName: record.short_name ?? null, taxNo: record.tax_no ?? null, contactName: record.contact_name ?? null, contactPhone: record.contact_phone ?? null, paymentTerms: record.payment_terms ?? null, legalEntityId: record.legal_entity_id ?? null, enabled: Boolean(record.enabled), archivedAt: record.archived_at?.toISOString?.() ?? null, createdAt: record.created_at?.toISOString?.() ?? null, updatedAt: record.updated_at?.toISOString?.() ?? null, legalEntity: record.legal_entity ? { id: record.legal_entity.id, code: record.legal_entity.code, name: record.legal_entity.name, } : null, }; } async function ensureLegalEntityAvailable(tx, legalEntityId) { if (!legalEntityId) { return null; } const legalEntity = await tx.legalEntity.findUnique({ where: { id: legalEntityId }, select: { id: true, code: true, name: true, archived_at: true }, }); if (!legalEntity) { throw buildValidationError({ legalEntityId: "not_found" }); } if (legalEntity.archived_at) { throw buildValidationError({ legalEntityId: "archived" }, 409); } return legalEntity; } function assertMasterDataViewPermission(fastify, request) { const canView = fastify.hasPermission(request.employee, "masterdata.view") || fastify.hasPermission(request.employee, "masterdata.manage") || fastify.hasPermission(request.employee, "procurement.create") || fastify.hasPermission(request.employee, "procurement.view_own") || fastify.hasPermission(request.employee, "procurement.view_all") || fastify.hasPermission(request.employee, "procurement.manage"); if (!canView) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "masterdata.view" }, }); } } async function masterDataSuppliersRoutes(fastify) { fastify.get( "/master-data/suppliers", { preHandler: fastify.authenticate, schema: { tags: ["master-data"], summary: "List suppliers", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { assertMasterDataViewPermission(fastify, request); const query = parseListQuery(request.query); const items = await fastify.prisma.supplier.findMany({ where: { ...(query.includeArchived ? {} : { archived_at: null }), ...(query.enabled == null ? {} : { enabled: query.enabled }), ...(query.legalEntityId ? { legal_entity_id: query.legalEntityId } : {}), ...(query.q ? { OR: [ { code: { contains: query.q, mode: "insensitive" } }, { name: { contains: query.q, mode: "insensitive" } }, { short_name: { contains: query.q, mode: "insensitive" } }, { tax_no: { contains: query.q, mode: "insensitive" } }, { contact_name: { contains: query.q, mode: "insensitive" } }, { contact_phone: { contains: query.q, mode: "insensitive" } }, ], } : {}), }, orderBy: [{ code: "asc" }], select: { id: true, code: true, name: true, short_name: true, tax_no: true, contact_name: true, contact_phone: true, payment_terms: true, legal_entity_id: true, enabled: true, archived_at: true, created_at: true, updated_at: true, legal_entity: { select: { id: true, code: true, name: true, }, }, }, }); return sendData(reply, request, { includeArchived: query.includeArchived, items: items.map(mapSupplier), }); }, ); fastify.post( "/master-data/suppliers", { preHandler: fastify.requirePermission("masterdata.manage"), schema: { tags: ["master-data"], summary: "Create supplier", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { const body = normalizeSupplierPayload(parseCreateBody(request.body)); try { const supplier = await fastify.prisma.$transaction(async (tx) => { await ensureLegalEntityAvailable(tx, body.legalEntityId ?? null); const created = await tx.supplier.create({ data: { code: body.code, name: body.name, short_name: body.shortName ?? null, tax_no: body.taxNo ?? null, contact_name: body.contactName ?? null, contact_phone: body.contactPhone ?? null, payment_terms: body.paymentTerms ?? null, legal_entity_id: body.legalEntityId ?? null, enabled: body.enabled ?? true, created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true, code: true, name: true, short_name: true, tax_no: true, contact_name: true, contact_phone: true, payment_terms: true, legal_entity_id: true, enabled: true, archived_at: true, created_at: true, updated_at: true, legal_entity: { select: { id: true, code: true, name: true, }, }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "SUPPLIER_CREATE", targetType: "supplier", targetId: created.id, extra: { code: created.code, legalEntityId: created.legal_entity_id, }, }), }); return created; }); return sendData(reply, request, { supplier: mapSupplier(supplier) }); } catch (error) { if (error?.code === "P2002") { throw buildValidationError({ code: "duplicate" }, 409); } throw error; } }, ); fastify.patch( "/master-data/suppliers/:id", { preHandler: fastify.requirePermission("masterdata.manage"), schema: { tags: ["master-data"], summary: "Patch supplier", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { const supplierId = parseUuid(request.params?.id); const body = normalizeSupplierPayload(parsePatchBody(request.body)); const current = await fastify.prisma.supplier.findUnique({ where: { id: supplierId }, select: { id: true, code: true, archived_at: true }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } if (current.archived_at) { throw buildValidationError({ status: "archived" }, 409); } try { const supplier = await fastify.prisma.$transaction(async (tx) => { if (body.legalEntityId !== undefined) { await ensureLegalEntityAvailable(tx, body.legalEntityId); } const updated = await tx.supplier.update({ where: { id: supplierId }, data: { ...(body.code !== undefined ? { code: body.code } : {}), ...(body.name !== undefined ? { name: body.name } : {}), ...(body.shortName !== undefined ? { short_name: body.shortName } : {}), ...(body.taxNo !== undefined ? { tax_no: body.taxNo } : {}), ...(body.contactName !== undefined ? { contact_name: body.contactName } : {}), ...(body.contactPhone !== undefined ? { contact_phone: body.contactPhone } : {}), ...(body.paymentTerms !== undefined ? { payment_terms: body.paymentTerms } : {}), ...(body.legalEntityId !== undefined ? { legal_entity_id: body.legalEntityId } : {}), ...(body.enabled !== undefined ? { enabled: body.enabled } : {}), updated_by: request.employee.id, }, select: { id: true, code: true, name: true, short_name: true, tax_no: true, contact_name: true, contact_phone: true, payment_terms: true, legal_entity_id: true, enabled: true, archived_at: true, created_at: true, updated_at: true, legal_entity: { select: { id: true, code: true, name: true, }, }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "SUPPLIER_UPDATE", targetType: "supplier", targetId: updated.id, extra: { code: updated.code, legalEntityId: updated.legal_entity_id, }, }), }); return updated; }); return sendData(reply, request, { supplier: mapSupplier(supplier) }); } catch (error) { if (error?.code === "P2002") { throw buildValidationError({ code: "duplicate" }, 409); } throw error; } }, ); fastify.post( "/master-data/suppliers/:id/archive", { preHandler: fastify.requirePermission("masterdata.manage"), schema: { tags: ["master-data"], summary: "Archive supplier", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { const supplierId = parseUuid(request.params?.id); const now = new Date(); const result = await fastify.prisma.$transaction(async (tx) => { const current = await tx.supplier.findUnique({ where: { id: supplierId }, select: { id: true, code: true, name: true, short_name: true, tax_no: true, contact_name: true, contact_phone: true, payment_terms: true, legal_entity_id: true, enabled: true, archived_at: true, created_at: true, updated_at: true, legal_entity: { select: { id: true, code: true, name: true, }, }, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } if (current.archived_at) { return { supplier: current, outcome: "already_archived" }; } const supplier = await tx.supplier.update({ where: { id: supplierId }, data: { enabled: false, archived_at: now, updated_by: request.employee.id, }, select: { id: true, code: true, name: true, short_name: true, tax_no: true, contact_name: true, contact_phone: true, payment_terms: true, legal_entity_id: true, enabled: true, archived_at: true, created_at: true, updated_at: true, legal_entity: { select: { id: true, code: true, name: true, }, }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "SUPPLIER_ARCHIVE", targetType: "supplier", targetId: supplier.id, extra: { code: supplier.code, legalEntityId: supplier.legal_entity_id, }, }), }); return { supplier, outcome: "archived" }; }); return sendData(reply, request, { supplier: mapSupplier(result.supplier), outcome: result.outcome, }); }, ); } module.exports = masterDataSuppliersRoutes;