diff --git a/issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv b/issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv new file mode 100644 index 00000000..9e41a4bb --- /dev/null +++ b/issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv @@ -0,0 +1,8 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +P16C-0,P16C执行合同建档,创建并校验P16C供应商主数据执行合同CSV,CSV通过校验且字段完整,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv,manual,DONE,DONE,DONE,issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv,[],P16C只建设供应商主数据与主数据页入口不进入采购编码 +P16C-1,Prisma供应商模型与迁移,新增Supplier模型及唯一性归档索引并生成迁移,Prisma schema与迁移可通过校验并成功生成客户端,cd server && npm run prisma:validate && npx prisma migrate dev --name p16c_suppliers && npm run prisma:generate,manual,DONE,DONE,DONE,server/prisma/schema.prisma | server/prisma/migrations/20260307093703_p16c_suppliers,"[""P16C-0""]",legal_entity_id首版可空且不拆联系人子表 +P16C-2,种子与权限边界补齐,补齐开发演示供应商种子并固定继续复用masterdata.view/manage权限边界,seed后可看到演示供应商且无新增supplier权限字,cd server && npm run test && npm run seed:config && npm run seed:dev-users,manual,DONE,DONE,DONE,server/src/scripts/seed-dev.js,"[""P16C-1""]",seed-config继续保持不预置真实供应商且无新增supplier权限字 +P16C-3,后端供应商CRUD API,在master-data命名空间实现供应商列表 新建 更新 归档接口并补审计与唯一性校验,供应商API支持权限校验 编码唯一 归档限制和按法体筛选,cd server && npm run test,manual,DONE,DONE,DONE,server/src/routes/masterDataSuppliers.js | server/src/app.js,"[""P16C-2""]",接口范围固定为GET POST PATCH POST archive并支持includeArchived enabled legalEntityId q +P16C-4,前端供应商Tab与API封装,在MasterDataPage加入供应商Tab并补masterData API封装与基础筛选表单,具备masterdata.view/manage的用户可查看 创建 归档供应商,cd client && npm run typecheck && npm run build,manual,TODO,TODO,TODO,client/src/api/masterData.ts | client/src/pages/MasterDataPage.tsx,"[""P16C-3""]",继续复用/system/master-data单页多Tab不新增独立菜单 +P16C-5,矩阵与蓝图事实源同步,在供应商主数据完成后同步feature-matrix事实源并保持与P16-P18蓝图一致,feature-matrix校验通过且P16C条目全部可追溯,python3 scripts/validate-feature-matrix.py,manual,TODO,TODO,TODO,docs/feature-matrix.md | scripts/validate-feature-matrix.py | issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv,"[""P16C-4""]",P16C仍归属共享主数据阶段并保持P17依赖关系不变 +P16C-6,P16C回归与安全收口,完成供应商主数据最小手工流转验证并执行后端前端回归及越权负测,CRUD 权限 归档限制与现有主流程均通过验证,cd server && npm run test && ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build,manual,TODO,TODO,TODO,server/tests/unit | client/src/pages/MasterDataPage.tsx | server/src/routes/masterData.js,"[""P16C-5""]",需补充供应商编码重复 归档后不可选 越权写入等负测 diff --git a/server/prisma/migrations/20260307093703_p16c_suppliers/migration.sql b/server/prisma/migrations/20260307093703_p16c_suppliers/migration.sql new file mode 100644 index 00000000..c351a582 --- /dev/null +++ b/server/prisma/migrations/20260307093703_p16c_suppliers/migration.sql @@ -0,0 +1,32 @@ +-- CreateTable +CREATE TABLE "suppliers" ( + "id" UUID NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "short_name" TEXT, + "tax_no" TEXT, + "contact_name" TEXT, + "contact_phone" TEXT, + "payment_terms" TEXT, + "legal_entity_id" UUID, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "archived_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_by" UUID, + "updated_at" TIMESTAMP(3) NOT NULL, + "updated_by" UUID, + + CONSTRAINT "suppliers_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "suppliers_code_key" ON "suppliers"("code"); + +-- CreateIndex +CREATE INDEX "idx_suppliers_legal_entity_status" ON "suppliers"("legal_entity_id", "enabled", "archived_at"); + +-- CreateIndex +CREATE INDEX "idx_suppliers_name" ON "suppliers"("name"); + +-- AddForeignKey +ALTER TABLE "suppliers" ADD CONSTRAINT "suppliers_legal_entity_id_fkey" FOREIGN KEY ("legal_entity_id") REFERENCES "legal_entities"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 4147cad7..29670ad0 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -409,6 +409,7 @@ model LegalEntity { cost_centers CostCenter[] employees Employee[] holiday_calendars HolidayCalendar[] @relation("HolidayCalendarLegalEntity") + suppliers Supplier[] @@index([enabled, archived_at], map: "idx_legal_entities_status") @@index([default_holiday_calendar_id], map: "idx_legal_entities_default_calendar") @@ -505,6 +506,31 @@ model HolidayCalendarDay { @@map("holiday_calendar_days") } +model Supplier { + id String @id @default(uuid()) @db.Uuid + code String @unique + name String + short_name String? + tax_no String? + contact_name String? + contact_phone String? + payment_terms String? + legal_entity_id String? @db.Uuid + enabled Boolean @default(true) + archived_at DateTime? + + created_at DateTime @default(now()) + created_by String? @db.Uuid + updated_at DateTime @updatedAt + updated_by String? @db.Uuid + + legal_entity LegalEntity? @relation(fields: [legal_entity_id], references: [id], onDelete: SetNull) + + @@index([legal_entity_id, enabled, archived_at], map: "idx_suppliers_legal_entity_status") + @@index([name], map: "idx_suppliers_name") + @@map("suppliers") +} + model ReimbursementForm { id String @id @default(uuid()) @db.Uuid form_number String? @unique diff --git a/server/src/app.js b/server/src/app.js index ce123daa..027da104 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -28,6 +28,7 @@ const usersRoutes = require("./routes/users"); const departmentsRoutes = require("./routes/departments"); const masterDataRoutes = require("./routes/masterData"); const masterDataHolidayCalendarsRoutes = require("./routes/masterDataHolidayCalendars"); +const masterDataSuppliersRoutes = require("./routes/masterDataSuppliers"); const employeesRoutes = require("./routes/employees"); const expenseTypesRoutes = require("./routes/expenseTypes"); const configReadRoutes = require("./routes/configRead"); @@ -286,6 +287,7 @@ async function buildApp() { await app.register(departmentsRoutes, { prefix: "/api" }); await app.register(masterDataRoutes, { prefix: "/api" }); await app.register(masterDataHolidayCalendarsRoutes, { prefix: "/api" }); + await app.register(masterDataSuppliersRoutes, { prefix: "/api" }); await app.register(expenseTypesRoutes, { prefix: "/api" }); await app.register(configReadRoutes, { prefix: "/api" }); await app.register(approvalRulesRoutes, { prefix: "/api" }); diff --git a/server/src/routes/masterDataSuppliers.js b/server/src/routes/masterDataSuppliers.js new file mode 100644 index 00000000..917d42a1 --- /dev/null +++ b/server/src/routes/masterDataSuppliers.js @@ -0,0 +1,521 @@ +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"); + + 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; diff --git a/server/src/scripts/seed-dev.js b/server/src/scripts/seed-dev.js index d4e44f92..cea21482 100644 --- a/server/src/scripts/seed-dev.js +++ b/server/src/scripts/seed-dev.js @@ -109,6 +109,49 @@ async function ensureCostCenter(prisma, payload) { }); } +async function ensureSupplier(prisma, payload) { + const now = new Date(); + const existing = await prisma.supplier.findUnique({ + where: { code: payload.code }, + select: { id: true }, + }); + + if (existing) { + return prisma.supplier.update({ + where: { id: existing.id }, + data: { + name: payload.name, + short_name: payload.shortName ?? null, + tax_no: payload.taxNo ?? null, + contact_name: payload.contactName ?? null, + contact_phone: payload.contactPhone ?? null, + payment_terms: payload.paymentTerms ?? null, + legal_entity_id: payload.legalEntityId ?? null, + enabled: true, + archived_at: null, + updated_at: now, + }, + }); + } + + return prisma.supplier.create({ + data: { + code: payload.code, + name: payload.name, + short_name: payload.shortName ?? null, + tax_no: payload.taxNo ?? null, + contact_name: payload.contactName ?? null, + contact_phone: payload.contactPhone ?? null, + payment_terms: payload.paymentTerms ?? null, + legal_entity_id: payload.legalEntityId ?? null, + enabled: true, + archived_at: null, + created_at: now, + updated_at: now, + }, + }); +} + async function ensureHolidayCalendar(prisma, payload) { const now = new Date(); const existing = await prisma.holidayCalendar.findUnique({ @@ -442,6 +485,17 @@ async function main() { ownerEmployeeId: manager.id, }); + const supplier = await ensureSupplier(prisma, { + code: "HYB_DEMO_VENDOR", + name: "海音达演示供应商", + shortName: "演示供应商", + taxNo: "91440300DEMO12345X", + contactName: "张采购", + contactPhone: "0755-12345678", + paymentTerms: "月结30天", + legalEntityId: legalEntity.id, + }); + // eslint-disable-next-line no-console console.log("Seeded dev users:"); // eslint-disable-next-line no-console @@ -464,6 +518,8 @@ async function main() { console.log(`- Legal Entity: ${legalEntity.code} / ${legalEntity.name}`); // eslint-disable-next-line no-console console.log(`- HolidayCalendar: ${holidayCalendar.code} / ${holidayCalendar.name}`); + // eslint-disable-next-line no-console + console.log(`- Supplier: ${supplier.code} / ${supplier.name}`); } finally { await prisma.$disconnect(); await pool.end();