diff --git a/furniture-3d-system/prisma/migrations/20260930150000_add_production_stop_demands/migration.sql b/furniture-3d-system/prisma/migrations/20260930150000_add_production_stop_demands/migration.sql new file mode 100644 index 00000000..4d72bd17 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260930150000_add_production_stop_demands/migration.sql @@ -0,0 +1,86 @@ +-- 停产需求管理初版:需求单、条码明细行、停产原因分类配置(纯新增表,不改动既有表)。 +CREATE TABLE "ProductionStopDemand" ( + "id" TEXT NOT NULL, + "demandNo" TEXT NOT NULL, + "requesterId" TEXT NOT NULL, + "requesterName" TEXT NOT NULL, + "department" TEXT NOT NULL DEFAULT '', + "reasonCategory" TEXT NOT NULL, + "reasonDetail" TEXT NOT NULL DEFAULT '', + "status" TEXT NOT NULL DEFAULT 'SUBMITTED', + "submittedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProductionStopDemand_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ProductionStopDemand_demandNo_key" ON "ProductionStopDemand"("demandNo"); + +CREATE INDEX "ProductionStopDemand_status_submittedAt_idx" ON "ProductionStopDemand"("status", "submittedAt"); + +CREATE INDEX "ProductionStopDemand_requesterId_submittedAt_idx" ON "ProductionStopDemand"("requesterId", "submittedAt"); + +CREATE TABLE "ProductionStopDemandItem" ( + "id" TEXT NOT NULL, + "demandId" TEXT NOT NULL, + "barcode" TEXT NOT NULL, + "categoryL1" TEXT NOT NULL DEFAULT '', + "categoryL2" TEXT NOT NULL DEFAULT '', + "spu" TEXT NOT NULL DEFAULT '', + "sku" TEXT NOT NULL DEFAULT '', + "productName" TEXT NOT NULL DEFAULT '', + "productStatus" TEXT NOT NULL DEFAULT '', + "supplierName" TEXT NOT NULL DEFAULT '', + "purchaseOwner" TEXT NOT NULL DEFAULT '', + "brand" TEXT NOT NULL DEFAULT '', + "productTag" TEXT NOT NULL DEFAULT '', + "productLabel" TEXT NOT NULL DEFAULT '', + "barcodeCreatedAt" TIMESTAMP(3), + "postStopSkuStatus" TEXT NOT NULL DEFAULT '', + "sales30dWithBig" TEXT NOT NULL DEFAULT '', + "sales30dWithBigSku" TEXT NOT NULL DEFAULT '', + "opsDirector" TEXT NOT NULL DEFAULT '', + "topSalesOpsDirector" TEXT NOT NULL DEFAULT '', + "stopSuggestion" TEXT NOT NULL DEFAULT '', + "replaceType" TEXT NOT NULL DEFAULT '', + "replaceBarcode" TEXT NOT NULL DEFAULT '', + "imageChange" TEXT NOT NULL DEFAULT '', + "modificationPoint" TEXT NOT NULL DEFAULT '', + "responseStatus" TEXT NOT NULL DEFAULT 'PENDING', + "responderId" TEXT, + "responderName" TEXT NOT NULL DEFAULT '', + "responseNote" TEXT NOT NULL DEFAULT '', + "respondedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProductionStopDemandItem_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ProductionStopDemandItem_demandId_barcode_key" ON "ProductionStopDemandItem"("demandId", "barcode"); + +CREATE INDEX "ProductionStopDemandItem_barcode_idx" ON "ProductionStopDemandItem"("barcode"); + +CREATE INDEX "ProductionStopDemandItem_sku_idx" ON "ProductionStopDemandItem"("sku"); + +CREATE INDEX "ProductionStopDemandItem_responseStatus_updatedAt_idx" ON "ProductionStopDemandItem"("responseStatus", "updatedAt"); + +CREATE INDEX "ProductionStopDemandItem_purchaseOwner_responseStatus_idx" ON "ProductionStopDemandItem"("purchaseOwner", "responseStatus"); + +ALTER TABLE "ProductionStopDemandItem" ADD CONSTRAINT "ProductionStopDemandItem_demandId_fkey" FOREIGN KEY ("demandId") REFERENCES "ProductionStopDemand"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TABLE "ProductionStopReasonCategory" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProductionStopReasonCategory_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ProductionStopReasonCategory_name_key" ON "ProductionStopReasonCategory"("name"); + +CREATE INDEX "ProductionStopReasonCategory_isActive_sortOrder_idx" ON "ProductionStopReasonCategory"("isActive", "sortOrder"); diff --git a/furniture-3d-system/prisma/migrations/20260930170000_add_production_stop_demand_delete_log_action/migration.sql b/furniture-3d-system/prisma/migrations/20260930170000_add_production_stop_demand_delete_log_action/migration.sql new file mode 100644 index 00000000..5235e4fe --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260930170000_add_production_stop_demand_delete_log_action/migration.sql @@ -0,0 +1,2 @@ +-- 停产需求明细删除支持写入操作日志(新增枚举值,不改动既有数据)。 +ALTER TYPE "OperationLogAction" ADD VALUE 'PRODUCTION_STOP_DEMAND_DELETE'; diff --git a/furniture-3d-system/prisma/migrations/20261007100000_add_production_stop_confirmations/migration.sql b/furniture-3d-system/prisma/migrations/20261007100000_add_production_stop_confirmations/migration.sql new file mode 100644 index 00000000..e9549c5f --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20261007100000_add_production_stop_confirmations/migration.sql @@ -0,0 +1,87 @@ +-- 停产需求「是否同意停产」多人确认:确认人配置、确认票、需求侧确认状态与截止时间。 +ALTER TABLE "ProductionStopDemand" + ADD COLUMN "confirmationDeadline" TIMESTAMP(3), + ADD COLUMN "confirmationStatus" TEXT NOT NULL DEFAULT 'PENDING', + ADD COLUMN "confirmationAt" TIMESTAMP(3), + ADD COLUMN "confirmationReasons" TEXT NOT NULL DEFAULT '', + ADD COLUMN "confirmationPushedAt" TIMESTAMP(3), + ADD COLUMN "confirmationPushMessage" TEXT NOT NULL DEFAULT ''; + +-- 原「采购反馈」改为需求级「是否同意停产」多人确认,移除按明细的反馈字段。 +DROP INDEX "ProductionStopDemand_status_submittedAt_idx"; +ALTER TABLE "ProductionStopDemand" DROP COLUMN "status"; + +DROP INDEX "ProductionStopDemandItem_responseStatus_updatedAt_idx"; +DROP INDEX "ProductionStopDemandItem_purchaseOwner_responseStatus_idx"; +ALTER TABLE "ProductionStopDemandItem" + DROP COLUMN "responseStatus", + DROP COLUMN "responderId", + DROP COLUMN "responderName", + DROP COLUMN "responseNote", + DROP COLUMN "respondedAt"; + +CREATE INDEX "ProductionStopDemand_submittedAt_idx" ON "ProductionStopDemand"("submittedAt"); + +CREATE INDEX "ProductionStopDemandItem_purchaseOwner_idx" ON "ProductionStopDemandItem"("purchaseOwner"); + +CREATE INDEX "ProductionStopDemandItem_updatedAt_idx" ON "ProductionStopDemandItem"("updatedAt"); + +CREATE INDEX "ProductionStopDemand_confirmationStatus_confirmationDeadline_idx" ON "ProductionStopDemand"("confirmationStatus", "confirmationDeadline"); + +-- 历史需求按「当天 15:00 前提交当天 16:00 截止,否则次日 16:00 截止」补齐确认截止时间。 +-- 注意:submittedAt / confirmationDeadline 是 timestamp(无时区),Prisma 按 UTC 读写, +-- 因此这里显式按 UTC 解释存储值,结果也写回 UTC,不依赖数据库会话时区。 +UPDATE "ProductionStopDemand" +SET "confirmationDeadline" = ( + CASE + WHEN (("submittedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai')::time < TIME '15:00' + THEN date_trunc('day', ("submittedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai') + + INTERVAL '16 hours' + ELSE date_trunc('day', ("submittedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai') + + INTERVAL '1 day 16 hours' + END +) AT TIME ZONE 'Asia/Shanghai' AT TIME ZONE 'UTC' +WHERE "confirmationDeadline" IS NULL; + +CREATE TABLE "ProductionStopConfirmationApprover" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "userName" TEXT NOT NULL, + "dingtalkUnionId" TEXT NOT NULL DEFAULT '', + "isActive" BOOLEAN NOT NULL DEFAULT true, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProductionStopConfirmationApprover_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ProductionStopConfirmationApprover_userId_key" ON "ProductionStopConfirmationApprover"("userId"); + +CREATE INDEX "ProductionStopConfirmationApprover_isActive_sortOrder_idx" ON "ProductionStopConfirmationApprover"("isActive", "sortOrder"); + +CREATE TABLE "ProductionStopDemandConfirmationVote" ( + "id" TEXT NOT NULL, + "demandId" TEXT NOT NULL, + "approverId" TEXT NOT NULL, + "approverUserId" TEXT NOT NULL, + "approverName" TEXT NOT NULL, + "decision" TEXT NOT NULL DEFAULT 'AGREED', + "reason" TEXT NOT NULL DEFAULT '', + "source" TEXT NOT NULL DEFAULT 'USER', + "respondedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProductionStopDemandConfirmationVote_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ProductionStopDemandConfirmationVote_demandId_approverId_key" ON "ProductionStopDemandConfirmationVote"("demandId", "approverId"); + +CREATE INDEX "ProductionStopDemandConfirmationVote_demandId_decision_idx" ON "ProductionStopDemandConfirmationVote"("demandId", "decision"); + +CREATE INDEX "ProductionStopDemandConfirmationVote_respondedAt_idx" ON "ProductionStopDemandConfirmationVote"("respondedAt"); + +ALTER TABLE "ProductionStopDemandConfirmationVote" ADD CONSTRAINT "ProductionStopDemandConfirmationVote_demandId_fkey" FOREIGN KEY ("demandId") REFERENCES "ProductionStopDemand"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "ProductionStopDemandConfirmationVote" ADD CONSTRAINT "ProductionStopDemandConfirmationVote_approverId_fkey" FOREIGN KEY ("approverId") REFERENCES "ProductionStopConfirmationApprover"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/furniture-3d-system/prisma/migrations/20261007150000_add_production_stop_demand_update_log_action/migration.sql b/furniture-3d-system/prisma/migrations/20261007150000_add_production_stop_demand_update_log_action/migration.sql new file mode 100644 index 00000000..16e3bdc7 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20261007150000_add_production_stop_demand_update_log_action/migration.sql @@ -0,0 +1,2 @@ +-- 停产需求修改停产原因支持写入操作日志(新增枚举值,不改动既有数据)。 +ALTER TYPE "OperationLogAction" ADD VALUE 'PRODUCTION_STOP_DEMAND_UPDATE'; diff --git a/furniture-3d-system/prisma/migrations/20261007180000_add_production_stop_demand_status/migration.sql b/furniture-3d-system/prisma/migrations/20261007180000_add_production_stop_demand_status/migration.sql new file mode 100644 index 00000000..72747e41 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20261007180000_add_production_stop_demand_status/migration.sql @@ -0,0 +1,32 @@ +-- 停产需求状态(待确认/需求驳回/已确认待走流程/已走流程待审批/流程审批通过)与流程负责人名单。 +ALTER TABLE "ProductionStopDemand" + ADD COLUMN "demandStatus" TEXT NOT NULL DEFAULT 'PENDING_CONFIRMATION', + ADD COLUMN "demandStatusAt" TIMESTAMP(3), + ADD COLUMN "demandStatusNote" TEXT NOT NULL DEFAULT ''; + +CREATE INDEX "ProductionStopDemand_demandStatus_updatedAt_idx" ON "ProductionStopDemand"("demandStatus", "updatedAt"); + +CREATE TABLE "ProductionStopFlowOwner" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "userName" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProductionStopFlowOwner_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ProductionStopFlowOwner_userId_key" ON "ProductionStopFlowOwner"("userId"); + +CREATE INDEX "ProductionStopFlowOwner_isActive_sortOrder_idx" ON "ProductionStopFlowOwner"("isActive", "sortOrder"); + +-- 历史需求按当前确认结果回填需求状态(确认通过→已确认待走流程;确认驳回→需求驳回)。 +UPDATE "ProductionStopDemand" +SET "demandStatus" = CASE + WHEN "confirmationStatus" = 'AGREED' THEN 'CONFIRMED_WAIT_FLOW' + WHEN "confirmationStatus" = 'REJECTED' THEN 'REJECTED' + ELSE 'PENDING_CONFIRMATION' + END, + "demandStatusAt" = COALESCE("confirmationAt", "createdAt"); diff --git a/furniture-3d-system/prisma/migrations/20261007190000_add_production_stop_demand_batch/migration.sql b/furniture-3d-system/prisma/migrations/20261007190000_add_production_stop_demand_batch/migration.sql new file mode 100644 index 00000000..02330877 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20261007190000_add_production_stop_demand_batch/migration.sql @@ -0,0 +1,3 @@ +-- 按条码拆单后,用批次号把同一次提交的需求单串起来(用于批量确认与推送聚合)。 +ALTER TABLE "ProductionStopDemand" ADD COLUMN "batchNo" TEXT NOT NULL DEFAULT ''; +CREATE INDEX "ProductionStopDemand_batchNo_idx" ON "ProductionStopDemand"("batchNo"); diff --git a/furniture-3d-system/prisma/migrations/20261007200000_add_production_stop_suggestion_config/migration.sql b/furniture-3d-system/prisma/migrations/20261007200000_add_production_stop_suggestion_config/migration.sql new file mode 100644 index 00000000..8372bf7b --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20261007200000_add_production_stop_suggestion_config/migration.sql @@ -0,0 +1,16 @@ +-- 盘停建议配置:人工批量维护「条码 → 盘停建议」,提单时按条码自动带出。 +CREATE TABLE "ProductionStopSuggestionConfig" ( + "id" TEXT NOT NULL, + "barcode" TEXT NOT NULL, + "suggestion" TEXT NOT NULL DEFAULT '', + "updatedById" TEXT NOT NULL DEFAULT '', + "updatedByName" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProductionStopSuggestionConfig_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ProductionStopSuggestionConfig_barcode_key" ON "ProductionStopSuggestionConfig"("barcode"); + +CREATE INDEX "ProductionStopSuggestionConfig_updatedAt_idx" ON "ProductionStopSuggestionConfig"("updatedAt"); diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index f9c3e508..45e0c375 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -1135,6 +1135,157 @@ model SupplyChainProductArchiveReplacementRule { @@index([sku]) @@index([replaceSku]) + +} + +model ProductionStopDemand { + id String @id @default(cuid()) + demandNo String @unique + requesterId String + requesterName String + department String @default("") + reasonCategory String + reasonDetail String @default("") @db.Text + submittedAt DateTime @default(now()) + items ProductionStopDemandItem[] + confirmationDeadline DateTime? + confirmationStatus String @default("PENDING") + confirmationAt DateTime? + confirmationReasons String @default("") @db.Text + confirmationPushedAt DateTime? + confirmationPushMessage String @default("") @db.Text + confirmationVotes ProductionStopDemandConfirmationVote[] + demandStatus String @default("PENDING_CONFIRMATION") + demandStatusAt DateTime? + demandStatusNote String @default("") @db.Text + batchNo String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([submittedAt]) + @@index([requesterId, submittedAt]) + @@index([confirmationStatus, confirmationDeadline]) + @@index([demandStatus, updatedAt]) + @@index([batchNo]) +} + +// 停产需求明细行(一条码一行):档案快照 + 替换信息 + 采购反馈。 + +model ProductionStopDemandItem { + id String @id @default(cuid()) + demandId String + demand ProductionStopDemand @relation(fields: [demandId], references: [id], onDelete: Cascade) + barcode String + categoryL1 String @default("") + categoryL2 String @default("") + spu String @default("") + sku String @default("") + productName String @default("") + productStatus String @default("") + supplierName String @default("") + purchaseOwner String @default("") + brand String @default("") + productTag String @default("") + productLabel String @default("") + barcodeCreatedAt DateTime? + postStopSkuStatus String @default("") + sales30dWithBig String @default("") + sales30dWithBigSku String @default("") + opsDirector String @default("") + topSalesOpsDirector String @default("") + stopSuggestion String @default("") + replaceType String @default("") + replaceBarcode String @default("") + imageChange String @default("") + modificationPoint String @default("") @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([demandId, barcode]) + @@index([barcode]) + @@index([sku]) + @@index([purchaseOwner]) + @@index([updatedAt]) +} + +// 停产原因分类基础选项配置(系统管理员维护,需求人只能选择已配置内容)。 + +model ProductionStopReasonCategory { + id String @id @default(cuid()) + name String @unique + sortOrder Int @default(0) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([isActive, sortOrder]) +} + +// 停产需求确认人(指定运营总监/指定其他人),由系统管理员维护,用于推送与确认权限判定。 + +model ProductionStopConfirmationApprover { + id String @id @default(cuid()) + userId String @unique + userName String + dingtalkUnionId String @default("") + isActive Boolean @default(true) + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + votes ProductionStopDemandConfirmationVote[] + + @@index([isActive, sortOrder]) +} + +// 停产需求「是否同意停产」确认票:一人一票,超时未反馈按默认同意记录来源 TIMEOUT。 + +model ProductionStopDemandConfirmationVote { + id String @id @default(cuid()) + demandId String + demand ProductionStopDemand @relation(fields: [demandId], references: [id], onDelete: Cascade) + approverId String + approver ProductionStopConfirmationApprover @relation(fields: [approverId], references: [id], onDelete: Restrict) + approverUserId String + approverName String + decision String @default("AGREED") + reason String @default("") @db.Text + source String @default("USER") + respondedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([demandId, approverId]) + @@index([demandId, decision]) + @@index([respondedAt]) +} + +// 停产流程负责人:负责「走流程」与强制停产确认,权限单独授予(系统管理员维护)。 + +model ProductionStopFlowOwner { + id String @id @default(cuid()) + userId String @unique + userName String + isActive Boolean @default(true) + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([isActive, sortOrder]) +} + +// 盘停建议配置:人工批量维护「条码 → 盘停建议」,提单时按条码自动带出。 + +model ProductionStopSuggestionConfig { + id String @id @default(cuid()) + barcode String @unique + suggestion String @default("") + updatedById String @default("") + updatedByName String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([updatedAt]) } model SupplyChainProductArchiveSource { @@ -3453,6 +3604,8 @@ enum OperationLogAction { JUSHUITAN_PRODUCT_PUSH JUSHUITAN_VIRTUAL_INVENTORY_PUSH SUPPLY_CHAIN_DROPSHIP_ISSUE_WRITE + PRODUCTION_STOP_DEMAND_DELETE + PRODUCTION_STOP_DEMAND_UPDATE } enum OperationLogStatus { @@ -3726,6 +3879,10 @@ model EmployeeHoursScoreRule { @@index([isActive, sortOrder]) } + + + + model WorkRecord { id String @id @default(cuid()) diff --git a/furniture-3d-system/scripts/finalize-production-stop-confirmations.ts b/furniture-3d-system/scripts/finalize-production-stop-confirmations.ts new file mode 100644 index 00000000..ff4cbd78 --- /dev/null +++ b/furniture-3d-system/scripts/finalize-production-stop-confirmations.ts @@ -0,0 +1,24 @@ +/** + * 停产需求确认收口脚本:把超过确认截止时间仍未反馈的需求单按「默认同意停产」落库。 + * + * 用法:npx tsx scripts/finalize-production-stop-confirmations.ts + * 建议:在生产服务器挂系统定时器,每天 16:05(北京时间)执行一次; + * 列表页读取时也会自动触发同样的收口逻辑,脚本用于无人访问时兜底。 + */ +import 'dotenv/config' +import { prisma } from '../src/lib/prisma' +import { productionStopDemandsService } from '../src/server/services/production-stop-demands.service' + +async function main() { + const finalized = await productionStopDemandsService.finalizeOverdueConfirmations() + console.log( + `[production-stop-confirmations] 超时未反馈按默认同意收口:${finalized} 张需求单`, + ) + await prisma.$disconnect() +} + +main().catch(async (error) => { + console.error('[production-stop-confirmations] FAILED:', error) + await prisma.$disconnect().catch(() => null) + process.exit(1) +}) diff --git a/furniture-3d-system/scripts/master-data-required-migrations.js b/furniture-3d-system/scripts/master-data-required-migrations.js index 431c864e..e87095f4 100644 --- a/furniture-3d-system/scripts/master-data-required-migrations.js +++ b/furniture-3d-system/scripts/master-data-required-migrations.js @@ -1702,6 +1702,29 @@ WHERE ns.nspname = 'public' }, ], }, + { + targetRelations: [ + '"ProductionStopDemand"', + '"ProductionStopDemandItem"', + '"ProductionStopReasonCategory"', + ], + path: 'prisma/migrations/20260930150000_add_production_stop_demands/migration.sql', + }, + { + targetRelations: [ + '"ProductionStopConfirmationApprover"', + '"ProductionStopDemandConfirmationVote"', + ], + path: 'prisma/migrations/20261007100000_add_production_stop_confirmations/migration.sql', + }, + { + targetRelations: ['"ProductionStopFlowOwner"'], + path: 'prisma/migrations/20261007180000_add_production_stop_demand_status/migration.sql', + }, + { + targetRelations: ['"ProductionStopSuggestionConfig"'], + path: 'prisma/migrations/20261007200000_add_production_stop_suggestion_config/migration.sql', + }, ] diff --git a/furniture-3d-system/scripts/sync-production-stop-flow-status.ts b/furniture-3d-system/scripts/sync-production-stop-flow-status.ts new file mode 100644 index 00000000..49b3c227 --- /dev/null +++ b/furniture-3d-system/scripts/sync-production-stop-flow-status.ts @@ -0,0 +1,26 @@ +/** + * 停产需求流程结果同步:校验已走流程需求的 PLM 流程状态。 + * - 流程明细 prostatus=2/3(条码恢复流程)→ 视为流程拒绝,需求自动驳回; + * - 产品全量档案状态变为「停产/退市」→ 流程审批通过。 + * + * 用法:npx tsx scripts/sync-production-stop-flow-status.ts + * 建议:生产挂系统定时器每天执行一次(次日验证口径),列表访问时另有 5 分钟节流兜底。 + */ +import 'dotenv/config' +import { prisma } from '../src/lib/prisma' +import { productionStopDemandsService } from '../src/server/services/production-stop-demands.service' + +async function main() { + const result = await productionStopDemandsService.syncFlowStatuses(100) + console.log( + `[production-stop-flow] 检查 ${result.checked} 张已走流程需求:驳回 ${result.rejected},审批通过 ${result.approved}` + + (result.failed ? '(流程数据源不可用,请稍后重试)' : ''), + ) + await prisma.$disconnect() +} + +main().catch(async (error) => { + console.error('[production-stop-flow] FAILED:', error) + await prisma.$disconnect().catch(() => null) + process.exit(1) +}) diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/approvers/[id]/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/approvers/[id]/route.ts new file mode 100644 index 00000000..6bc40dd0 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/approvers/[id]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +const patchSchema = z.object({ isActive: z.boolean() }).strict() + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + return handleRequest( + request, + async (ctx) => { + const { id } = await params + const body = await request.json().catch(() => null) + const parsed = patchSchema.safeParse(body) + if (!parsed.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '更新内容不正确', 400, ctx.requestId) + } + const updated = await productionStopDemandsService.updateConfirmationApprover( + id, + parsed.data, + ctx.user, + ) + return jsonSuccess(updated, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + return handleRequest( + request, + async (ctx) => { + const { id } = await params + const result = await productionStopDemandsService.deleteConfirmationApprover(id, ctx.user) + return jsonSuccess(result, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/approvers/candidates/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/approvers/candidates/route.ts new file mode 100644 index 00000000..50a53c3f --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/approvers/candidates/route.ts @@ -0,0 +1,15 @@ +import { NextRequest } from 'next/server' +import { handleRequest, jsonSuccess } from '@/server/core/response' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const search = (request.nextUrl.searchParams.get('search') ?? '').slice(0, 60) + const candidates = await productionStopDemandsService.searchApproverCandidates(search) + return jsonSuccess({ candidates }, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/approvers/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/approvers/route.ts new file mode 100644 index 00000000..09b22520 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/approvers/route.ts @@ -0,0 +1,37 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const approvers = await productionStopDemandsService.listConfirmationApprovers(ctx.user) + return jsonSuccess({ approvers }, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} + +const createSchema = z.object({ userId: z.string().trim().min(1) }).strict() + +export async function POST(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const body = await request.json().catch(() => null) + const parsed = createSchema.safeParse(body) + if (!parsed.success) { + return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择要添加的确认人', 400, ctx.requestId) + } + const created = await productionStopDemandsService.createConfirmationApprover( + parsed.data, + ctx.user, + ) + return jsonSuccess(created, 201, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/confirmations/push/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/confirmations/push/route.ts new file mode 100644 index 00000000..8df762c2 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/confirmations/push/route.ts @@ -0,0 +1,23 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +const pushSchema = z.object({ demandId: z.string().trim().min(1) }).strict() + +export async function POST(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const body = await request.json().catch(() => null) + const parsed = pushSchema.safeParse(body) + if (!parsed.success) { + return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '缺少停产需求', 400, ctx.requestId) + } + const result = await productionStopDemandsService.dispatchConfirmationPush(parsed.data.demandId) + return jsonSuccess(result, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/confirmations/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/confirmations/route.ts new file mode 100644 index 00000000..5c379d1a --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/confirmations/route.ts @@ -0,0 +1,68 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { PRODUCTION_STOP_CONFIRMATION_DECISIONS } from '@/lib/production-stop-demands' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' +import type { ProductionStopBatchConfirmResult } from '@/server/services/production-stop-demands.service' +import type { ProductionStopConfirmationInfo } from '@/lib/production-stop-demands' + +const voteSchema = z.object({ + demandId: z.string().trim().min(1).optional(), + demandIds: z.array(z.string().trim().min(1)).min(1).max(200).optional(), + decision: z.enum(PRODUCTION_STOP_CONFIRMATION_DECISIONS), + reason: z.string().trim().max(500).optional().default(''), +}).strict().refine((value) => Boolean(value.demandId) || Boolean(value.demandIds?.length), { + message: '缺少需求', +}) + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const demandId = (request.nextUrl.searchParams.get('demandId') ?? '').trim() + if (!demandId) { + return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '缺少停产需求', 400, ctx.requestId) + } + const info = await productionStopDemandsService.getConfirmationInfo(demandId, ctx.user) + return jsonSuccess(info, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} + +export async function POST(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const body = await request.json().catch(() => null) + const parsed = voteSchema.safeParse(body) + if (!parsed.success) { + return jsonError( + ErrorCode.VALIDATION_INVALID_FORMAT, + '确认内容不正确,请选择是否同意停产', + 400, + ctx.requestId, + ) + } + // 批量确认(勾选多条条码)与单条确认共用入口。 + const payload: ProductionStopConfirmationInfo | ProductionStopBatchConfirmResult = + parsed.data.demandIds?.length + ? await productionStopDemandsService.submitConfirmationsBatch( + parsed.data.demandIds, + parsed.data, + ctx.user, + ) + : await productionStopDemandsService.submitConfirmation( + { + demandId: parsed.data.demandId ?? '', + decision: parsed.data.decision, + reason: parsed.data.reason, + }, + ctx.user, + ) + return jsonSuccess(payload, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/demands/[demandId]/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/demands/[demandId]/route.ts new file mode 100644 index 00000000..27feaebb --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/demands/[demandId]/route.ts @@ -0,0 +1,39 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +const patchSchema = z.object({ + reasonCategory: z.string().trim().min(1).max(40), + reasonDetail: z.string().trim().max(500).optional().default(''), +}).strict() + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ demandId: string }> }, +) { + return handleRequest( + request, + async (ctx) => { + const { demandId } = await params + const body = await request.json().catch(() => null) + const parsed = patchSchema.safeParse(body) + if (!parsed.success) { + return jsonError( + ErrorCode.VALIDATION_INVALID_FORMAT, + '停产原因修改内容不正确,请从原因分类配置中重新选择', + 400, + ctx.requestId, + ) + } + const updated = await productionStopDemandsService.updateDemandReason( + demandId, + parsed.data, + ctx.user, + ) + return jsonSuccess(updated, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/demands/[demandId]/status/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/demands/[demandId]/status/route.ts new file mode 100644 index 00000000..1d3efcce --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/demands/[demandId]/status/route.ts @@ -0,0 +1,40 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +const bodySchema = z.object({ + action: z.enum(['RESUBMIT', 'FORCE_CONFIRM', 'MARK_FLOW_STARTED']), + note: z.string().trim().max(500).optional().default(''), +}).strict() + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ demandId: string }> }, +) { + return handleRequest( + request, + async (ctx) => { + const { demandId } = await params + const body = await request.json().catch(() => null) + const parsed = bodySchema.safeParse(body) + if (!parsed.success) { + return jsonError( + ErrorCode.VALIDATION_INVALID_FORMAT, + '需求状态操作不正确', + 400, + ctx.requestId, + ) + } + const { action, note } = parsed.data + const info = action === 'RESUBMIT' + ? await productionStopDemandsService.resubmitDemand(demandId, ctx.user) + : action === 'FORCE_CONFIRM' + ? await productionStopDemandsService.forceConfirmDemand(demandId, { note }, ctx.user) + : await productionStopDemandsService.markFlowStarted(demandId, { note }, ctx.user) + return jsonSuccess(info, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/demands/reason/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/demands/reason/route.ts new file mode 100644 index 00000000..031520a1 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/demands/reason/route.ts @@ -0,0 +1,36 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +const bodySchema = z.object({ + demandIds: z.array(z.string().trim().min(1)).min(1).max(200), + reasonCategory: z.string().trim().min(1).max(40), + reasonDetail: z.string().trim().max(500).optional().default(''), +}).strict() + +export async function POST(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const body = await request.json().catch(() => null) + const parsed = bodySchema.safeParse(body) + if (!parsed.success) { + return jsonError( + ErrorCode.VALIDATION_INVALID_FORMAT, + '批量修改停产原因内容不正确,请从原因分类配置中重新选择', + 400, + ctx.requestId, + ) + } + const result = await productionStopDemandsService.updateDemandReasonBatch( + parsed.data.demandIds, + parsed.data, + ctx.user, + ) + return jsonSuccess(result, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/demands/status/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/demands/status/route.ts new file mode 100644 index 00000000..13bab703 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/demands/status/route.ts @@ -0,0 +1,31 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +const bodySchema = z.object({ + demandIds: z.array(z.string().trim().min(1)).min(1).max(200), + action: z.literal('MARK_FLOW_STARTED'), + note: z.string().trim().max(500).optional().default(''), +}).strict() + +export async function POST(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const body = await request.json().catch(() => null) + const parsed = bodySchema.safeParse(body) + if (!parsed.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '批量走流程内容不正确', 400, ctx.requestId) + } + const result = await productionStopDemandsService.markFlowStartedBatch( + parsed.data.demandIds, + parsed.data.note, + ctx.user, + ) + return jsonSuccess(result, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/flow-owners/[id]/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/flow-owners/[id]/route.ts new file mode 100644 index 00000000..3623ffe7 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/flow-owners/[id]/route.ts @@ -0,0 +1,42 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +const patchSchema = z.object({ isActive: z.boolean() }).strict() + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + return handleRequest( + request, + async (ctx) => { + const { id } = await params + const body = await request.json().catch(() => null) + const parsed = patchSchema.safeParse(body) + if (!parsed.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '更新内容不正确', 400, ctx.requestId) + } + const updated = await productionStopDemandsService.updateFlowOwner(id, parsed.data, ctx.user) + return jsonSuccess(updated, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + return handleRequest( + request, + async (ctx) => { + const { id } = await params + const result = await productionStopDemandsService.deleteFlowOwner(id, ctx.user) + return jsonSuccess(result, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/flow-owners/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/flow-owners/route.ts new file mode 100644 index 00000000..3ef7ddb8 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/flow-owners/route.ts @@ -0,0 +1,34 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const owners = await productionStopDemandsService.listFlowOwners(ctx.user) + return jsonSuccess({ owners }, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} + +const createSchema = z.object({ userId: z.string().trim().min(1) }).strict() + +export async function POST(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const body = await request.json().catch(() => null) + const parsed = createSchema.safeParse(body) + if (!parsed.success) { + return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择要添加的流程负责人', 400, ctx.requestId) + } + const created = await productionStopDemandsService.createFlowOwner(parsed.data, ctx.user) + return jsonSuccess(created, 201, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/items/[itemId]/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/items/[itemId]/route.ts new file mode 100644 index 00000000..2faecc78 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/items/[itemId]/route.ts @@ -0,0 +1,37 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +const patchSchema = z.object({ + replaceType: z.string().trim().max(16).optional().default(''), + replaceBarcode: z.string().trim().max(160).optional().default(''), + imageChange: z.string().trim().max(16).optional().default(''), + modificationPoint: z.string().trim().max(500).optional().default(''), +}) + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ itemId: string }> }, +) { + return handleRequest( + request, + async (ctx) => { + const { itemId } = await params + const body = await request.json().catch(() => null) + const parsed = patchSchema.safeParse(body) + if (!parsed.success) { + return jsonError( + ErrorCode.VALIDATION_INVALID_FORMAT, + '替换信息填写不正确', + 400, + ctx.requestId, + ) + } + const item = await productionStopDemandsService.updateItem(itemId, parsed.data) + return jsonSuccess(item, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/items/bulk-delete/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/items/bulk-delete/route.ts new file mode 100644 index 00000000..9f0735f2 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/items/bulk-delete/route.ts @@ -0,0 +1,30 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +const bulkDeleteSchema = z.object({ + ids: z.array(z.string().trim().min(1)).min(1).max(200), +}).strict() + +export async function POST(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const body = await request.json().catch(() => null) + const parsed = bulkDeleteSchema.safeParse(body) + if (!parsed.success) { + return jsonError( + ErrorCode.VALIDATION_INVALID_FORMAT, + '批量删除内容不正确', + 400, + ctx.requestId, + ) + } + const result = await productionStopDemandsService.deleteItems(parsed.data, ctx.user) + return jsonSuccess(result, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/reason-categories/[id]/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/reason-categories/[id]/route.ts new file mode 100644 index 00000000..205a444e --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/reason-categories/[id]/route.ts @@ -0,0 +1,54 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +const patchSchema = z.object({ + name: z.string().trim().min(1).max(40).optional(), + isActive: z.boolean().optional(), +}) + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + return handleRequest( + request, + async (ctx) => { + const { id } = await params + const body = await request.json().catch(() => null) + const parsed = patchSchema.safeParse(body) + if (!parsed.success) { + return jsonError( + ErrorCode.VALIDATION_INVALID_FORMAT, + '停产原因分类更新内容不正确', + 400, + ctx.requestId, + ) + } + const updated = await productionStopDemandsService.updateReasonCategory( + id, + parsed.data, + ctx.user, + ) + return jsonSuccess(updated, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + return handleRequest( + request, + async (ctx) => { + const { id } = await params + const result = await productionStopDemandsService.deleteReasonCategory(id, ctx.user) + return jsonSuccess(result, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/reason-categories/bulk/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/reason-categories/bulk/route.ts new file mode 100644 index 00000000..5b7c4cdd --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/reason-categories/bulk/route.ts @@ -0,0 +1,28 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +const bodySchema = z.object({ + names: z.array(z.string().trim().min(1).max(40)).min(1).max(200), +}).strict() + +export async function POST(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const body = await request.json().catch(() => null) + const parsed = bodySchema.safeParse(body) + if (!parsed.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '批量添加内容不正确', 400, ctx.requestId) + } + const result = await productionStopDemandsService.createReasonCategoriesBatch( + parsed.data, + ctx.user, + ) + return jsonSuccess(result, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/reason-categories/delete/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/reason-categories/delete/route.ts new file mode 100644 index 00000000..75fd9fe4 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/reason-categories/delete/route.ts @@ -0,0 +1,26 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +const bodySchema = z.object({ ids: z.array(z.string().trim().min(1)).min(1).max(200) }).strict() + +export async function POST(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const body = await request.json().catch(() => null) + const parsed = bodySchema.safeParse(body) + if (!parsed.success) { + return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请先勾选要删除的分类', 400, ctx.requestId) + } + const result = await productionStopDemandsService.deleteReasonCategoriesBatch( + parsed.data.ids, + ctx.user, + ) + return jsonSuccess(result, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/reason-categories/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/reason-categories/route.ts new file mode 100644 index 00000000..0848a1ff --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/reason-categories/route.ts @@ -0,0 +1,48 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + // 默认只给需求人返回启用中的分类;配置弹窗带 all=1 取全部(仅系统管理员)。 + const includeInactive = request.nextUrl.searchParams.get('all') === '1' + const categories = await productionStopDemandsService.listReasonCategories(ctx.user, { + includeInactive, + }) + return jsonSuccess({ categories }, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} + +const createSchema = z.object({ + name: z.string().trim().min(1).max(40), +}) + +export async function POST(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const body = await request.json().catch(() => null) + const parsed = createSchema.safeParse(body) + if (!parsed.success) { + return jsonError( + ErrorCode.VALIDATION_INVALID_FORMAT, + '停产原因分类名称不正确', + 400, + ctx.requestId, + ) + } + const created = await productionStopDemandsService.createReasonCategory( + parsed.data, + ctx.user, + ) + return jsonSuccess(created, 201, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/route.ts new file mode 100644 index 00000000..f8319ecb --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/route.ts @@ -0,0 +1,118 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { + PRODUCTION_STOP_CONFIRMATION_STATUSES, + PRODUCTION_STOP_DEMAND_STATUSES, + isProductionStopItemSortKey, + type ProductionStopItemSortKey, +} from '@/lib/production-stop-demands' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +const listQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(200).default(50), + keyword: z.string().trim().max(160).optional(), + confirmationStatus: z.enum(PRODUCTION_STOP_CONFIRMATION_STATUSES).optional(), + demandStatus: z.enum(PRODUCTION_STOP_DEMAND_STATUSES).optional(), + demandStatuses: z.string().trim().max(240).optional(), + reasonCategory: z.string().trim().max(60).optional(), + purchaseOwner: z.string().trim().max(60).optional(), + requesterName: z.string().trim().max(60).optional(), + dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + sortKey: z.string().trim().max(64).optional(), + sortDirection: z.enum(['asc', 'desc']).optional(), +}) + +// 已走流程的需求:列表访问时按 5 分钟节流刷新一次 PLM 流程结果(生产另有定时脚本兜底)。 +let lastFlowSyncAt = 0 +const FLOW_SYNC_INTERVAL_MS = 5 * 60 * 1000 + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const parsed = listQuerySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams.entries()), + ) + if ( + !parsed.success || + (parsed.data.sortKey && !isProductionStopItemSortKey(parsed.data.sortKey)) + ) { + return jsonError( + ErrorCode.VALIDATION_INVALID_FORMAT, + '筛选条件不正确', + 400, + ctx.requestId, + ) + } + const access = await productionStopDemandsService.resolveListAccess(ctx.user) + if (!access.canView && !access.isApprover) { + return jsonError( + ErrorCode.AUTH_PERMISSION_DENIED, + '当前账号没有该供应链功能的权限', + 403, + ctx.requestId, + ) + } + if (Date.now() - lastFlowSyncAt > FLOW_SYNC_INTERVAL_MS) { + lastFlowSyncAt = Date.now() + void productionStopDemandsService.syncFlowStatuses(30).catch((error) => { + console.error('[production-stop-demands] flow sync failed', error) + }) + } + const demandStatuses = (parsed.data.demandStatuses ?? '') + .split(',') + .map((value) => value.trim()) + .filter((value): value is (typeof PRODUCTION_STOP_DEMAND_STATUSES)[number] => + (PRODUCTION_STOP_DEMAND_STATUSES as readonly string[]).includes(value), + ) + const result = await productionStopDemandsService.listPage({ + keyword: parsed.data.keyword || undefined, + confirmationStatus: parsed.data.confirmationStatus, + demandStatus: parsed.data.demandStatus, + demandStatuses: demandStatuses.length ? demandStatuses : undefined, + reasonCategory: parsed.data.reasonCategory || undefined, + purchaseOwner: parsed.data.purchaseOwner || undefined, + requesterName: parsed.data.requesterName || undefined, + dateFrom: parsed.data.dateFrom || undefined, + dateTo: parsed.data.dateTo || undefined, + sortKey: parsed.data.sortKey as ProductionStopItemSortKey | undefined, + sortDirection: parsed.data.sortDirection, + page: parsed.data.page, + pageSize: parsed.data.pageSize, + }) + return jsonSuccess(result, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} + +const createSchema = z.object({ + reasonCategory: z.string().trim().min(1).max(40), + reasonDetail: z.string().trim().max(500).optional().default(''), + barcodesText: z.string().min(1).max(20000), +}) + +export async function POST(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const body = await request.json().catch(() => null) + const parsed = createSchema.safeParse(body) + if (!parsed.success) { + return jsonError( + ErrorCode.VALIDATION_INVALID_FORMAT, + '提交内容不正确,请检查原因分类与条码', + 400, + ctx.requestId, + ) + } + const result = await productionStopDemandsService.createDemand(parsed.data, ctx.user) + return jsonSuccess(result, 201, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/suggestions/[id]/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/suggestions/[id]/route.ts new file mode 100644 index 00000000..b8797dbf --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/suggestions/[id]/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from 'next/server' +import { handleRequest, jsonSuccess } from '@/server/core/response' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + return handleRequest( + request, + async (ctx) => { + const { id } = await params + const result = await productionStopDemandsService.deleteSuggestionConfig(id) + return jsonSuccess(result, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/suggestions/delete/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/suggestions/delete/route.ts new file mode 100644 index 00000000..3173e429 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/suggestions/delete/route.ts @@ -0,0 +1,23 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +const bodySchema = z.object({ ids: z.array(z.string().trim().min(1)).min(1).max(500) }).strict() + +export async function POST(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const body = await request.json().catch(() => null) + const parsed = bodySchema.safeParse(body) + if (!parsed.success) { + return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请先勾选要删除的配置', 400, ctx.requestId) + } + const result = await productionStopDemandsService.deleteSuggestionConfigs(parsed.data.ids) + return jsonSuccess(result, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/suggestions/export/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/suggestions/export/route.ts new file mode 100644 index 00000000..f5c16262 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/suggestions/export/route.ts @@ -0,0 +1,15 @@ +import { NextRequest } from 'next/server' +import { handleRequest, jsonSuccess } from '@/server/core/response' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const keyword = (request.nextUrl.searchParams.get('keyword') ?? '').slice(0, 120) + const result = await productionStopDemandsService.exportSuggestionConfigs(keyword) + return jsonSuccess(result, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/suggestions/item/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/suggestions/item/route.ts new file mode 100644 index 00000000..988a14e9 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/suggestions/item/route.ts @@ -0,0 +1,26 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +const bodySchema = z.object({ + barcode: z.string().trim().min(1).max(160), + suggestion: z.string().trim().max(60).optional().default(''), +}).strict() + +export async function POST(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const body = await request.json().catch(() => null) + const parsed = bodySchema.safeParse(body) + if (!parsed.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '条码或盘停建议不正确', 400, ctx.requestId) + } + const result = await productionStopDemandsService.saveSuggestionConfig(parsed.data, ctx.user) + return jsonSuccess(result, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/suggestions/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/suggestions/route.ts new file mode 100644 index 00000000..c3abdaa3 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/suggestions/route.ts @@ -0,0 +1,83 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +const querySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(200).default(50), + keyword: z.string().trim().max(120).optional(), + barcode: z.string().trim().max(160).optional(), + suggestion: z.string().trim().max(60).optional(), +}) + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const parsed = querySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams.entries()), + ) + if (!parsed.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '查询条件不正确', 400, ctx.requestId) + } + const result = await productionStopDemandsService.listSuggestionConfigs({ + page: parsed.data.page, + pageSize: parsed.data.pageSize, + keyword: parsed.data.keyword, + barcode: parsed.data.barcode, + suggestion: parsed.data.suggestion, + }) + return jsonSuccess( + { + rows: result.rows.map((row) => ({ + id: row.id, + barcode: row.barcode, + suggestion: row.suggestion, + updatedByName: row.updatedByName, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + })), + page: parsed.data.page, + pageSize: parsed.data.pageSize, + total: result.total, + totalPages: Math.max(1, Math.ceil(result.total / parsed.data.pageSize)), + }, + 200, + ctx.requestId, + ) + }, + { requireAuth: true }, + ) +} + +const importSchema = z.object({ + text: z.string().min(1).max(200000), + mode: z.enum(['SKIP', 'UPDATE', 'REPLACE']).optional().default('UPDATE'), +}).strict() + +export async function POST(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const body = await request.json().catch(() => null) + const parsed = importSchema.safeParse(body) + if (!parsed.success) { + return jsonError( + ErrorCode.VALIDATION_REQUIRED_FIELD, + '请粘贴要导入的「条码,盘停建议」数据', + 400, + ctx.requestId, + ) + } + const result = await productionStopDemandsService.importSuggestionConfigs( + parsed.data.text, + ctx.user, + parsed.data.mode, + ) + return jsonSuccess(result, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/supply-chain/components/SupplyChainSidebar.test.tsx b/furniture-3d-system/src/app/supply-chain/components/SupplyChainSidebar.test.tsx index 91841fd6..299d109d 100644 --- a/furniture-3d-system/src/app/supply-chain/components/SupplyChainSidebar.test.tsx +++ b/furniture-3d-system/src/app/supply-chain/components/SupplyChainSidebar.test.tsx @@ -76,4 +76,16 @@ describe('SupplyChainSidebar', () => { screen.getByRole('link', { name: '物流到货签收时长看板' }), ).toHaveAttribute('aria-current', 'page') }) + + it('shows production stop demands under business management', () => { + usePathnameMock.mockReturnValue( + '/supply-chain/management/production-stop-demands', + ) + + render() + + expect( + screen.getByRole('link', { name: '停产需求管理' }), + ).toHaveAttribute('aria-current', 'page') + }) }) diff --git a/furniture-3d-system/src/app/supply-chain/components/supply-chain-tabs.tsx b/furniture-3d-system/src/app/supply-chain/components/supply-chain-tabs.tsx index 46d0a23d..06f66ce5 100644 --- a/furniture-3d-system/src/app/supply-chain/components/supply-chain-tabs.tsx +++ b/furniture-3d-system/src/app/supply-chain/components/supply-chain-tabs.tsx @@ -110,6 +110,7 @@ export const SUPPLY_CHAIN_ROUTE_TITLES: Record = { '/supply-chain/purchase/price-change': '产品变价', '/supply-chain/management/employee-hours': '员工工时统计', '/supply-chain/management/logistics-arrival-signing-duration': '物流到货签收时长看板', + '/supply-chain/management/production-stop-demands': '停产需求管理', '/supply-chain/script-monitor': '脚本监控', '/supply-chain/operation-logs': '操作日志', '/supply-chain/user-permissions': '用户权限', diff --git a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts index 383aa2a8..4f303668 100644 --- a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts +++ b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts @@ -150,4 +150,14 @@ describe('supply chain menu', () => { })]), ) }) + + it('places production stop demands under business management', () => { + expect(flattenSupplyChainMenu(filterSupplyChainMenu(['businessManagement']))).toEqual( + expect.arrayContaining([expect.objectContaining({ + label: '停产需求管理', + module: '经营管理', + route: '/supply-chain/management/production-stop-demands', + })]), + ) + }) }) diff --git a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts index 0224f5b3..98e1aedf 100644 --- a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts +++ b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts @@ -103,6 +103,11 @@ export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [ route: '/supply-chain/management/custom-table', targetKey: 'businessManagement', }, + { + label: '停产需求管理', + route: '/supply-chain/management/production-stop-demands', + targetKey: 'businessManagement', + }, ...MANAGEMENT_BLANK_PAGES.map((page) => ({ label: page.label, route: page.route, diff --git a/furniture-3d-system/src/app/supply-chain/management/production-stop-demands/ProductionStopDemandsView.test.tsx b/furniture-3d-system/src/app/supply-chain/management/production-stop-demands/ProductionStopDemandsView.test.tsx new file mode 100644 index 00000000..f6c43405 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/management/production-stop-demands/ProductionStopDemandsView.test.tsx @@ -0,0 +1,263 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { + PRODUCTION_STOP_ITEM_COLUMNS, + type ProductionStopItemRow, +} from '@/lib/production-stop-demands' +import { ProductionStopDemandsView } from './ProductionStopDemandsView' + +const emptyPage = { + rows: [], + page: 1, + pageSize: 50, + total: 0, + totalPages: 1, + filterOptions: { reasonCategories: [], purchaseOwners: [], requesterNames: [] }, +} + +function itemRow(overrides: Partial): ProductionStopItemRow { + return { + id: 'item-1', + demandId: 'demand-1', + demandNo: 'TC20260930-001', + submittedAt: '2026-09-30T02:00:00.000Z', + requesterName: '员工1', + department: '测试部', + reasonCategory: '滞销款', + reasonDetail: '', + barcode: 'A1', + categoryL1: '住宅家具', + categoryL2: '书架', + spu: 'SPU1', + sku: 'SKU1', + productName: '商品1', + productStatus: '销售', + supplierName: '供应商1', + purchaseOwner: '采购1', + barcodeCreatedAt: '2026-01-01T00:00:00.000Z', + brand: '品牌1', + productTag: '仓库-崔桥工厂仓', + productLabel: '', + postStopSkuStatus: '非销售', + sales30dWithBig: '', + sales30dWithBigSku: '', + opsDirector: '', + topSalesOpsDirector: '', + stopSuggestion: '', + replaceType: '', + replaceBarcode: '', + imageChange: '', + modificationPoint: '', + confirmationStatus: 'PENDING', + confirmationDeadline: '2026-09-30T08:00:00.000Z', + confirmationAt: '', + confirmationReasons: '', + confirmationVoteCount: 0, + demandStatus: 'PENDING_CONFIRMATION', + demandStatusAt: '2026-09-30T02:00:00.000Z', + demandStatusNote: '', + ...overrides, + } +} + +describe('停产需求管理视图', () => { + it('只读用户看到列表与空状态,不显示新建和配置入口', () => { + render( + , + ) + + expect(screen.getByRole('navigation', { name: '停产需求处理全流程' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /提需求/ })).toBeInTheDocument() + expect(screen.getByText('暂无停产需求')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /新建停产需求/ })).toBeNull() + expect(screen.queryByRole('button', { name: /原因分类配置/ })).toBeNull() + }) + + it('可编辑用户显示新建入口,管理员显示原因分类配置', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: /新建停产需求/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /原因分类配置/ })).toBeInTheDocument() + }) + + it('每个字段列都提供列宽拖拽手柄', () => { + render( + , + ) + + expect(screen.getAllByRole('separator')).toHaveLength(PRODUCTION_STOP_ITEM_COLUMNS.length) + expect(screen.getByRole('separator', { name: '调整条码列宽' })).toBeInTheDocument() + }) + + it('勾选单行或多行后出现删除入口', () => { + render( + , + ) + + expect(screen.queryByRole('button', { name: /删除选中/ })).toBeNull() + + fireEvent.click(screen.getByRole('checkbox', { name: '选择A1' })) + expect(screen.getByRole('button', { name: /删除选中(1)/ })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('checkbox', { name: '选择A2' })) + expect(screen.getByRole('button', { name: /删除选中(2)/ })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('checkbox', { name: '全选本页' })) + expect(screen.queryByRole('button', { name: /删除选中/ })).toBeNull() + }) + + it('删除使用页面内确认框,不依赖浏览器原生弹窗', async () => { + const confirmSpy = jest.spyOn(window, 'confirm') + const fetchMock = jest.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/items/bulk-delete')) { + return { + ok: true, + json: async () => ({ success: true, data: { deleted: 1, deletedDemands: 1 } }), + } + } + return { ok: true, json: async () => ({ success: true, data: emptyPage }) } + }) + global.fetch = fetchMock as unknown as typeof fetch + + render( + , + ) + + fireEvent.click(screen.getByRole('checkbox', { name: '选择A1' })) + fireEvent.click(screen.getByRole('button', { name: /删除选中(1)/ })) + + expect(confirmSpy).not.toHaveBeenCalled() + const dialog = screen.getByRole('dialog', { name: '删除停产需求明细' }) + expect(within(dialog).getByText(/确定删除选中的 1 条停产需求明细/)).toBeInTheDocument() + + fireEvent.click(within(dialog).getByRole('button', { name: '删除 1 条' })) + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + '/api/supply-chain/management/production-stop-demands/items/bulk-delete', + expect.objectContaining({ method: 'POST' }), + ) + }) + expect(await screen.findByText(/已删除 1 条停产需求明细/)).toBeInTheDocument() + confirmSpy.mockRestore() + }) + + it('原因分类不在配置中时给出提示与解决办法', async () => { + const fetchMock = jest.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/reason-categories')) { + return { + ok: true, + json: async () => ({ + success: true, + data: { + categories: [ + { id: 'c1', name: '滞销款', sortOrder: 10, isActive: true, usedByDemandCount: 0 }, + ], + }, + }), + } + } + if (url.includes('/confirmations')) { + return { + ok: true, + json: async () => ({ + success: true, + data: { + demandId: 'demand-1', + demandNo: 'TC20260930-001', + status: 'PENDING', + demandStatus: 'PENDING_CONFIRMATION', + demandStatusAt: '2026-09-30T02:00:00.000Z', + demandStatusNote: '', + deadline: '', + confirmedAt: '', + reasons: '', + pushedAt: '', + pushMessage: '', + canConfirm: false, + myDecision: '', + myReason: '', + votes: [], + approvers: [], + }, + }), + } + } + return { ok: true, json: async () => ({ success: true, data: emptyPage }) } + }) + global.fetch = fetchMock as unknown as typeof fetch + + render( + , + ) + + fireEvent.click(screen.getByText('A1')) + expect( + await screen.findByText(/不在原因分类配置中,请找系统管理员处理/), + ).toBeInTheDocument() + expect(screen.getByRole('button', { name: '保存停产原因' })).toBeInTheDocument() + // 条码档案里应包含条码字段,并提供一键复制 + const dialog = screen.getByRole('dialog', { name: '停产需求明细' }) + expect(within(dialog).getByText('条码')).toBeInTheDocument() + expect(within(dialog).getByRole('button', { name: '复制条码' })).toBeInTheDocument() + }) + + it('确认人搜索输入即搜,搜不到时给出指引', async () => { + const fetchMock = jest.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/approvers/candidates')) { + return { ok: true, json: async () => ({ success: true, data: { candidates: [] } }) } + } + if (url.includes('/approvers')) { + return { ok: true, json: async () => ({ success: true, data: { approvers: [] } }) } + } + if (url.includes('/flow-owners')) { + return { ok: true, json: async () => ({ success: true, data: { owners: [] } }) } + } + return { ok: true, json: async () => ({ success: true, data: emptyPage }) } + }) + global.fetch = fetchMock as unknown as typeof fetch + + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: /确认人配置/ })) + const input = await screen.findByPlaceholderText(/搜索姓名或邮箱/) + fireEvent.change(input, { target: { value: '海棠' } }) + + expect(await screen.findByText(/没有找到“海棠”/, {}, { timeout: 3000 })).toBeInTheDocument() + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('search=%E6%B5%B7%E6%A3%A0'), + expect.anything(), + ) + }) + }) +}) diff --git a/furniture-3d-system/src/app/supply-chain/management/production-stop-demands/ProductionStopDemandsView.tsx b/furniture-3d-system/src/app/supply-chain/management/production-stop-demands/ProductionStopDemandsView.tsx new file mode 100644 index 00000000..fe35b8e4 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/management/production-stop-demands/ProductionStopDemandsView.tsx @@ -0,0 +1,3822 @@ +'use client' + +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ChangeEvent, + type FormEvent, + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent, +} from 'react' +import { + AlertCircle, + ArrowDown, + ArrowUp, + ArrowUpDown, + CheckCircle2, + ClipboardList, + Check, + ChevronLeft, + ChevronRight, + Copy, + Download, + Route, + RefreshCw, + Plus, + Search, + Send, + Settings2, + Trash2, + Upload, + X, +} from 'lucide-react' +import { + PRODUCTION_STOP_CONFIRMATION_LABELS, + PRODUCTION_STOP_CONFIRMATION_SOURCE_LABELS, + PRODUCTION_STOP_DEMAND_STATUS_LABELS, + PRODUCTION_STOP_DEMAND_STATUS_TONES, + PRODUCTION_STOP_IMAGE_CHANGE_OPTIONS, + PRODUCTION_STOP_ITEM_COLUMNS, + PRODUCTION_STOP_REPLACE_TYPES, + parseBarcodeInput, + type ProductionStopConfirmationApproverOption, + type ProductionStopConfirmationInfo, + type ProductionStopConfirmationStatus, + type ProductionStopDemandStatus, + type ProductionStopFlowOwnerOption, + type ProductionStopItemPage, + type ProductionStopItemSortKey, + type ProductionStopItemRow, + type ProductionStopReasonCategoryOption, +} from '@/lib/production-stop-demands' +import styles from './production-stop-demands.module.css' + +type CreateResult = { + batchNo: string + demandIds: string[] + demandNos: string[] + accepted: string[] + rejected: Array<{ barcode: string; reason: string }> + warnings: Array<{ barcode: string; message: string }> + pushMessage: string +} + +type ApproverCandidate = { + id: string + name: string + email: string + dingtalkUnionId: string + isApprover: boolean + isFlowOwner: boolean +} + +type ConfirmState = { + title: string + message: string + confirmLabel: string + needsNote?: boolean + action: () => Promise | void +} + +type AppliedFilters = { + keyword: string + confirmationStatus: '' | ProductionStopConfirmationStatus + demandStatus: '' | ProductionStopDemandStatus + demandStatuses: ProductionStopDemandStatus[] + reasonCategory: string + purchaseOwner: string + requesterName: string + sortKey: ProductionStopItemSortKey | '' + sortDirection: 'asc' | 'desc' + dateFrom: string + dateTo: string +} + +const EMPTY_FILTERS: AppliedFilters = { + keyword: '', + confirmationStatus: '', + demandStatus: '', + demandStatuses: [], + reasonCategory: '', + purchaseOwner: '', + requesterName: '', + sortKey: '', + sortDirection: 'desc', + dateFrom: '', + dateTo: '', +} + +const PAGE_SIZE = 50 +const MIN_COLUMN_WIDTH = 70 +const MAX_COLUMN_WIDTH = 720 +const CHECKBOX_COLUMN_WIDTH = 44 +// 停产需求处理全流程步骤:点击任一步骤即筛选该阶段的需求。 +const PROCESS_STEPS: Array<{ + key: string + label: string + icon: typeof ClipboardList + statuses: ProductionStopDemandStatus[] +}> = [ + { key: 'demand', label: '提需求', icon: ClipboardList, statuses: [] }, + { key: 'confirm', label: '需求确认', icon: CheckCircle2, statuses: ['PENDING_CONFIRMATION'] }, + { + key: 'flow', + label: '走停产流程', + icon: Route, + statuses: ['CONFIRMED_WAIT_FLOW', 'FLOW_IN_APPROVAL'], + }, + { + key: 'result', + label: '流程结果跟进同步', + icon: RefreshCw, + statuses: ['FLOW_APPROVED', 'REJECTED'], + }, +] +const COLUMN_WIDTHS_STORAGE_KEY = 'supply-chain.production-stop-demands.column-widths.v1' +const EMPTY_DETAIL_DRAFT = { + replaceType: '', + replaceBarcode: '', + imageChange: '', + modificationPoint: '', +} + +const COLUMN_GROUPS = PRODUCTION_STOP_ITEM_COLUMNS.reduce< + Array<{ group: string; span: number }> +>((groups, column) => { + const last = groups[groups.length - 1] + if (last && last.group === column.group) last.span += 1 + else groups.push({ group: column.group, span: 1 }) + return groups +}, []) + +async function requestJson(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { credentials: 'include', ...init }) + const payload = (await response.json().catch(() => null)) as { + success?: boolean + data?: T + error?: { message?: string } + } | null + if (!response.ok || !payload?.success) { + throw new Error(payload?.error?.message || '请求失败,请稍后重试') + } + return payload.data as T +} + +function formatDateTime(value: string) { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date) +} + +function confirmationBadgeClass(status: ProductionStopConfirmationStatus) { + if (status === 'AGREED') return `${styles.badge} ${styles.badgeAgreed}` + if (status === 'REJECTED') return `${styles.badge} ${styles.badgeRejected}` + return `${styles.badge} ${styles.badgePending}` +} + +function demandStatusBadgeClass(status: ProductionStopDemandStatus) { + const tone = PRODUCTION_STOP_DEMAND_STATUS_TONES[status] + if (tone === 'agreed') return `${styles.badge} ${styles.badgeAgreed}` + if (tone === 'rejected') return `${styles.badge} ${styles.badgeRejected}` + if (tone === 'flow') return `${styles.badge} ${styles.badgeFlow}` + return `${styles.badge} ${styles.badgePending}` +} + +function filterParams(filters: AppliedFilters, page: number) { + const params = new URLSearchParams({ page: String(page), pageSize: String(PAGE_SIZE) }) + if (filters.keyword) params.set('keyword', filters.keyword) + if (filters.confirmationStatus) params.set('confirmationStatus', filters.confirmationStatus) + if (filters.demandStatus) params.set('demandStatus', filters.demandStatus) + if (filters.demandStatuses.length) params.set('demandStatuses', filters.demandStatuses.join(',')) + if (filters.reasonCategory) params.set('reasonCategory', filters.reasonCategory) + if (filters.purchaseOwner) params.set('purchaseOwner', filters.purchaseOwner) + if (filters.requesterName) params.set('requesterName', filters.requesterName) + if (filters.sortKey) { + params.set('sortKey', filters.sortKey) + params.set('sortDirection', filters.sortDirection) + } + if (filters.dateFrom) params.set('dateFrom', filters.dateFrom) + if (filters.dateTo) params.set('dateTo', filters.dateTo) + return params +} + +export function ProductionStopDemandsView({ + initialData, + canEdit, + isAdmin, + isFlowOwner, + isApprover, +}: { + initialData: ProductionStopItemPage + canEdit: boolean + isAdmin: boolean + isFlowOwner: boolean + isApprover: boolean +}) { + const [data, setData] = useState(initialData) + const [page, setPage] = useState(initialData.page) + const [filters, setFilters] = useState(EMPTY_FILTERS) + const [keywordDraft, setKeywordDraft] = useState('') + const [requesterDraft, setRequesterDraft] = useState('') + const [purchaseDraft, setPurchaseDraft] = useState('') + const [dateFromDraft, setDateFromDraft] = useState('') + const [dateToDraft, setDateToDraft] = useState('') + const [loading, setLoading] = useState(false) + const [loadError, setLoadError] = useState('') + const skipNextLoad = useRef(true) + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [deleteBusy, setDeleteBusy] = useState(false) + const [actionError, setActionError] = useState('') + const [notice, setNotice] = useState('') + const [confirmState, setConfirmState] = useState(null) + const [confirmBusy, setConfirmBusy] = useState(false) + const [batchReasonOpen, setBatchReasonOpen] = useState(false) + const [batchReasonDraft, setBatchReasonDraft] = useState({ reasonCategory: '', reasonDetail: '' }) + const [batchReasonBusy, setBatchReasonBusy] = useState(false) + const [batchReasonError, setBatchReasonError] = useState('') + const [suggestionsOpen, setSuggestionsOpen] = useState(false) + const [suggestionText, setSuggestionText] = useState('') + const [suggestionRows, setSuggestionRows] = useState>([]) + const [suggestionPage, setSuggestionPage] = useState(1) + const [suggestionTotal, setSuggestionTotal] = useState(0) + const [suggestionPages, setSuggestionPages] = useState(1) + const [suggestionSelected, setSuggestionSelected] = useState>(new Set()) + const [suggestionBarcodeFilter, setSuggestionBarcodeFilter] = useState('') + const [suggestionValueFilter, setSuggestionValueFilter] = useState('') + const [suggestionEditOpen, setSuggestionEditOpen] = useState(false) + const [suggestionEditDraft, setSuggestionEditDraft] = useState({ barcode: '', suggestion: '' }) + const [suggestionEditing, setSuggestionEditing] = useState(false) + const [suggestionEditError, setSuggestionEditError] = useState('') + const [suggestionImportOpen, setSuggestionImportOpen] = useState(false) + const [suggestionWidths, setSuggestionWidths] = useState({ createdAt: 170, barcode: 220, suggestion: 260 }) + const suggestionResizeRef = useRef<{ key: keyof typeof suggestionWidths; clientX: number; width: number } | null>(null) + const [suggestionError, setSuggestionError] = useState('') + const [suggestionNotice, setSuggestionNotice] = useState('') + const [suggestionBusy, setSuggestionBusy] = useState(false) + const [columnWidths, setColumnWidths] = useState>(() => + Object.fromEntries( + PRODUCTION_STOP_ITEM_COLUMNS.map((column) => [column.key, column.width]), + ), + ) + const columnResizeRef = useRef<{ key: string; clientX: number; width: number } | null>(null) + const widthsHydrated = useRef(false) + + useEffect(() => { + try { + const raw = window.localStorage.getItem(COLUMN_WIDTHS_STORAGE_KEY) + if (raw) { + const parsed = JSON.parse(raw) as Record + setColumnWidths((current) => { + const next = { ...current } + for (const column of PRODUCTION_STOP_ITEM_COLUMNS) { + const value = Number(parsed[column.key]) + if (Number.isFinite(value) && value >= MIN_COLUMN_WIDTH && value <= MAX_COLUMN_WIDTH) { + next[column.key] = Math.round(value) + } + } + return next + }) + } + } catch { + // 本地列宽缓存不可用时忽略,继续使用默认列宽。 + } finally { + widthsHydrated.current = true + } + }, []) + + useEffect(() => { + if (!widthsHydrated.current) return + try { + window.localStorage.setItem(COLUMN_WIDTHS_STORAGE_KEY, JSON.stringify(columnWidths)) + } catch { + // 本地存储不可用时忽略列宽记忆。 + } + }, [columnWidths]) + + const tableWidth = + CHECKBOX_COLUMN_WIDTH + + PRODUCTION_STOP_ITEM_COLUMNS.reduce( + (total, column) => total + (columnWidths[column.key] ?? column.width), + 0, + ) + + const handleColumnResizeStart = ( + event: ReactPointerEvent, + key: string, + ) => { + event.preventDefault() + event.stopPropagation() + event.currentTarget.setPointerCapture(event.pointerId) + columnResizeRef.current = { key, clientX: event.clientX, width: columnWidths[key] ?? 120 } + } + + const handleColumnResizeMove = (event: ReactPointerEvent) => { + const start = columnResizeRef.current + if (!start) return + const nextWidth = Math.min( + MAX_COLUMN_WIDTH, + Math.max(MIN_COLUMN_WIDTH, start.width + event.clientX - start.clientX), + ) + setColumnWidths((current) => ({ ...current, [start.key]: Math.round(nextWidth) })) + } + + const handleColumnResizeEnd = (event: ReactPointerEvent) => { + columnResizeRef.current = null + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + } + + const handleColumnResizeKeyDown = ( + event: ReactKeyboardEvent, + key: string, + ) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return + event.preventDefault() + setColumnWidths((current) => ({ + ...current, + [key]: Math.min( + MAX_COLUMN_WIDTH, + Math.max( + MIN_COLUMN_WIDTH, + (current[key] ?? 120) + (event.key === 'ArrowRight' ? 10 : -10), + ), + ), + })) + } + + const resetColumnWidth = (key: string) => { + const fallback = PRODUCTION_STOP_ITEM_COLUMNS.find((column) => column.key === key)?.width ?? 120 + setColumnWidths((current) => ({ ...current, [key]: fallback })) + } + + const load = useCallback(async (targetPage: number, activeFilters: AppliedFilters) => { + setLoading(true) + setLoadError('') + setNotice('') + setActionError('') + try { + const params = filterParams(activeFilters, targetPage) + const result = await requestJson( + `/api/supply-chain/management/production-stop-demands?${params.toString()}`, + ) + setData(result) + setSelectedIds(new Set()) + } catch (error) { + setLoadError(error instanceof Error ? error.message : '加载失败,请稍后重试') + } finally { + setLoading(false) + } + }, []) + + const toggleRowSelection = (id: string) => { + setSelectedIds((current) => { + const next = new Set(current) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const allOnPageSelected = + data.rows.length > 0 && data.rows.every((row) => selectedIds.has(row.id)) + + const toggleAllOnPage = () => { + setSelectedIds((current) => { + const next = new Set(current) + if (data.rows.every((row) => next.has(row.id))) { + for (const row of data.rows) next.delete(row.id) + } else { + for (const row of data.rows) next.add(row.id) + } + return next + }) + } + + const performDelete = async (ids: string[]) => { + setDeleteBusy(true) + setActionError('') + try { + const result = await requestJson<{ deleted: number; deletedDemands: number }>( + '/api/supply-chain/management/production-stop-demands/items/bulk-delete', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids }), + }, + ) + setSelectedIds(new Set()) + await load(page, filters) + setNotice( + `已删除 ${result.deleted} 条停产需求明细` + + (result.deletedDemands + ? `,其中 ${result.deletedDemands} 张需求单已无明细,已一并删除` + : ''), + ) + } catch (error) { + setActionError(error instanceof Error ? error.message : '删除失败,请稍后重试') + } finally { + setDeleteBusy(false) + } + } + + // 用页面内确认框代替浏览器原生 confirm(内置浏览器会拦截原生弹窗,导致点了没反应)。 + const deleteSelected = () => { + const ids = [...selectedIds] + if (!ids.length) return + setConfirmState({ + title: '删除停产需求明细', + message: `确定删除选中的 ${ids.length} 条停产需求明细吗?删除后不可恢复;需求单下的明细被删光时,需求单会一并删除。`, + confirmLabel: `删除 ${ids.length} 条`, + action: () => performDelete(ids), + }) + } + + const runConfirm = async () => { + if (!confirmState) return + const current = confirmState + setConfirmBusy(true) + try { + await current.action() + } finally { + setConfirmBusy(false) + setConfirmState(null) + } + } + + useEffect(() => { + if (skipNextLoad.current) { + skipNextLoad.current = false + return + } + void load(page, filters) + }, [page, filters, load]) + + const applyFilters = () => { + setPage(1) + setFilters({ + keyword: keywordDraft.trim(), + confirmationStatus: filters.confirmationStatus, + demandStatus: filters.demandStatus, + reasonCategory: filters.reasonCategory, + purchaseOwner: purchaseDraft.trim(), + requesterName: requesterDraft.trim(), + sortKey: filters.sortKey, + sortDirection: filters.sortDirection, + dateFrom: dateFromDraft, + dateTo: dateToDraft, + demandStatuses: filters.demandStatuses, + }) + } + + const activeProcessStep = + PROCESS_STEPS.find( + (step) => + step.statuses.length === filters.demandStatuses.length + && step.statuses.every((status) => filters.demandStatuses.includes(status)), + )?.key ?? '' + + const applyProcessStep = (statuses: ProductionStopDemandStatus[]) => { + setPage(1) + setFilters((current) => ({ ...current, demandStatus: '', demandStatuses: statuses })) + } + + /** 点击表头排序:首次升序 → 再点降序 → 第三次取消排序。 */ + const toggleSort = (key: ProductionStopItemSortKey) => { + setPage(1) + setFilters((current) => { + if (current.sortKey !== key) return { ...current, sortKey: key, sortDirection: 'asc' } + if (current.sortDirection === 'asc') return { ...current, sortDirection: 'desc' } + return { ...current, sortKey: '', sortDirection: 'desc' } + }) + } + + const resetFilters = () => { + setKeywordDraft('') + setRequesterDraft('') + setPurchaseDraft('') + setDateFromDraft('') + setDateToDraft('') + setPage(1) + setFilters(EMPTY_FILTERS) + } + + // 新建需求 + const [createOpen, setCreateOpen] = useState(false) + const [categories, setCategories] = useState([]) + const [createReason, setCreateReason] = useState('') + const [createDetail, setCreateDetail] = useState('') + const [barcodeText, setBarcodeText] = useState('') + const [createBusy, setCreateBusy] = useState(false) + const [createError, setCreateError] = useState('') + const [createResult, setCreateResult] = useState(null) + + const loadCategories = useCallback(async (includeInactive = false) => { + const result = await requestJson<{ categories: ProductionStopReasonCategoryOption[] }>( + `/api/supply-chain/management/production-stop-demands/reason-categories${includeInactive ? '?all=1' : ''}`, + ) + setCategories(result.categories) + return result.categories + }, []) + + const openCreate = async () => { + setCreateReason('') + setCreateDetail('') + setBarcodeText('') + setCreateError('') + setCreateResult(null) + setCreateOpen(true) + try { + await loadCategories() + } catch (error) { + setCreateError(error instanceof Error ? error.message : '原因分类加载失败') + } + } + + const parsedBarcodeCount = useMemo(() => parseBarcodeInput(barcodeText).length, [barcodeText]) + + const submitCreate = async (event: FormEvent) => { + event.preventDefault() + setCreateBusy(true) + setCreateError('') + try { + const result = await requestJson( + '/api/supply-chain/management/production-stop-demands', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + reasonCategory: createReason, + reasonDetail: createDetail, + barcodesText: barcodeText, + }), + }, + ) + setCreateResult(result) + setBarcodeText('') + setPage(1) + await load(1, filters) + // 提交成功后自动关闭弹窗,结果统一在页面提示条里展示。 + setCreateOpen(false) + setNotice( + `已创建 ${result.demandIds.length} 张需求单(一个条码一张)· 批次 ${result.batchNo} · 钉钉推送:${result.pushMessage}` + + (result.rejected.length + ? `;${result.rejected.length} 个条码未写入(${result.rejected.map((row) => `${row.barcode}:${row.reason}`).join('、')})` + : '') + + (result.warnings.length ? `;${result.warnings.length} 个条码已在停产流程中` : ''), + ) + } catch (error) { + setCreateError(error instanceof Error ? error.message : '提交失败,请稍后重试') + } finally { + setCreateBusy(false) + } + } + + const pickBarcodeFile = async (event: ChangeEvent) => { + const file = event.target.files?.[0] + event.target.value = '' + if (!file) return + try { + const XLSX = await import('xlsx') + const buffer = await file.arrayBuffer() + const workbook = XLSX.read(buffer, { type: 'array' }) + const sheet = workbook.Sheets[workbook.SheetNames[0]] + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: false }) + const values = rows + .flatMap((row) => (Array.isArray(row) ? row.slice(0, 1) : [])) + .map((cell) => String(cell ?? '').trim()) + .filter((value) => value && !/^(条码|barcode)$/i.test(value)) + if (!values.length) { + setCreateError('文件里没有读到条码,请确认第一列是条码') + return + } + setBarcodeText((current) => [current.trim(), values.join('\n')].filter(Boolean).join('\n')) + setCreateError('') + } catch { + setCreateError('文件解析失败,请使用 .xlsx / .xls / .csv 文件') + } + } + + // 明细 + 停产确认 + const [detailRow, setDetailRow] = useState(null) + const [detailDraft, setDetailDraft] = useState(EMPTY_DETAIL_DRAFT) + const [detailBusy, setDetailBusy] = useState(false) + const [detailError, setDetailError] = useState('') + const [confirmation, setConfirmation] = useState(null) + const [confirmationLoading, setConfirmationLoading] = useState(false) + const [confirmationError, setConfirmationError] = useState('') + const [voteDecision, setVoteDecision] = useState<'' | 'AGREED' | 'REJECTED'>('') + const [voteReason, setVoteReason] = useState('') + const [voteBusy, setVoteBusy] = useState(false) + const [pushBusy, setPushBusy] = useState(false) + const [reasonDraft, setReasonDraft] = useState({ reasonCategory: '', reasonDetail: '' }) + const [reasonBusy, setReasonBusy] = useState(false) + const [reasonError, setReasonError] = useState('') + const [statusNote, setStatusNote] = useState('') + const [statusBusy, setStatusBusy] = useState(false) + const [statusError, setStatusError] = useState('') + + const patchRow = useCallback((next: ProductionStopItemRow) => { + setData((current) => ({ + ...current, + rows: current.rows.map((row) => (row.id === next.id ? next : row)), + })) + setDetailRow((current) => (current && current.id === next.id ? next : current)) + }, []) + + const loadConfirmation = useCallback(async (demandId: string) => { + setConfirmationLoading(true) + setConfirmationError('') + try { + const info = await requestJson( + `/api/supply-chain/management/production-stop-demands/confirmations?demandId=${encodeURIComponent(demandId)}`, + ) + setConfirmation(info) + setVoteDecision(info.myDecision || '') + setVoteReason(info.myReason || '') + } catch (error) { + setConfirmation(null) + setConfirmationError(error instanceof Error ? error.message : '确认信息加载失败') + } finally { + setConfirmationLoading(false) + } + }, []) + + const openDetail = (row: ProductionStopItemRow) => { + setDetailRow(row) + setDetailDraft({ + replaceType: row.replaceType, + replaceBarcode: row.replaceBarcode, + imageChange: row.imageChange, + modificationPoint: row.modificationPoint, + }) + setDetailError('') + setConfirmation(null) + setConfirmationError('') + setVoteDecision('') + setVoteReason('') + setReasonDraft({ reasonCategory: row.reasonCategory, reasonDetail: row.reasonDetail }) + setReasonError('') + setStatusNote('') + setStatusError('') + void loadConfirmation(row.demandId) + if (!categories.length) void loadCategories() + } + + const saveDetail = async () => { + if (!detailRow) return + setDetailBusy(true) + setDetailError('') + try { + const updated = await requestJson( + `/api/supply-chain/management/production-stop-demands/items/${detailRow.id}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(detailDraft), + }, + ) + patchRow(updated) + } catch (error) { + setDetailError(error instanceof Error ? error.message : '保存失败,请稍后重试') + } finally { + setDetailBusy(false) + } + } + + const submitVote = async () => { + if (!detailRow || !confirmation) return + if (!voteDecision) { + setConfirmationError('请选择同意停产或不同意停产') + return + } + setVoteBusy(true) + setConfirmationError('') + try { + const info = await requestJson( + '/api/supply-chain/management/production-stop-demands/confirmations', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + demandId: detailRow.demandId, + decision: voteDecision, + reason: voteReason, + }), + }, + ) + setConfirmation(info) + setDetailRow((current) => + current ? { ...current, confirmationStatus: info.status } : current, + ) + setData((current) => ({ + ...current, + rows: current.rows.map((row) => + row.demandId === info.demandId ? { ...row, confirmationStatus: info.status } : row, + ), + })) + } catch (error) { + setConfirmationError(error instanceof Error ? error.message : '确认提交失败,请稍后重试') + } finally { + setVoteBusy(false) + } + } + + const pushConfirmation = async () => { + if (!detailRow) return + setPushBusy(true) + setConfirmationError('') + try { + const result = await requestJson<{ pushed: number; message: string }>( + '/api/supply-chain/management/production-stop-demands/confirmations/push', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ demandId: detailRow.demandId }), + }, + ) + setNotice(result.message) + await loadConfirmation(detailRow.demandId) + } catch (error) { + setConfirmationError(error instanceof Error ? error.message : '推送失败,请稍后重试') + } finally { + setPushBusy(false) + } + } + + const saveDemandReason = async () => { + if (!detailRow) return + setReasonBusy(true) + setReasonError('') + try { + const updated = await requestJson<{ + id: string + demandNo: string + reasonCategory: string + reasonDetail: string + }>(`/api/supply-chain/management/production-stop-demands/demands/${detailRow.demandId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(reasonDraft), + }) + setData((current) => ({ + ...current, + rows: current.rows.map((row) => + row.demandId === detailRow.demandId + ? { ...row, reasonCategory: updated.reasonCategory, reasonDetail: updated.reasonDetail } + : row, + ), + })) + setDetailRow((current) => + current + ? { + ...current, + reasonCategory: updated.reasonCategory, + reasonDetail: updated.reasonDetail, + } + : current, + ) + setNotice(`需求单 ${updated.demandNo} 的停产原因已更新`) + } catch (error) { + setReasonError(error instanceof Error ? error.message : '保存失败,请稍后重试') + } finally { + setReasonBusy(false) + } + } + + const applyDemandStatusInfo = (info: ProductionStopConfirmationInfo) => { + setConfirmation(info) + const patch = { + confirmationStatus: info.status, + demandStatus: info.demandStatus, + demandStatusAt: info.demandStatusAt, + demandStatusNote: info.demandStatusNote, + } + setDetailRow((current) => (current && current.demandId === info.demandId ? { ...current, ...patch } : current)) + setData((current) => ({ + ...current, + rows: current.rows.map((row) => (row.demandId === info.demandId ? { ...row, ...patch } : row)), + })) + } + + const runDemandStatusAction = async ( + action: 'RESUBMIT' | 'FORCE_CONFIRM' | 'MARK_FLOW_STARTED', + ) => { + if (!detailRow) return + setStatusBusy(true) + setStatusError('') + try { + const info = await requestJson( + `/api/supply-chain/management/production-stop-demands/demands/${detailRow.demandId}/status`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, note: statusNote }), + }, + ) + applyDemandStatusInfo(info) + setStatusNote('') + setNotice( + action === 'RESUBMIT' + ? '已重新提交确认,等待指定确认人反馈' + : action === 'FORCE_CONFIRM' + ? '已强制确认停产,需求状态更新为「已确认待走流程」' + : '已标记为「已走流程待审批」,次日会自动校验流程结果', + ) + void loadConfirmation(detailRow.demandId) + void load(page, filters) + } catch (error) { + setStatusError(error instanceof Error ? error.message : '状态更新失败,请稍后重试') + } finally { + setStatusBusy(false) + } + } + + const copyText = async (value: string, label: string) => { + if (!value) return + try { + if (!navigator.clipboard?.writeText) throw new Error('clipboard-unavailable') + await navigator.clipboard.writeText(value) + setNotice(`已复制${label}:${value}`) + } catch { + // 兜底方案:内置浏览器可能禁用剪贴板 API,用临时输入框复制。 + try { + const helper = document.createElement('textarea') + helper.value = value + helper.setAttribute('readonly', '') + helper.style.position = 'fixed' + helper.style.opacity = '0' + document.body.appendChild(helper) + helper.select() + const copied = document.execCommand('copy') + document.body.removeChild(helper) + if (copied) { + setNotice(`已复制${label}:${value}`) + return + } + } catch { + // 忽略,走下面的手动复制提示。 + } + setNotice('当前浏览器不允许自动复制,请手动选中文本复制') + } + } + + const loadSuggestions = useCallback( + async (params: { barcode?: string; suggestion?: string; page?: number } = {}) => { + const search = new URLSearchParams({ + page: String(params.page ?? 1), + pageSize: '50', + }) + if (params.barcode) search.set('barcode', params.barcode) + if (params.suggestion) search.set('suggestion', params.suggestion) + const result = await requestJson<{ + rows: Array<{ + id: string + barcode: string + suggestion: string + updatedByName: string + createdAt: string + updatedAt: string + }> + page: number + total: number + totalPages: number + }>(`/api/supply-chain/management/production-stop-demands/suggestions?${search.toString()}`) + setSuggestionRows(result.rows) + setSuggestionPage(result.page) + setSuggestionTotal(result.total) + setSuggestionPages(result.totalPages) + setSuggestionSelected(new Set()) + return result.rows + }, + [], + ) + + const openSuggestions = async () => { + setSuggestionsOpen(true) + setSuggestionText('') + setSuggestionBarcodeFilter('') + setSuggestionValueFilter('') + setSuggestionSelected(new Set()) + setSuggestionImportOpen(false) + setSuggestionEditOpen(false) + setSuggestionError('') + setSuggestionNotice('') + setSuggestionBusy(true) + try { + await loadSuggestions({}) + } catch (error) { + setSuggestionError(error instanceof Error ? error.message : '盘停建议配置加载失败') + } finally { + setSuggestionBusy(false) + } + } + + const searchSuggestions = async (page = 1) => { + setSuggestionBusy(true) + setSuggestionError('') + setSuggestionNotice('') + try { + await loadSuggestions({ + barcode: suggestionBarcodeFilter.trim(), + suggestion: suggestionValueFilter.trim(), + page, + }) + } catch (error) { + setSuggestionError(error instanceof Error ? error.message : '查询失败,请稍后重试') + } finally { + setSuggestionBusy(false) + } + } + + const toggleSuggestionRow = (id: string) => { + setSuggestionSelected((current) => { + const next = new Set(current) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const openSuggestionCreate = () => { + setSuggestionEditing(false) + setSuggestionEditDraft({ barcode: '', suggestion: '' }) + setSuggestionEditError('') + setSuggestionEditOpen(true) + } + + const openSuggestionEdit = () => { + const target = suggestionRows.find((row) => suggestionSelected.has(row.id)) + if (!target) { + setSuggestionError('请先勾选一条要编辑的配置') + return + } + setSuggestionEditing(true) + setSuggestionEditDraft({ barcode: target.barcode, suggestion: target.suggestion }) + setSuggestionEditError('') + setSuggestionEditOpen(true) + } + + const saveSuggestionEdit = async () => { + setSuggestionBusy(true) + setSuggestionEditError('') + try { + await requestJson('/api/supply-chain/management/production-stop-demands/suggestions/item', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(suggestionEditDraft), + }) + setSuggestionEditOpen(false) + setSuggestionNotice(suggestionEditing ? '配置已更新' : '配置已新增') + await searchSuggestions(suggestionPage) + } catch (error) { + setSuggestionEditError(error instanceof Error ? error.message : '保存失败,请稍后重试') + } finally { + setSuggestionBusy(false) + } + } + + const deleteSelectedSuggestions = () => { + const ids = [...suggestionSelected] + if (!ids.length) { + setSuggestionError('请先勾选要删除的配置') + return + } + setConfirmState({ + title: '删除盘停建议配置', + message: `确定删除选中的 ${ids.length} 条盘停建议配置吗?删除后这些条码提单时不再自动带出建议。`, + confirmLabel: `删除 ${ids.length} 条`, + action: async () => { + setSuggestionBusy(true) + try { + await requestJson('/api/supply-chain/management/production-stop-demands/suggestions/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids }), + }) + setSuggestionNotice(`已删除 ${ids.length} 条配置`) + await searchSuggestions(1) + } catch (error) { + setSuggestionError(error instanceof Error ? error.message : '删除失败,请稍后重试') + } finally { + setSuggestionBusy(false) + } + }, + }) + } + + const exportSuggestions = async () => { + setSuggestionBusy(true) + setSuggestionError('') + try { + const search = new URLSearchParams() + if (suggestionBarcodeFilter.trim()) search.set('keyword', suggestionBarcodeFilter.trim()) + const result = await requestJson<{ + rows: Array<{ barcode: string; suggestion: string; updatedByName: string; updatedAt: string }> + }>(`/api/supply-chain/management/production-stop-demands/suggestions/export?${search.toString()}`) + const XLSX = await import('xlsx') + const sheet = XLSX.utils.aoa_to_sheet([ + ['条码', '盘停建议', '最后修改人', '更新时间'], + ...result.rows.map((row) => [ + row.barcode, + row.suggestion, + row.updatedByName, + formatDateTime(row.updatedAt), + ]), + ]) + const workbook = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(workbook, sheet, '盘停建议配置') + XLSX.writeFile(workbook, '盘停建议配置.xlsx') + } catch (error) { + setSuggestionError(error instanceof Error ? error.message : '导出失败,请稍后重试') + } finally { + setSuggestionBusy(false) + } + } + + const suggestionTableWidth = + 40 + suggestionWidths.createdAt + suggestionWidths.barcode + suggestionWidths.suggestion + + const startSuggestionResize = ( + event: ReactPointerEvent, + key: keyof typeof suggestionWidths, + ) => { + event.preventDefault() + event.stopPropagation() + event.currentTarget.setPointerCapture(event.pointerId) + suggestionResizeRef.current = { key, clientX: event.clientX, width: suggestionWidths[key] } + } + + const moveSuggestionResize = (event: ReactPointerEvent) => { + const start = suggestionResizeRef.current + if (!start) return + const next = Math.min(720, Math.max(70, start.width + event.clientX - start.clientX)) + setSuggestionWidths((current) => ({ ...current, [start.key]: Math.round(next) })) + } + + const endSuggestionResize = (event: ReactPointerEvent) => { + suggestionResizeRef.current = null + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + } + + const importSuggestions = async (mode: 'SKIP' | 'UPDATE' | 'REPLACE') => { + setSuggestionBusy(true) + setSuggestionError('') + setSuggestionNotice('') + try { + const result = await requestJson<{ + created: number + updated: number + skipped: Array<{ line: string; reason: string }> + }>('/api/supply-chain/management/production-stop-demands/suggestions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: suggestionText, mode }), + }) + setSuggestionNotice( + `导入完成(${mode === 'SKIP' ? '跳过不更新' : mode === 'REPLACE' ? '全部更新' : '更新覆盖'}):新增 ${result.created} 条,更新 ${result.updated} 条` + + (typeof (result as { removed?: number }).removed === 'number' + ? `,清空 ${(result as { removed?: number }).removed} 条旧配置` + : '') + + (result.skipped.length ? `;跳过 ${result.skipped.length} 行` : ''), + ) + setSuggestionText('') + await loadSuggestions({ + barcode: suggestionBarcodeFilter.trim(), + suggestion: suggestionValueFilter.trim(), + page: 1, + }) + } catch (error) { + setSuggestionError(error instanceof Error ? error.message : '导入失败,请稍后重试') + } finally { + setSuggestionBusy(false) + } + } + + /** 解析待导入文本(与服务端同一口径),用于两列预览。 */ + const suggestionPreview = useMemo(() => { + const rows: Array<{ barcode: string; suggestion: string }> = [] + for (const rawLine of suggestionText.split(/\r?\n/)) { + const line = rawLine.trim() + if (!line) continue + const match = line.match(/^(\S+)[\t,,;;\s]+(.*)$/) + const barcode = (match?.[1] ?? line).trim().toUpperCase() + const suggestion = (match?.[2] ?? '').trim() + if (!barcode || /^(条码|BARCODE)$/i.test(barcode)) continue + rows.push({ barcode, suggestion }) + } + return rows + }, [suggestionText]) + + const downloadSuggestionTemplate = async () => { + const XLSX = await import('xlsx') + const sheet = XLSX.utils.aoa_to_sheet([ + ['条码', '盘停建议'], + ['DNZ025471N1226V01', '建议盘停'], + ['DNZ025470N1226V01', '先观察两周'], + ]) + const workbook = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(workbook, sheet, '盘停建议配置') + XLSX.writeFile(workbook, '盘停建议配置导入模板.xlsx') + } + + const pickSuggestionFile = async (event: ChangeEvent) => { + const file = event.target.files?.[0] + event.target.value = '' + if (!file) return + try { + const XLSX = await import('xlsx') + const buffer = await file.arrayBuffer() + const workbook = XLSX.read(buffer, { type: 'array' }) + const sheet = workbook.Sheets[workbook.SheetNames[0]] + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: false }) + const lines = rows + .flatMap((row) => (Array.isArray(row) ? [row.slice(0, 2)] : [])) + .map(([barcode, suggestion]) => [String(barcode ?? '').trim(), String(suggestion ?? '').trim()]) + .filter(([barcode]) => barcode && !/^(条码|barcode)$/i.test(barcode)) + .map(([barcode, suggestion]) => `${barcode},${suggestion}`) + if (!lines.length) { + setSuggestionError('文件里没有读到数据,请确认第一列是条码、第二列是盘停建议') + return + } + setSuggestionText(lines.join('\n')) + setSuggestionError('') + } catch { + setSuggestionError('文件解析失败,请使用 .xlsx / .xls / .csv 文件') + } + } + + /** 勾选的是明细行,批量工作流接口需要需求单 ID(删除选中仍用明细 ID)。 */ + const selectedDemandIds = () => + data.rows.filter((row) => selectedIds.has(row.id)).map((row) => row.demandId) + + const batchConfirm = async (decision: 'AGREED' | 'REJECTED') => { + const ids = selectedDemandIds() + if (!ids.length) return + try { + const result = await requestJson<{ + confirmed: string[] + skipped: Array<{ demandNo: string; reason: string }> + }>('/api/supply-chain/management/production-stop-demands/confirmations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ demandIds: ids, decision, reason: statusNote }), + }) + setNotice( + `已提交 ${result.confirmed.length} 条${decision === 'AGREED' ? '同意停产' : '不同意停产'}` + + (result.skipped.length + ? `;跳过 ${result.skipped.length} 条(${result.skipped.map((row) => `${row.demandNo}:${row.reason}`).join(';')})` + : ''), + ) + setSelectedIds(new Set()) + setStatusNote('') + await load(page, filters) + } catch (error) { + setActionError(error instanceof Error ? error.message : '批量确认失败,请稍后重试') + } + } + + const batchMarkFlowStarted = async () => { + const ids = selectedDemandIds() + if (!ids.length) return + try { + const result = await requestJson<{ + marked: string[] + skipped: Array<{ demandNo: string; reason: string }> + }>('/api/supply-chain/management/production-stop-demands/demands/status', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ demandIds: ids, action: 'MARK_FLOW_STARTED', note: statusNote }), + }) + setNotice( + `已确认走流程 ${result.marked.length} 条` + + (result.skipped.length ? `;跳过 ${result.skipped.length} 条(${result.skipped.map((row) => `${row.demandNo}:${row.reason}`).join(';')})` : ''), + ) + setSelectedIds(new Set()) + setStatusNote('') + await load(page, filters) + } catch (error) { + setActionError(error instanceof Error ? error.message : '批量走流程失败,请稍后重试') + } + } + + const openBatchReason = async () => { + setBatchReasonDraft({ reasonCategory: '', reasonDetail: '' }) + setBatchReasonError('') + setBatchReasonOpen(true) + if (!categories.length) { + try { + await loadCategories() + } catch (error) { + setBatchReasonError(error instanceof Error ? error.message : '原因分类加载失败') + } + } + } + + const submitBatchReason = async () => { + const ids = selectedDemandIds() + if (!ids.length) return + setBatchReasonBusy(true) + setBatchReasonError('') + try { + const result = await requestJson<{ + updated: string[] + skipped: Array<{ demandNo: string; reason: string }> + }>('/api/supply-chain/management/production-stop-demands/demands/reason', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ demandIds: ids, ...batchReasonDraft }), + }) + setNotice( + `已修改 ${result.updated.length} 条需求的停产原因` + + (result.skipped.length + ? `;跳过 ${result.skipped.length} 条(${result.skipped.map((row) => `${row.demandNo}:${row.reason}`).join(';')})` + : ''), + ) + setBatchReasonOpen(false) + setSelectedIds(new Set()) + await load(page, filters) + } catch (error) { + setBatchReasonError(error instanceof Error ? error.message : '批量修改失败,请稍后重试') + } finally { + setBatchReasonBusy(false) + } + } + + // 原因分类配置 + const [configOpen, setConfigOpen] = useState(false) + const [configRows, setConfigRows] = useState([]) + const [configNames, setConfigNames] = useState>({}) + const [configError, setConfigError] = useState('') + const [configBusy, setConfigBusy] = useState(false) + const [newCategoryName, setNewCategoryName] = useState('') + const [configKeyword, setConfigKeyword] = useState('') + const [configSelected, setConfigSelected] = useState>(new Set()) + + const refreshConfigRows = useCallback(async () => { + const rows = await loadCategories(true) + setConfigRows(rows) + setConfigNames(Object.fromEntries(rows.map((row) => [row.id, row.name]))) + }, [loadCategories]) + + const openConfig = async () => { + setConfigError('') + setNewCategoryName('') + setConfigKeyword('') + setConfigSelected(new Set()) + setConfigOpen(true) + try { + await refreshConfigRows() + } catch (error) { + setConfigError(error instanceof Error ? error.message : '原因分类加载失败') + } + } + + const runConfigAction = async (action: () => Promise, fallback: string) => { + setConfigBusy(true) + setConfigError('') + try { + await action() + await refreshConfigRows() + } catch (error) { + setConfigError(error instanceof Error ? error.message : fallback) + } finally { + setConfigBusy(false) + } + } + + const saveRow = (row: ProductionStopReasonCategoryOption) => { + const name = (configNames[row.id] ?? '').trim() + if (!name || name === row.name) return + void runConfigAction( + () => requestJson( + `/api/supply-chain/management/production-stop-demands/reason-categories/${row.id}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }, + ), + '重命名失败,请稍后重试', + ) + } + + const toggleRow = (row: ProductionStopReasonCategoryOption) => { + void runConfigAction( + () => requestJson( + `/api/supply-chain/management/production-stop-demands/reason-categories/${row.id}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive: !row.isActive }), + }, + ), + '更新状态失败,请稍后重试', + ) + } + + const removeRow = (row: ProductionStopReasonCategoryOption) => { + setConfirmState({ + title: '删除停产原因分类', + message: `确定删除停产原因分类“${row.name}”吗?已被需求使用的分类不能删除,可改为停用。`, + confirmLabel: '删除', + action: () => runConfigAction( + () => requestJson( + `/api/supply-chain/management/production-stop-demands/reason-categories/${row.id}`, + { method: 'DELETE' }, + ), + '删除失败,请稍后重试', + ), + }) + } + + const addCategory = () => { + void addCategoriesBatch() + } + + /** 批量添加:一行一个分类名称,重复/非法自动跳过并回报。 */ + const addCategoriesBatch = async () => { + const names = newCategoryName + .split(/\r?\n/) + .map((name) => name.trim()) + .filter(Boolean) + if (!names.length) return + setConfigBusy(true) + setConfigError('') + try { + const result = await requestJson<{ + created: string[] + skipped: Array<{ name: string; reason: string }> + }>('/api/supply-chain/management/production-stop-demands/reason-categories/bulk', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ names }), + }) + setNewCategoryName('') + await refreshConfigRows() + setConfigError( + result.skipped.length + ? `已添加 ${result.created.length} 个;跳过 ${result.skipped.length} 个(${result.skipped.map((row) => `${row.name}:${row.reason}`).join(';')})` + : '', + ) + } catch (error) { + setConfigError(error instanceof Error ? error.message : '批量添加失败,请稍后重试') + } finally { + setConfigBusy(false) + } + } + + const deleteSelectedCategories = () => { + const ids = [...configSelected] + if (!ids.length) return + setConfirmState({ + title: '批量删除停产原因分类', + message: `确定删除选中的 ${ids.length} 个分类吗?已被需求使用的分类会自动跳过(可改为停用)。`, + confirmLabel: `删除 ${ids.length} 个`, + action: async () => { + setConfigBusy(true) + try { + const result = await requestJson<{ + deleted: string[] + skipped: Array<{ name: string; reason: string }> + }>('/api/supply-chain/management/production-stop-demands/reason-categories/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids }), + }) + setConfigSelected(new Set()) + await refreshConfigRows() + setConfigError( + `已删除 ${result.deleted.length} 个` + + (result.skipped.length + ? `;跳过 ${result.skipped.length} 个(${result.skipped.map((row) => `${row.name}:${row.reason}`).join(';')})` + : ''), + ) + } catch (error) { + setConfigError(error instanceof Error ? error.message : '批量删除失败,请稍后重试') + } finally { + setConfigBusy(false) + } + }, + }) + } + + const activeCategories = useMemo( + () => categories.filter((category) => category.isActive), + [categories], + ) + + /** 配置列表快速筛选(按当前编辑中的名称匹配)。 */ + const filteredConfigRows = useMemo(() => { + const keyword = configKeyword.trim().toLowerCase() + if (!keyword) return configRows + return configRows.filter((row) => + (configNames[row.id] ?? row.name).toLowerCase().includes(keyword), + ) + }, [configKeyword, configNames, configRows]) + + const toggleConfigSelection = (id: string) => { + setConfigSelected((current) => { + const next = new Set(current) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + // 停产确认人配置(系统管理员维护:指定运营总监或指定其他人) + const [approversOpen, setApproversOpen] = useState(false) + const [approverRows, setApproverRows] = useState([]) + const [flowOwnerRows, setFlowOwnerRows] = useState([]) + const [approverCandidates, setApproverCandidates] = useState([]) + const [approverSearch, setApproverSearch] = useState('') + const [approverError, setApproverError] = useState('') + const [approverBusy, setApproverBusy] = useState(false) + const [approverKeyword, setApproverKeyword] = useState('') + const [approverSelected, setApproverSelected] = useState>(new Set()) + const [approverCandidateSelected, setApproverCandidateSelected] = useState>(new Set()) + const [flowOwnersOpen, setFlowOwnersOpen] = useState(false) + const [flowOwnerKeyword, setFlowOwnerKeyword] = useState('') + const [flowOwnerSelected, setFlowOwnerSelected] = useState>(new Set()) + const [flowCandidateSelected, setFlowCandidateSelected] = useState>(new Set()) + + const loadApprovers = useCallback(async () => { + const result = await requestJson<{ approvers: ProductionStopConfirmationApproverOption[] }>( + '/api/supply-chain/management/production-stop-demands/approvers', + ) + setApproverRows(result.approvers) + return result.approvers + }, []) + + const loadFlowOwners = useCallback(async () => { + const result = await requestJson<{ owners: ProductionStopFlowOwnerOption[] }>( + '/api/supply-chain/management/production-stop-demands/flow-owners', + ) + setFlowOwnerRows(result.owners) + return result.owners + }, []) + + const searchApproverCandidates = useCallback(async (keyword: string) => { + const result = await requestJson<{ candidates: ApproverCandidate[] }>( + `/api/supply-chain/management/production-stop-demands/approvers/candidates?search=${encodeURIComponent(keyword)}`, + ) + // 原始候选人列表:确认人弹窗过滤掉已是确认人的,流程负责人弹窗过滤掉已是负责人的。 + setApproverCandidates(result.candidates) + }, []) + + // 输入即搜(防抖 300ms),不用再点「搜索」按钮。 + useEffect(() => { + if (!approversOpen) return + const timer = setTimeout(() => { + void searchApproverCandidates(approverSearch).catch(() => null) + }, 300) + return () => clearTimeout(timer) + }, [approverSearch, approversOpen, searchApproverCandidates]) + + const runApproverAction = async (action: () => Promise, fallback: string) => { + setApproverBusy(true) + setApproverError('') + try { + await action() + } catch (error) { + setApproverError(error instanceof Error ? error.message : fallback) + } finally { + setApproverBusy(false) + } + } + + const openApprovers = async () => { + setApproversOpen(true) + setApproverError('') + setApproverSearch('') + setApproverKeyword('') + setApproverSelected(new Set()) + setApproverCandidateSelected(new Set()) + setApproverBusy(true) + try { + await Promise.all([loadApprovers(), searchApproverCandidates('')]) + } catch (error) { + setApproverError(error instanceof Error ? error.message : '确认人加载失败') + } finally { + setApproverBusy(false) + } + } + + const openFlowOwners = async () => { + setFlowOwnersOpen(true) + setApproverError('') + setApproverSearch('') + setFlowOwnerKeyword('') + setFlowOwnerSelected(new Set()) + setFlowCandidateSelected(new Set()) + setApproverBusy(true) + try { + await Promise.all([loadFlowOwners(), searchApproverCandidates('')]) + } catch (error) { + setApproverError(error instanceof Error ? error.message : '流程负责人加载失败') + } finally { + setApproverBusy(false) + } + } + + const batchAddApprovers = async () => { + const ids = [...approverCandidateSelected] + if (!ids.length) return + await runApproverAction(async () => { + const failures: string[] = [] + for (const userId of ids) { + try { + await requestJson('/api/supply-chain/management/production-stop-demands/approvers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId }), + }) + } catch (error) { + failures.push(error instanceof Error ? error.message : '添加失败') + } + } + setApproverCandidateSelected(new Set()) + await Promise.all([loadApprovers(), searchApproverCandidates(approverSearch)]) + if (failures.length) setApproverError(`部分添加失败:${failures.join(';')}`) + }, '批量添加失败,请稍后重试') + } + + const batchDeleteApprovers = () => { + const ids = [...approverSelected] + if (!ids.length) return + setConfirmState({ + title: '批量删除需求确认人', + message: `确定把选中的 ${ids.length} 人从需求确认人名单中删除吗?已有确认记录的人会自动跳过(可改为停用)。`, + confirmLabel: `删除 ${ids.length} 人`, + action: () => + runApproverAction(async () => { + const failures: string[] = [] + for (const id of ids) { + try { + await requestJson( + `/api/supply-chain/management/production-stop-demands/approvers/${id}`, + { method: 'DELETE' }, + ) + } catch (error) { + failures.push(error instanceof Error ? error.message : '删除失败') + } + } + setApproverSelected(new Set()) + await Promise.all([loadApprovers(), searchApproverCandidates(approverSearch)]) + if (failures.length) setApproverError(`部分删除失败:${failures.join(';')}`) + }, '批量删除失败,请稍后重试'), + }) + } + + const batchAddFlowOwners = async () => { + const ids = [...flowCandidateSelected] + if (!ids.length) return + await runApproverAction(async () => { + const failures: string[] = [] + for (const userId of ids) { + try { + await requestJson('/api/supply-chain/management/production-stop-demands/flow-owners', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId }), + }) + } catch (error) { + failures.push(error instanceof Error ? error.message : '添加失败') + } + } + setFlowCandidateSelected(new Set()) + await Promise.all([loadFlowOwners(), searchApproverCandidates(approverSearch)]) + if (failures.length) setApproverError(`部分添加失败:${failures.join(';')}`) + }, '批量添加失败,请稍后重试') + } + + const batchDeleteFlowOwners = () => { + const ids = [...flowOwnerSelected] + if (!ids.length) return + setConfirmState({ + title: '批量删除流程负责人', + message: `确定把选中的 ${ids.length} 人从流程负责人名单中删除吗?`, + confirmLabel: `删除 ${ids.length} 人`, + action: () => + runApproverAction(async () => { + const failures: string[] = [] + for (const id of ids) { + try { + await requestJson( + `/api/supply-chain/management/production-stop-demands/flow-owners/${id}`, + { method: 'DELETE' }, + ) + } catch (error) { + failures.push(error instanceof Error ? error.message : '删除失败') + } + } + setFlowOwnerSelected(new Set()) + await Promise.all([loadFlowOwners(), searchApproverCandidates(approverSearch)]) + if (failures.length) setApproverError(`部分删除失败:${failures.join(';')}`) + }, '批量删除失败,请稍后重试'), + }) + } + + const addApprover = (userId: string) => { + void runApproverAction(async () => { + await requestJson('/api/supply-chain/management/production-stop-demands/approvers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId }), + }) + await Promise.all([loadApprovers(), searchApproverCandidates(approverSearch)]) + }, '添加确认人失败,请稍后重试') + } + + const toggleApprover = (row: ProductionStopConfirmationApproverOption) => { + void runApproverAction(async () => { + await requestJson( + `/api/supply-chain/management/production-stop-demands/approvers/${row.id}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive: !row.isActive }), + }, + ) + await Promise.all([loadApprovers(), searchApproverCandidates(approverSearch)]) + }, '更新确认人失败,请稍后重试') + } + + const addFlowOwner = (userId: string) => { + void runApproverAction(async () => { + await requestJson('/api/supply-chain/management/production-stop-demands/flow-owners', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId }), + }) + await Promise.all([loadFlowOwners(), searchApproverCandidates(approverSearch)]) + }, '添加流程负责人失败,请稍后重试') + } + + const toggleFlowOwner = (row: ProductionStopFlowOwnerOption) => { + void runApproverAction(async () => { + await requestJson( + `/api/supply-chain/management/production-stop-demands/flow-owners/${row.id}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive: !row.isActive }), + }, + ) + await loadFlowOwners() + }, '更新流程负责人失败,请稍后重试') + } + + const removeFlowOwner = (row: ProductionStopFlowOwnerOption) => { + setConfirmState({ + title: '删除流程负责人', + message: `确定把“${row.userName}”从流程负责人名单中删除吗?删除后该人员将无法确认走流程。`, + confirmLabel: '删除', + action: () => runApproverAction(async () => { + await requestJson( + `/api/supply-chain/management/production-stop-demands/flow-owners/${row.id}`, + { method: 'DELETE' }, + ) + await Promise.all([loadFlowOwners(), searchApproverCandidates(approverSearch)]) + }, '删除流程负责人失败,请稍后重试'), + }) + } + + const removeApprover = (row: ProductionStopConfirmationApproverOption) => { + setConfirmState({ + title: '删除停产确认人', + message: `确定把“${row.userName}”从停产确认人名单中删除吗?已有确认记录的人不能删除,可改为停用。`, + confirmLabel: '删除', + action: () => runApproverAction(async () => { + await requestJson( + `/api/supply-chain/management/production-stop-demands/approvers/${row.id}`, + { method: 'DELETE' }, + ) + await Promise.all([loadApprovers(), searchApproverCandidates(approverSearch)]) + }, '删除确认人失败,请稍后重试'), + }) + } + + const renderCell = (row: ProductionStopItemRow, key: string) => { + const value = String((row as unknown as Record)[key] ?? '') + if (key === 'confirmationStatus') { + return ( + + + {PRODUCTION_STOP_CONFIRMATION_LABELS[row.confirmationStatus]} + + {row.confirmationStatus === 'PENDING' && row.confirmationDeadline ? ( + + 已确认 {row.confirmationVoteCount} 票 · 截止 {formatDateTime(row.confirmationDeadline)} + + ) : null} + + ) + } + if (key === 'demandStatus') { + return ( + + + {PRODUCTION_STOP_DEMAND_STATUS_LABELS[row.demandStatus]} + + + ) + } + if (key === 'submittedAt' || key === 'barcodeCreatedAt') { + return {formatDateTime(value) || '-'} + } + return ( + + {value || '-'} + + ) + } + + return ( +
+
+ +
+ +
+ + + + + + + + + {data.filterOptions.requesterNames.map((name) => ( + + + {data.filterOptions.purchaseOwners.map((name) => ( + + + + {isAdmin ? ( + + ) : null} + {isAdmin ? ( + + ) : null} + {isAdmin ? ( + + ) : null} + {canEdit ? ( + + ) : null} + {canEdit ? ( + + ) : null} + {selectedIds.size ? ( + <> + {canEdit ? ( + + ) : null} + {isApprover ? ( + <> + + + + ) : null} + {isFlowOwner ? ( + + ) : null} + + + + ) : null} + + {loading + ? '加载中…' + : `共 ${data.total} 条明细${selectedIds.size ? ` · 已选 ${selectedIds.size} 条` : ''}`} + +
+ + {loadError || actionError || notice ? ( +
+ {loadError || actionError ? ( + + + {loadError || actionError} + + ) : null} + {notice ? {notice} : null} +
+ ) : null} + +
+ + + + {PRODUCTION_STOP_ITEM_COLUMNS.map((column) => ( + + ))} + + + + + ))} + + + + {PRODUCTION_STOP_ITEM_COLUMNS.map((column) => ( + + ))} + + + + {data.rows.map((row) => ( + { + // 正在选中文字时不打开详情,保证表格内容可复制。 + const selected = window.getSelection?.()?.toString() ?? '' + if (selected.trim()) return + openDetail(row) + }} + > + + {PRODUCTION_STOP_ITEM_COLUMNS.map((column) => ( + + ))} + + ))} + {!data.rows.length ? ( + + + + ) : null} + +
{group.group}
+ + +
+ + handleColumnResizeStart(event, column.key)} + onPointerMove={handleColumnResizeMove} + onPointerUp={handleColumnResizeEnd} + onPointerCancel={handleColumnResizeEnd} + onDoubleClick={() => resetColumnWidth(column.key)} + onKeyDown={(event) => handleColumnResizeKeyDown(event, column.key)} + /> +
+
event.stopPropagation()}> + + {renderCell(row, column.key)}
+ 暂无停产需求 +
+
+ +
+ + 第 {data.page} / {data.totalPages} 页 + +
+ + {createOpen ? ( +
{ + if (event.target === event.currentTarget) setCreateOpen(false) + }} + > +
+
+
+

新建停产需求

+

需求人、需求时间与需求部门由系统自动识别

+
+ +
+
+
+
+ +
+ 批量导入条码 +
+ + 模板仅需「条码」一列,也支持直接粘贴 +
+
+
+