Merge branch 'codex/wuyongjie-production-stop-demands' into codex/xuxiake-mainline
feat(production-stop-demands): 停产需求管理全流程(提单→需求确认→走停产流程→流程结果同步) - 按条码拆单提交;批量同意/不同意、批量走流程、批量修改停产原因 - 需求确认人与流程负责人分开配置(批量新增/删除/查询) - 确认截止时间(15:00/16:00 规则)与超时默认同意;任一人不同意即整单驳回 - PLM 流程结果同步(拒绝→需求驳回;商品停产/退市→流程审批通过)与盘停建议配置 (cherry picked from commit 33555c7f6bc0881452a074b06c89b6caf911ca1d)
This commit is contained in:
parent
de7518bb16
commit
8270ae9b38
@ -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");
|
||||
@ -0,0 +1,2 @@
|
||||
-- 停产需求明细删除支持写入操作日志(新增枚举值,不改动既有数据)。
|
||||
ALTER TYPE "OperationLogAction" ADD VALUE 'PRODUCTION_STOP_DEMAND_DELETE';
|
||||
@ -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;
|
||||
@ -0,0 +1,2 @@
|
||||
-- 停产需求修改停产原因支持写入操作日志(新增枚举值,不改动既有数据)。
|
||||
ALTER TYPE "OperationLogAction" ADD VALUE 'PRODUCTION_STOP_DEMAND_UPDATE';
|
||||
@ -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");
|
||||
@ -0,0 +1,3 @@
|
||||
-- 按条码拆单后,用批次号把同一次提交的需求单串起来(用于批量确认与推送聚合)。
|
||||
ALTER TABLE "ProductionStopDemand" ADD COLUMN "batchNo" TEXT NOT NULL DEFAULT '';
|
||||
CREATE INDEX "ProductionStopDemand_batchNo_idx" ON "ProductionStopDemand"("batchNo");
|
||||
@ -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");
|
||||
@ -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())
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
@ -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',
|
||||
},
|
||||
|
||||
]
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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(<SupplyChainSidebar visibleTargetKeys={['businessManagement']} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('link', { name: '停产需求管理' }),
|
||||
).toHaveAttribute('aria-current', 'page')
|
||||
})
|
||||
})
|
||||
|
||||
@ -110,6 +110,7 @@ export const SUPPLY_CHAIN_ROUTE_TITLES: Record<string, string> = {
|
||||
'/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': '用户权限',
|
||||
|
||||
@ -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',
|
||||
})]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>): 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(
|
||||
<ProductionStopDemandsView initialData={emptyPage} canEdit={false} isAdmin={false} isFlowOwner={false} />,
|
||||
)
|
||||
|
||||
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(
|
||||
<ProductionStopDemandsView initialData={emptyPage} canEdit isAdmin isFlowOwner={false} />,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /新建停产需求/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /原因分类配置/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('每个字段列都提供列宽拖拽手柄', () => {
|
||||
render(
|
||||
<ProductionStopDemandsView initialData={emptyPage} canEdit={false} isAdmin={false} isFlowOwner={false} />,
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('separator')).toHaveLength(PRODUCTION_STOP_ITEM_COLUMNS.length)
|
||||
expect(screen.getByRole('separator', { name: '调整条码列宽' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('勾选单行或多行后出现删除入口', () => {
|
||||
render(
|
||||
<ProductionStopDemandsView
|
||||
initialData={{
|
||||
...emptyPage,
|
||||
rows: [
|
||||
itemRow({ id: 'item-1', barcode: 'A1' }),
|
||||
itemRow({ id: 'item-2', barcode: 'A2' }),
|
||||
],
|
||||
total: 2,
|
||||
}}
|
||||
canEdit
|
||||
isAdmin={false}
|
||||
isFlowOwner={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<ProductionStopDemandsView
|
||||
initialData={{ ...emptyPage, rows: [itemRow({ id: 'item-1', barcode: 'A1' })], total: 1 }}
|
||||
canEdit
|
||||
isAdmin={false}
|
||||
isFlowOwner={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<ProductionStopDemandsView
|
||||
initialData={{
|
||||
...emptyPage,
|
||||
rows: [itemRow({ id: 'item-1', barcode: 'A1', reasonCategory: '已删除的分类' })],
|
||||
total: 1,
|
||||
}}
|
||||
canEdit
|
||||
isAdmin={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<ProductionStopDemandsView initialData={emptyPage} canEdit isAdmin isFlowOwner={false} />,
|
||||
)
|
||||
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(),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,47 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import type { ProductionStopItemPage } from '@/lib/production-stop-demands'
|
||||
import { productionStopDemandsService } from '@/server/services/production-stop-demands.service'
|
||||
import { ProductionStopDemandsView } from './ProductionStopDemandsView'
|
||||
|
||||
export const metadata = { title: '停产需求管理 · 供应链中心' }
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
function unavailableInitialData(): ProductionStopItemPage {
|
||||
return {
|
||||
rows: [],
|
||||
page: 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
filterOptions: { reasonCategories: [], purchaseOwners: [], requesterNames: [] },
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ProductionStopDemandsPage() {
|
||||
const { authUser } = await requireSession('/login')
|
||||
const listAccess = await productionStopDemandsService.resolveListAccess(authUser)
|
||||
// 指定的停产确认人即使没有经营管理权限,也需要能打开页面完成确认。
|
||||
if (!listAccess.canView && !listAccess.isApprover) redirect('/supply-chain')
|
||||
const flowAccess = await productionStopDemandsService.resolveFlowAccess(authUser)
|
||||
let initialData = unavailableInitialData()
|
||||
try {
|
||||
initialData = await productionStopDemandsService.listPage({ page: 1, pageSize: PAGE_SIZE })
|
||||
} catch (cause) {
|
||||
console.warn('[production-stop-demands-page] initial data unavailable', {
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
})
|
||||
}
|
||||
const isAdmin = authUser.role === 'ADMIN' || authUser.role === 'SUPER_ADMIN'
|
||||
return (
|
||||
<ProductionStopDemandsView
|
||||
initialData={initialData}
|
||||
canEdit={listAccess.canEdit}
|
||||
isAdmin={isAdmin}
|
||||
isFlowOwner={flowAccess.isFlowOwner}
|
||||
isApprover={listAccess.isApprover}
|
||||
/>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
97
furniture-3d-system/src/lib/production-stop-demands.test.ts
Normal file
97
furniture-3d-system/src/lib/production-stop-demands.test.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import {
|
||||
PRODUCTION_STOP_DEFAULT_REASON_CATEGORIES,
|
||||
PRODUCTION_STOP_ITEM_COLUMNS,
|
||||
parseBarcodeInput,
|
||||
resolveProductionStopConfirmationDeadline,
|
||||
} from './production-stop-demands'
|
||||
|
||||
describe('停产需求管理口径', () => {
|
||||
it('条码解析支持换行、逗号、分号与空格分隔,并按大小写去重', () => {
|
||||
expect(parseBarcodeInput('A1\nb2,A1; B2、C3 c4')).toEqual(['A1', 'b2', 'C3', 'c4'])
|
||||
})
|
||||
|
||||
it('明细列覆盖需求表字段并按需求顺序排列', () => {
|
||||
expect(PRODUCTION_STOP_ITEM_COLUMNS[0]).toMatchObject({ key: 'submittedAt', label: '需求时间' })
|
||||
expect(PRODUCTION_STOP_ITEM_COLUMNS.map((column) => column.label)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'需求人',
|
||||
'需求部门',
|
||||
'停产原因分类',
|
||||
'条码',
|
||||
'替换类型',
|
||||
'替换编码',
|
||||
'是否换图',
|
||||
'修改点',
|
||||
'分类',
|
||||
'SPU',
|
||||
'SKU',
|
||||
'条码生产状态',
|
||||
'供应商',
|
||||
'采购',
|
||||
'条码创建时间',
|
||||
'品牌',
|
||||
'商品标签',
|
||||
'产品标签',
|
||||
'停产后货号状态',
|
||||
'含大单近30日日销-条码',
|
||||
'含大单近30日日销-SKU',
|
||||
'运营总负责人',
|
||||
'销量最高运营总负责人',
|
||||
'盘停建议',
|
||||
]),
|
||||
)
|
||||
expect(PRODUCTION_STOP_ITEM_COLUMNS[PRODUCTION_STOP_ITEM_COLUMNS.length - 1])
|
||||
.toMatchObject({ key: 'demandStatus', label: '需求状态', group: '状态跟踪' })
|
||||
expect(PRODUCTION_STOP_ITEM_COLUMNS).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ key: 'confirmationStatus', label: '停产确认' }),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('停产原因分类包含配置表里的初始选项', () => {
|
||||
expect(PRODUCTION_STOP_DEFAULT_REASON_CATEGORIES).toEqual([
|
||||
'滞销款',
|
||||
'精简SKU',
|
||||
'产品升级改款',
|
||||
'专利问题',
|
||||
'资料编辑错误',
|
||||
'同条码其他供应商生产',
|
||||
'C/D类产品淘汰',
|
||||
'高差评低销量产品淘汰',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('是否同意停产确认截止时间', () => {
|
||||
it('当天 15:00 之前提交,当天 16:00 截止', () => {
|
||||
// 北京时间 2026-10-07 14:00 提交 → 当天 16:00(08:00Z)截止
|
||||
expect(
|
||||
resolveProductionStopConfirmationDeadline(
|
||||
new Date('2026-10-07T06:00:00.000Z'),
|
||||
).toISOString(),
|
||||
).toBe('2026-10-07T08:00:00.000Z')
|
||||
})
|
||||
|
||||
it('14:59 仍按当天截止,15:00 起顺延到次日 16:00', () => {
|
||||
expect(
|
||||
resolveProductionStopConfirmationDeadline(
|
||||
new Date('2026-10-07T06:59:00.000Z'),
|
||||
).toISOString(),
|
||||
).toBe('2026-10-07T08:00:00.000Z')
|
||||
expect(
|
||||
resolveProductionStopConfirmationDeadline(
|
||||
new Date('2026-10-07T07:00:00.000Z'),
|
||||
).toISOString(),
|
||||
).toBe('2026-10-08T08:00:00.000Z')
|
||||
})
|
||||
|
||||
it('晚上提交的需求,次日 16:00 截止', () => {
|
||||
// 北京时间 2026-10-07 20:30 提交 → 次日 16:00(2026-10-08T08:00Z)
|
||||
expect(
|
||||
resolveProductionStopConfirmationDeadline(
|
||||
new Date('2026-10-07T12:30:00.000Z'),
|
||||
).toISOString(),
|
||||
).toBe('2026-10-08T08:00:00.000Z')
|
||||
})
|
||||
})
|
||||
325
furniture-3d-system/src/lib/production-stop-demands.ts
Normal file
325
furniture-3d-system/src/lib/production-stop-demands.ts
Normal file
@ -0,0 +1,325 @@
|
||||
// 停产需求管理(初版)共享口径:字段、选项、状态与输入解析。
|
||||
// 需求来源:《停产需求管理需求xlsx》需求明细 + 停产原因分类基础选项配置。
|
||||
|
||||
export const PRODUCTION_STOP_REPLACE_TYPES = ['相同款', '相似款'] as const
|
||||
export const PRODUCTION_STOP_IMAGE_CHANGE_OPTIONS = ['换图', '不换图'] as const
|
||||
|
||||
// 「是否同意停产」确认:由指定运营总监/指定人投票,任意一人不同意即最终不同意,超时默认同意。
|
||||
export const PRODUCTION_STOP_CONFIRMATION_STATUSES = ['PENDING', 'AGREED', 'REJECTED'] as const
|
||||
export type ProductionStopConfirmationStatus =
|
||||
(typeof PRODUCTION_STOP_CONFIRMATION_STATUSES)[number]
|
||||
|
||||
export const PRODUCTION_STOP_CONFIRMATION_DECISIONS = ['AGREED', 'REJECTED'] as const
|
||||
export type ProductionStopConfirmationDecision =
|
||||
(typeof PRODUCTION_STOP_CONFIRMATION_DECISIONS)[number]
|
||||
|
||||
export const PRODUCTION_STOP_CONFIRMATION_LABELS: Record<ProductionStopConfirmationStatus, string> = {
|
||||
PENDING: '待确认',
|
||||
AGREED: '同意停产',
|
||||
REJECTED: '不同意停产',
|
||||
}
|
||||
|
||||
export const PRODUCTION_STOP_CONFIRMATION_SOURCES = ['USER', 'TIMEOUT'] as const
|
||||
export type ProductionStopConfirmationSource =
|
||||
(typeof PRODUCTION_STOP_CONFIRMATION_SOURCES)[number]
|
||||
|
||||
export const PRODUCTION_STOP_CONFIRMATION_SOURCE_LABELS: Record<ProductionStopConfirmationSource, string> = {
|
||||
USER: '人工确认',
|
||||
TIMEOUT: '超时默认同意',
|
||||
}
|
||||
|
||||
// 需求状态:需求全生命周期(停产确认只是其中一个环节)
|
||||
export const PRODUCTION_STOP_DEMAND_STATUSES = [
|
||||
'PENDING_CONFIRMATION',
|
||||
'REJECTED',
|
||||
'CONFIRMED_WAIT_FLOW',
|
||||
'FLOW_IN_APPROVAL',
|
||||
'FLOW_APPROVED',
|
||||
] as const
|
||||
export type ProductionStopDemandStatus = (typeof PRODUCTION_STOP_DEMAND_STATUSES)[number]
|
||||
|
||||
export const PRODUCTION_STOP_DEMAND_STATUS_LABELS: Record<ProductionStopDemandStatus, string> = {
|
||||
PENDING_CONFIRMATION: '待确认',
|
||||
REJECTED: '需求驳回',
|
||||
CONFIRMED_WAIT_FLOW: '已确认待走流程',
|
||||
FLOW_IN_APPROVAL: '已走流程待审批',
|
||||
FLOW_APPROVED: '流程审批通过',
|
||||
}
|
||||
|
||||
export type ProductionStopDemandStatusTone = 'pending' | 'rejected' | 'agreed' | 'flow'
|
||||
|
||||
export const PRODUCTION_STOP_DEMAND_STATUS_TONES: Record<
|
||||
ProductionStopDemandStatus,
|
||||
ProductionStopDemandStatusTone
|
||||
> = {
|
||||
PENDING_CONFIRMATION: 'pending',
|
||||
REJECTED: 'rejected',
|
||||
CONFIRMED_WAIT_FLOW: 'agreed',
|
||||
FLOW_IN_APPROVAL: 'flow',
|
||||
FLOW_APPROVED: 'agreed',
|
||||
}
|
||||
|
||||
/** 产品全量档案里出现这些状态时,视为「流程审批通过」(已退市/已停产)。 */
|
||||
export const PRODUCTION_STOP_APPROVED_PRODUCT_STATUSES = ['停产', '退市'] as const
|
||||
|
||||
/** PLM 流程明细里 prostatus=2/3 对应「条码恢复流程」,视为流程拒绝(需求驳回)。 */
|
||||
export const PRODUCTION_STOP_FLOW_REJECTED_PROSTATUS = [2, 3] as const
|
||||
|
||||
export const PRODUCTION_STOP_CONFIRMATION_SUBMIT_CUTOFF_HOUR = 15
|
||||
export const PRODUCTION_STOP_CONFIRMATION_DEADLINE_HOUR = 16
|
||||
const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* 确认截止时间(北京时间):
|
||||
* - 当天 15:00 之前提交的需求 → 当天 16:00 截止;
|
||||
* - 15:00 及之后提交的需求 → 次日 16:00 截止。
|
||||
*/
|
||||
export function resolveProductionStopConfirmationDeadline(submittedAt: Date): Date {
|
||||
const shanghai = new Date(submittedAt.getTime() + SHANGHAI_OFFSET_MS)
|
||||
const dayStartUtcMs = Date.UTC(
|
||||
shanghai.getUTCFullYear(),
|
||||
shanghai.getUTCMonth(),
|
||||
shanghai.getUTCDate(),
|
||||
)
|
||||
const hoursToAdd = shanghai.getUTCHours() < PRODUCTION_STOP_CONFIRMATION_SUBMIT_CUTOFF_HOUR
|
||||
? PRODUCTION_STOP_CONFIRMATION_DEADLINE_HOUR
|
||||
: 24 + PRODUCTION_STOP_CONFIRMATION_DEADLINE_HOUR
|
||||
return new Date(dayStartUtcMs + hoursToAdd * 60 * 60 * 1000 - SHANGHAI_OFFSET_MS)
|
||||
}
|
||||
|
||||
export const PRODUCTION_STOP_MAX_BARCODES = 500
|
||||
export const PRODUCTION_STOP_DELETE_MAX_ITEMS = 200
|
||||
export const PRODUCTION_STOP_REASON_DETAIL_MAX_LENGTH = 500
|
||||
export const PRODUCTION_STOP_NOTE_MAX_LENGTH = 500
|
||||
export const PRODUCTION_STOP_MODIFICATION_MAX_LENGTH = 500
|
||||
|
||||
// 停产原因分类初始选项(系统管理员可在页面内增删改,首次使用时自动落库)。
|
||||
export const PRODUCTION_STOP_DEFAULT_REASON_CATEGORIES = [
|
||||
'滞销款',
|
||||
'精简SKU',
|
||||
'产品升级改款',
|
||||
'专利问题',
|
||||
'资料编辑错误',
|
||||
'同条码其他供应商生产',
|
||||
'C/D类产品淘汰',
|
||||
'高差评低销量产品淘汰',
|
||||
] as const
|
||||
|
||||
export type ProductionStopItemColumn = {
|
||||
key: ProductionStopItemSortKey
|
||||
label: string
|
||||
width: number
|
||||
group: '需求信息' | '条码档案' | '替换信息' | '状态跟踪'
|
||||
}
|
||||
|
||||
export const PRODUCTION_STOP_ITEM_SORT_KEYS = [
|
||||
'submittedAt',
|
||||
'requesterName',
|
||||
'department',
|
||||
'reasonCategory',
|
||||
'reasonDetail',
|
||||
'barcode',
|
||||
'replaceType',
|
||||
'replaceBarcode',
|
||||
'imageChange',
|
||||
'modificationPoint',
|
||||
'categoryL2',
|
||||
'spu',
|
||||
'sku',
|
||||
'productStatus',
|
||||
'supplierName',
|
||||
'purchaseOwner',
|
||||
'barcodeCreatedAt',
|
||||
'brand',
|
||||
'productTag',
|
||||
'productLabel',
|
||||
'postStopSkuStatus',
|
||||
'sales30dWithBig',
|
||||
'sales30dWithBigSku',
|
||||
'opsDirector',
|
||||
'topSalesOpsDirector',
|
||||
'stopSuggestion',
|
||||
'confirmationStatus',
|
||||
'demandStatus',
|
||||
] as const
|
||||
export type ProductionStopItemSortKey = (typeof PRODUCTION_STOP_ITEM_SORT_KEYS)[number]
|
||||
|
||||
export function isProductionStopItemSortKey(value: string): value is ProductionStopItemSortKey {
|
||||
return (PRODUCTION_STOP_ITEM_SORT_KEYS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
// 明细列表列口径,顺序与需求表的字段顺序一致,末尾追加采购反馈列。
|
||||
export const PRODUCTION_STOP_ITEM_COLUMNS: readonly ProductionStopItemColumn[] = [
|
||||
{ key: 'submittedAt', label: '需求时间', width: 150, group: '需求信息' },
|
||||
{ key: 'requesterName', label: '需求人', width: 100, group: '需求信息' },
|
||||
{ key: 'department', label: '需求部门', width: 120, group: '需求信息' },
|
||||
{ key: 'reasonCategory', label: '停产原因分类', width: 150, group: '需求信息' },
|
||||
{ key: 'reasonDetail', label: '停产原因详细说明', width: 180, group: '需求信息' },
|
||||
{ key: 'barcode', label: '条码', width: 170, group: '需求信息' },
|
||||
{ key: 'replaceType', label: '替换类型', width: 100, group: '替换信息' },
|
||||
{ key: 'replaceBarcode', label: '替换编码', width: 170, group: '替换信息' },
|
||||
{ key: 'imageChange', label: '是否换图', width: 90, group: '替换信息' },
|
||||
{ key: 'modificationPoint', label: '修改点', width: 160, group: '替换信息' },
|
||||
{ key: 'categoryL2', label: '分类', width: 130, group: '条码档案' },
|
||||
{ key: 'spu', label: 'SPU', width: 130, group: '条码档案' },
|
||||
{ key: 'sku', label: 'SKU', width: 130, group: '条码档案' },
|
||||
{ key: 'productStatus', label: '条码生产状态', width: 110, group: '条码档案' },
|
||||
{ key: 'supplierName', label: '供应商', width: 140, group: '条码档案' },
|
||||
{ key: 'purchaseOwner', label: '采购', width: 90, group: '条码档案' },
|
||||
{ key: 'barcodeCreatedAt', label: '条码创建时间', width: 130, group: '条码档案' },
|
||||
{ key: 'brand', label: '品牌', width: 130, group: '条码档案' },
|
||||
{ key: 'productTag', label: '商品标签', width: 150, group: '条码档案' },
|
||||
{ key: 'productLabel', label: '产品标签', width: 110, group: '条码档案' },
|
||||
{ key: 'postStopSkuStatus', label: '停产后货号状态', width: 130, group: '条码档案' },
|
||||
{ key: 'sales30dWithBig', label: '含大单近30日日销-条码', width: 160, group: '条码档案' },
|
||||
{ key: 'sales30dWithBigSku', label: '含大单近30日日销-SKU', width: 160, group: '条码档案' },
|
||||
{ key: 'opsDirector', label: '运营总负责人', width: 120, group: '条码档案' },
|
||||
{ key: 'topSalesOpsDirector', label: '销量最高运营总负责人', width: 170, group: '条码档案' },
|
||||
{ key: 'stopSuggestion', label: '盘停建议', width: 120, group: '条码档案' },
|
||||
{ key: 'confirmationStatus', label: '停产确认', width: 150, group: '状态跟踪' },
|
||||
{ key: 'demandStatus', label: '需求状态', width: 140, group: '状态跟踪' },
|
||||
]
|
||||
|
||||
export type ProductionStopItemRow = {
|
||||
id: string
|
||||
demandId: string
|
||||
demandNo: string
|
||||
submittedAt: string
|
||||
requesterName: string
|
||||
department: string
|
||||
reasonCategory: string
|
||||
reasonDetail: string
|
||||
barcode: string
|
||||
categoryL1: string
|
||||
categoryL2: string
|
||||
spu: string
|
||||
sku: string
|
||||
productName: string
|
||||
productStatus: string
|
||||
supplierName: string
|
||||
purchaseOwner: string
|
||||
barcodeCreatedAt: string
|
||||
brand: string
|
||||
productTag: string
|
||||
productLabel: string
|
||||
postStopSkuStatus: string
|
||||
sales30dWithBig: string
|
||||
sales30dWithBigSku: string
|
||||
opsDirector: string
|
||||
topSalesOpsDirector: string
|
||||
stopSuggestion: string
|
||||
replaceType: string
|
||||
replaceBarcode: string
|
||||
imageChange: string
|
||||
modificationPoint: string
|
||||
confirmationStatus: ProductionStopConfirmationStatus
|
||||
confirmationDeadline: string
|
||||
confirmationAt: string
|
||||
confirmationReasons: string
|
||||
confirmationVoteCount: number
|
||||
demandStatus: ProductionStopDemandStatus
|
||||
demandStatusAt: string
|
||||
demandStatusNote: string
|
||||
}
|
||||
|
||||
export type ProductionStopItemFilters = {
|
||||
keyword?: string
|
||||
confirmationStatus?: ProductionStopConfirmationStatus
|
||||
demandStatus?: ProductionStopDemandStatus
|
||||
demandStatuses?: ProductionStopDemandStatus[]
|
||||
reasonCategory?: string
|
||||
purchaseOwner?: string
|
||||
requesterName?: string
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
sortKey?: ProductionStopItemSortKey
|
||||
sortDirection?: 'asc' | 'desc'
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export type ProductionStopConfirmationVote = {
|
||||
id: string
|
||||
approverId: string
|
||||
approverUserId: string
|
||||
approverName: string
|
||||
decision: ProductionStopConfirmationDecision
|
||||
reason: string
|
||||
source: ProductionStopConfirmationSource
|
||||
respondedAt: string
|
||||
}
|
||||
|
||||
export type ProductionStopConfirmationInfo = {
|
||||
demandId: string
|
||||
demandNo: string
|
||||
status: ProductionStopConfirmationStatus
|
||||
demandStatus: ProductionStopDemandStatus
|
||||
demandStatusAt: string
|
||||
demandStatusNote: string
|
||||
deadline: string
|
||||
confirmedAt: string
|
||||
reasons: string
|
||||
pushedAt: string
|
||||
pushMessage: string
|
||||
canConfirm: boolean
|
||||
myDecision: ProductionStopConfirmationDecision | ''
|
||||
myReason: string
|
||||
votes: ProductionStopConfirmationVote[]
|
||||
approvers: Array<{ id: string; name: string }>
|
||||
}
|
||||
|
||||
export type ProductionStopConfirmationApproverOption = {
|
||||
id: string
|
||||
userId: string
|
||||
userName: string
|
||||
dingtalkUnionId: string
|
||||
isActive: boolean
|
||||
sortOrder: number
|
||||
voteCount: number
|
||||
}
|
||||
|
||||
export type ProductionStopFlowOwnerOption = {
|
||||
id: string
|
||||
userId: string
|
||||
userName: string
|
||||
isActive: boolean
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export type ProductionStopItemPage = {
|
||||
rows: ProductionStopItemRow[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
totalPages: number
|
||||
filterOptions: {
|
||||
reasonCategories: string[]
|
||||
purchaseOwners: string[]
|
||||
requesterNames: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export type ProductionStopReasonCategoryOption = {
|
||||
id: string
|
||||
name: string
|
||||
sortOrder: number
|
||||
isActive: boolean
|
||||
usedByDemandCount: number
|
||||
}
|
||||
|
||||
/** 批量条码文本解析:支持换行、逗号、分号、制表符与空格分隔,去重且保持输入顺序。 */
|
||||
export function parseBarcodeInput(value: string): string[] {
|
||||
const tokens = String(value ?? '')
|
||||
.split(/[\s,;,;、]+/)
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean)
|
||||
const seen = new Set<string>()
|
||||
const result: string[] = []
|
||||
for (const token of tokens) {
|
||||
const normalized = token.toUpperCase()
|
||||
if (seen.has(normalized)) continue
|
||||
seen.add(normalized)
|
||||
result.push(token)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@ -362,4 +362,34 @@ describe('supply chain permission settings', () => {
|
||||
),
|
||||
).toEqual({ targetKey: 'businessManagement', action: 'edit' })
|
||||
})
|
||||
|
||||
it('maps production stop demand routes and API exemptions', () => {
|
||||
expect(
|
||||
getSupplyChainPermissionTargetForPath('/supply-chain/management/production-stop-demands'),
|
||||
).toBe('businessManagement')
|
||||
expect(
|
||||
getSupplyChainApiPermissionRequirement(
|
||||
'/api/supply-chain/management/production-stop-demands',
|
||||
'GET',
|
||||
),
|
||||
).toBeNull()
|
||||
expect(
|
||||
getSupplyChainApiPermissionRequirement(
|
||||
'/api/supply-chain/management/production-stop-demands',
|
||||
'POST',
|
||||
),
|
||||
).toEqual({ targetKey: 'businessManagement', action: 'edit' })
|
||||
expect(
|
||||
getSupplyChainApiPermissionRequirement(
|
||||
'/api/supply-chain/management/production-stop-demands/confirmations',
|
||||
'POST',
|
||||
),
|
||||
).toBeNull()
|
||||
expect(
|
||||
getSupplyChainApiPermissionRequirement(
|
||||
'/api/supply-chain/management/production-stop-demands/demands/abc/status',
|
||||
'PATCH',
|
||||
),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@ -400,6 +400,7 @@ export function getSupplyChainPermissionTargetForPath(pathname: string) {
|
||||
if (isManagementBlankPagePath(pathname)) return 'businessManagement' as const
|
||||
if (pathname === '/supply-chain/management/employee-hours') return 'employeeHours' as const
|
||||
if (pathname === '/supply-chain/management/logistics-arrival-signing-duration') return 'businessManagement' as const
|
||||
if (pathname === '/supply-chain/management/production-stop-demands') return 'businessManagement' as const
|
||||
if (pathname === '/supply-chain/database/product-archive') return 'productArchive' as const
|
||||
if (pathname === '/supply-chain/database/category-config') return 'categoryConfig' as const
|
||||
if (pathname === '/supply-chain/database/warehouse-config') return 'warehouseConfig' as const
|
||||
@ -434,6 +435,20 @@ export function getSupplyChainApiPermissionRequirement(pathname: string, method:
|
||||
if (pathname.startsWith('/api/supply-chain/management/employee-hours')) {
|
||||
return { targetKey: 'employeeHours' as const, action: 'view' as const }
|
||||
}
|
||||
if (pathname.startsWith('/api/supply-chain/management/production-stop-demands')) {
|
||||
// 指定确认人的确认接口与列表只读权限在服务层单独校验(确认人可能没有经营管理权限)。
|
||||
if (pathname === '/api/supply-chain/management/production-stop-demands/confirmations') {
|
||||
return null
|
||||
}
|
||||
// 需求状态流转:重新提交需经营管理编辑权限,强制确认/走流程需流程负责人权限,均在服务层校验。
|
||||
if (/^\/api\/supply-chain\/management\/production-stop-demands\/demands\/(status|[^/]+\/status)$/.test(pathname)) {
|
||||
return null
|
||||
}
|
||||
if (pathname === '/api/supply-chain/management/production-stop-demands' && method.toUpperCase() === 'GET') {
|
||||
return null
|
||||
}
|
||||
return { targetKey: 'businessManagement' as const, action }
|
||||
}
|
||||
if (pathname === '/api/supply-chain/1688-dropship-data/filter-options') {
|
||||
return { targetKey: 'newProduct1688DropshipData' as const, action: method.toUpperCase() === 'POST' ? 'view' as const : action }
|
||||
}
|
||||
|
||||
@ -44,6 +44,13 @@ export enum ErrorCode {
|
||||
SUPPLY_CHAIN_CATEGORY_GROUP_TIER_NOT_FOUND = 'SUPPLY_CHAIN_CATEGORY_GROUP_TIER_001',
|
||||
SUPPLY_CHAIN_CATEGORY_GROUP_TIER_EXISTS = 'SUPPLY_CHAIN_CATEGORY_GROUP_TIER_002',
|
||||
|
||||
// 停产需求管理错误 PRODUCTION_STOP_xxx
|
||||
PRODUCTION_STOP_DEMAND_NOT_FOUND = 'PRODUCTION_STOP_001',
|
||||
PRODUCTION_STOP_ITEM_NOT_FOUND = 'PRODUCTION_STOP_002',
|
||||
PRODUCTION_STOP_REASON_CATEGORY_NOT_FOUND = 'PRODUCTION_STOP_003',
|
||||
PRODUCTION_STOP_REASON_CATEGORY_EXISTS = 'PRODUCTION_STOP_004',
|
||||
PRODUCTION_STOP_REASON_CATEGORY_IN_USE = 'PRODUCTION_STOP_005',
|
||||
|
||||
// SKU 错误 SKU_xxx
|
||||
SKU_NOT_FOUND = 'SKU_001',
|
||||
SKU_DUPLICATE_CODE = 'SKU_002',
|
||||
@ -133,6 +140,12 @@ export const ERROR_MESSAGES: Record<ErrorCode, string> = {
|
||||
[ErrorCode.SUPPLY_CHAIN_WAREHOUSE_CONFIG_NOT_FOUND]: '仓库配置不存在',
|
||||
[ErrorCode.SUPPLY_CHAIN_WAREHOUSE_CONFIG_EXISTS]: '仓库配置已存在',
|
||||
[ErrorCode.SUPPLY_CHAIN_OWNER_CONFIG_NOT_FOUND]: '负责人配置不存在',
|
||||
// 停产需求管理
|
||||
[ErrorCode.PRODUCTION_STOP_DEMAND_NOT_FOUND]: '停产需求不存在',
|
||||
[ErrorCode.PRODUCTION_STOP_ITEM_NOT_FOUND]: '停产需求明细不存在',
|
||||
[ErrorCode.PRODUCTION_STOP_REASON_CATEGORY_NOT_FOUND]: '停产原因分类不存在',
|
||||
[ErrorCode.PRODUCTION_STOP_REASON_CATEGORY_EXISTS]: '停产原因分类已存在',
|
||||
[ErrorCode.PRODUCTION_STOP_REASON_CATEGORY_IN_USE]: '该停产原因分类已被需求使用,不能删除',
|
||||
[ErrorCode.SUPPLY_CHAIN_CATEGORY_GROUP_TIER_NOT_FOUND]: '分类档位不存在',
|
||||
[ErrorCode.SUPPLY_CHAIN_CATEGORY_GROUP_TIER_EXISTS]: '该分类下已存在同名规模标签',
|
||||
// 认证错误
|
||||
@ -249,6 +262,11 @@ export const ERROR_HTTP_STATUS: Record<ErrorCode, number> = {
|
||||
[ErrorCode.SUPPLY_CHAIN_OWNER_CONFIG_NOT_FOUND]: HTTP_STATUS.NOT_FOUND,
|
||||
[ErrorCode.SUPPLY_CHAIN_CATEGORY_GROUP_TIER_NOT_FOUND]: HTTP_STATUS.NOT_FOUND,
|
||||
[ErrorCode.SUPPLY_CHAIN_CATEGORY_GROUP_TIER_EXISTS]: HTTP_STATUS.CONFLICT,
|
||||
[ErrorCode.PRODUCTION_STOP_DEMAND_NOT_FOUND]: HTTP_STATUS.NOT_FOUND,
|
||||
[ErrorCode.PRODUCTION_STOP_ITEM_NOT_FOUND]: HTTP_STATUS.NOT_FOUND,
|
||||
[ErrorCode.PRODUCTION_STOP_REASON_CATEGORY_NOT_FOUND]: HTTP_STATUS.NOT_FOUND,
|
||||
[ErrorCode.PRODUCTION_STOP_REASON_CATEGORY_EXISTS]: HTTP_STATUS.CONFLICT,
|
||||
[ErrorCode.PRODUCTION_STOP_REASON_CATEGORY_IN_USE]: HTTP_STATUS.CONFLICT,
|
||||
// 认证错误 -> 401/403
|
||||
[ErrorCode.AUTH_INVALID_CREDENTIALS]: HTTP_STATUS.UNAUTHORIZED,
|
||||
[ErrorCode.AUTH_INVALID_TOKEN]: HTTP_STATUS.UNAUTHORIZED,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,106 @@
|
||||
import 'server-only'
|
||||
|
||||
import sql from 'mssql'
|
||||
|
||||
const DETAIL_FLOW_TABLE = 'plm_productDiscontinuationDetail_flow'
|
||||
const FLOW_TABLE = 'plm_productDiscontinuation_flow'
|
||||
const DEFAULT_SCHEMA = 'rpt'
|
||||
|
||||
export type ProductionStopFlowRow = {
|
||||
skucode: string
|
||||
prostatus: number | null
|
||||
state: number | null
|
||||
flowId: string | null
|
||||
flowStatus: number | null
|
||||
title: string | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
function requiredEnv(name: string) {
|
||||
const value = process.env[name]?.trim()
|
||||
if (!value) throw new Error(`Missing required environment variable: ${name}`)
|
||||
return value
|
||||
}
|
||||
|
||||
function booleanEnv(name: string, fallback: boolean) {
|
||||
const value = process.env[name]?.trim().toLowerCase()
|
||||
if (!value) return fallback
|
||||
return value === 'true' || value === '1'
|
||||
}
|
||||
|
||||
function safeIdentifier(value: string) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
||||
throw new Error('JST_REPORT_DB_SCHEMA is not a valid SQL Server identifier')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
class ProductionStopFlowRepositoryClass {
|
||||
private pool: sql.ConnectionPool | null = null
|
||||
|
||||
private async getPool() {
|
||||
if (this.pool?.connected) return this.pool
|
||||
this.pool = await new sql.ConnectionPool({
|
||||
server: requiredEnv('JST_REPORT_DB_HOST'),
|
||||
port: Number(process.env.JST_REPORT_DB_PORT?.trim() || '1433'),
|
||||
database: requiredEnv('JST_REPORT_DB_NAME'),
|
||||
user: requiredEnv('JST_REPORT_DB_USER'),
|
||||
password: requiredEnv('JST_REPORT_DB_PASSWORD'),
|
||||
options: {
|
||||
encrypt: booleanEnv('JST_REPORT_DB_ENCRYPT', true),
|
||||
trustServerCertificate: booleanEnv('JST_REPORT_DB_TRUST_SERVER_CERTIFICATE', true),
|
||||
},
|
||||
pool: { min: 0, max: 4, idleTimeoutMillis: 30_000 },
|
||||
requestTimeout: 30_000,
|
||||
connectionTimeout: 15_000,
|
||||
}).connect()
|
||||
return this.pool
|
||||
}
|
||||
|
||||
/** 按条码(skucode)取每个条码最新一条停产流程明细。 */
|
||||
async listLatestBySkucodes(codes: readonly string[]): Promise<ProductionStopFlowRow[]> {
|
||||
const normalized = Array.from(
|
||||
new Set(codes.map((code) => code.trim().toUpperCase()).filter(Boolean)),
|
||||
).slice(0, 200)
|
||||
if (!normalized.length) return []
|
||||
const schema = safeIdentifier(process.env.JST_REPORT_DB_SCHEMA?.trim() || DEFAULT_SCHEMA)
|
||||
const pool = await this.getPool()
|
||||
const request = pool.request()
|
||||
const parameters = normalized.map((code, index) => {
|
||||
const parameter = `code${index}`
|
||||
request.input(parameter, sql.VarChar(100), code)
|
||||
return `@${parameter}`
|
||||
})
|
||||
const result = await request.query<ProductionStopFlowRow>(`
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
UPPER(LTRIM(RTRIM(CAST(d.skucode AS varchar(100))))) AS skucode,
|
||||
d.prostatus AS prostatus,
|
||||
d.state AS state,
|
||||
CAST(d.flowId AS varchar(100)) AS flowId,
|
||||
f.flowStatus AS flowStatus,
|
||||
CAST(f.title AS varchar(400)) AS title,
|
||||
CAST(f.remark AS varchar(400)) AS remark,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY UPPER(LTRIM(RTRIM(CAST(d.skucode AS varchar(100)))))
|
||||
ORDER BY d.Id DESC
|
||||
) AS row_rank
|
||||
FROM [${schema}].[${DETAIL_FLOW_TABLE}] d
|
||||
LEFT JOIN [${schema}].[${FLOW_TABLE}] f ON f.flowId = d.flowId
|
||||
WHERE UPPER(LTRIM(RTRIM(CAST(d.skucode AS varchar(100))))) IN (${parameters.join(', ')})
|
||||
)
|
||||
SELECT skucode, prostatus, state, flowId, flowStatus, title, remark
|
||||
FROM ranked
|
||||
WHERE row_rank = 1
|
||||
`)
|
||||
return result.recordset
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (!this.pool) return
|
||||
await this.pool.close()
|
||||
this.pool = null
|
||||
}
|
||||
}
|
||||
|
||||
export const productionStopFlowRepository = new ProductionStopFlowRepositoryClass()
|
||||
@ -38,6 +38,8 @@ const ACTION_LABELS: Record<OperationLogAction, string> = {
|
||||
JUSHUITAN_PRODUCT_PUSH: '聚水潭商品推送',
|
||||
JUSHUITAN_VIRTUAL_INVENTORY_PUSH: '聚水潭虚拟库存推送',
|
||||
SUPPLY_CHAIN_DROPSHIP_ISSUE_WRITE: '代发问题单写入',
|
||||
PRODUCTION_STOP_DEMAND_DELETE: '停产需求明细删除',
|
||||
PRODUCTION_STOP_DEMAND_UPDATE: '停产需求原因修改',
|
||||
}
|
||||
|
||||
export type OperationLogFailurePayload = {
|
||||
|
||||
@ -0,0 +1,670 @@
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import type { ProductionStopItemRow } from '@/lib/production-stop-demands'
|
||||
import {
|
||||
ProductionStopArchiveUnavailableError,
|
||||
productionStopDemandRepository,
|
||||
} from '@/server/repositories/production-stop-demands.repository'
|
||||
import { dingtalkTodoService } from '@/server/services/dingtalk-todo.service'
|
||||
import { operationLogService } from '@/server/services/operation-log.service'
|
||||
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
|
||||
import { productionStopDemandsService } from './production-stop-demands.service'
|
||||
|
||||
jest.mock('@/server/services/operation-log.service', () => ({
|
||||
operationLogService: { record: jest.fn() },
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services/dingtalk-todo.service', () => ({
|
||||
dingtalkTodoService: { isEnabled: jest.fn(() => false), createTask: jest.fn() },
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services/supply-chain-permission-settings.service', () => ({
|
||||
supplyChainPermissionSettingsService: {
|
||||
getUserAccess: jest.fn(async () => ({ view: true, download: true, edit: true })),
|
||||
},
|
||||
}))
|
||||
|
||||
// mssql 依赖的浏览器构建在 jest 环境无法解析,这里直接替换流程数据源仓储。
|
||||
jest.mock('@/server/repositories/production-stop-flow.repository', () => ({
|
||||
productionStopFlowRepository: {
|
||||
listLatestBySkucodes: jest.fn(async () => []),
|
||||
close: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/server/repositories/production-stop-demands.repository', () => {
|
||||
const actual = jest.requireActual('@/server/repositories/production-stop-demands.repository')
|
||||
return {
|
||||
ProductionStopArchiveUnavailableError: actual.ProductionStopArchiveUnavailableError,
|
||||
productionStopDemandRepository: {
|
||||
ensureReasonCategoryDefaults: jest.fn(),
|
||||
listItemsPage: jest.fn(),
|
||||
findArchivedProfiles: jest.fn(),
|
||||
findExistingArchiveCodes: jest.fn(),
|
||||
findSuggestionsByBarcodes: jest.fn(),
|
||||
listSuggestionConfigs: jest.fn(),
|
||||
upsertSuggestionConfigs: jest.fn(),
|
||||
deleteSuggestionConfig: jest.fn(),
|
||||
listSkuSalesBarcodes: jest.fn(),
|
||||
listActiveProcessBarcodes: jest.fn(),
|
||||
nextDemandNo: jest.fn(),
|
||||
createDemand: jest.fn(),
|
||||
updateItemFields: jest.fn(),
|
||||
updateDemandReason: jest.fn(),
|
||||
findDemandById: jest.fn(),
|
||||
listOverduePendingDemands: jest.fn(),
|
||||
listConfirmationVotes: jest.fn(),
|
||||
upsertConfirmationVote: jest.fn(),
|
||||
insertTimeoutVotes: jest.fn(),
|
||||
updateDemandConfirmation: jest.fn(),
|
||||
listConfirmationApprovers: jest.fn(),
|
||||
listActiveApprovers: jest.fn(),
|
||||
findApproverByUserId: jest.fn(),
|
||||
findApproverById: jest.fn(),
|
||||
createConfirmationApprover: jest.fn(),
|
||||
updateConfirmationApprover: jest.fn(),
|
||||
deleteConfirmationApprover: jest.fn(),
|
||||
countApproverVotes: jest.fn(),
|
||||
searchApproverCandidates: jest.fn(),
|
||||
findUserById: jest.fn(),
|
||||
deleteItems: jest.fn(),
|
||||
listReasonCategories: jest.fn(),
|
||||
findOwnerDepartmentByName: jest.fn(),
|
||||
findReasonCategoryByName: jest.fn(),
|
||||
createReasonCategory: jest.fn(),
|
||||
updateReasonCategory: jest.fn(),
|
||||
deleteReasonCategory: jest.fn(),
|
||||
countDemandsByReasonCategory: jest.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const repo = productionStopDemandRepository as unknown as { [key: string]: jest.Mock }
|
||||
const todoService = dingtalkTodoService as unknown as {
|
||||
isEnabled: jest.Mock
|
||||
createTask: jest.Mock
|
||||
}
|
||||
const permissionService = supplyChainPermissionSettingsService as unknown as {
|
||||
getUserAccess: jest.Mock
|
||||
}
|
||||
|
||||
const user = { id: 'u1', name: '员工1', role: 'USER' } as AuthUser
|
||||
|
||||
function archiveProfile(overrides: Partial<{
|
||||
barcode: string
|
||||
sku: string
|
||||
productStatus: string
|
||||
}> = {}) {
|
||||
return {
|
||||
barcode: 'A1',
|
||||
categoryL1: '住宅家具',
|
||||
categoryL2: '书架',
|
||||
spu: 'SPU1',
|
||||
sku: 'SKU1',
|
||||
productName: '商品1',
|
||||
productStatus: '销售',
|
||||
supplierName: '供应商1',
|
||||
purchaseOwner: '采购1',
|
||||
brand: '品牌1',
|
||||
productTag: '仓库-崔桥工厂仓',
|
||||
businessCreatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function demandRecord(overrides: Partial<{
|
||||
id: string
|
||||
demandNo: string
|
||||
confirmationStatus: 'PENDING' | 'AGREED' | 'REJECTED'
|
||||
confirmationDeadline: Date | null
|
||||
}> = {}) {
|
||||
return {
|
||||
id: 'demand1',
|
||||
demandNo: 'TC20260930-001',
|
||||
requesterName: '员工1',
|
||||
itemCount: 1,
|
||||
confirmationStatus: 'PENDING' as const,
|
||||
confirmationDeadline: new Date('2026-09-30T08:00:00.000Z'),
|
||||
confirmationAt: null,
|
||||
confirmationReasons: '',
|
||||
confirmationPushedAt: null,
|
||||
confirmationPushMessage: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function vote(overrides: Partial<{
|
||||
id: string
|
||||
approverId: string
|
||||
approverUserId: string
|
||||
approverName: string
|
||||
decision: 'AGREED' | 'REJECTED'
|
||||
reason: string
|
||||
source: 'USER' | 'TIMEOUT'
|
||||
respondedAt: string
|
||||
}> = {}) {
|
||||
return {
|
||||
id: 'vote1',
|
||||
approverId: 'approver1',
|
||||
approverUserId: 'u2',
|
||||
approverName: '总监甲',
|
||||
decision: 'AGREED' as const,
|
||||
reason: '',
|
||||
source: 'USER' as const,
|
||||
respondedAt: '2026-09-30T07:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function approver(overrides: Partial<{
|
||||
id: string
|
||||
userId: string
|
||||
userName: string
|
||||
isActive: boolean
|
||||
dingtalkUnionId: string
|
||||
}> = {}) {
|
||||
return {
|
||||
id: 'approver1',
|
||||
userId: 'u2',
|
||||
userName: '总监甲',
|
||||
dingtalkUnionId: 'union-1',
|
||||
isActive: true,
|
||||
sortOrder: 10,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function itemRowFixture(overrides: Partial<ProductionStopItemRow> = {}): ProductionStopItemRow {
|
||||
return {
|
||||
id: 'item1',
|
||||
demandId: 'demand1',
|
||||
demandNo: 'TC20260930-001',
|
||||
submittedAt: '',
|
||||
requesterName: '',
|
||||
department: '',
|
||||
reasonCategory: '',
|
||||
reasonDetail: '',
|
||||
barcode: 'A1',
|
||||
categoryL1: '',
|
||||
categoryL2: '',
|
||||
spu: '',
|
||||
sku: '',
|
||||
productName: '',
|
||||
productStatus: '',
|
||||
supplierName: '',
|
||||
purchaseOwner: '',
|
||||
barcodeCreatedAt: '',
|
||||
brand: '',
|
||||
productTag: '',
|
||||
productLabel: '',
|
||||
postStopSkuStatus: '',
|
||||
sales30dWithBig: '',
|
||||
sales30dWithBigSku: '',
|
||||
opsDirector: '',
|
||||
topSalesOpsDirector: '',
|
||||
stopSuggestion: '',
|
||||
replaceType: '',
|
||||
replaceBarcode: '',
|
||||
imageChange: '',
|
||||
modificationPoint: '',
|
||||
confirmationStatus: 'PENDING',
|
||||
confirmationDeadline: '',
|
||||
confirmationAt: '',
|
||||
confirmationReasons: '',
|
||||
demandStatus: 'PENDING_CONFIRMATION',
|
||||
demandStatusAt: '',
|
||||
demandStatusNote: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
repo.ensureReasonCategoryDefaults.mockResolvedValue(undefined)
|
||||
repo.findOwnerDepartmentByName.mockResolvedValue('')
|
||||
repo.listActiveProcessBarcodes.mockResolvedValue(new Map())
|
||||
repo.listSkuSalesBarcodes.mockResolvedValue(new Map())
|
||||
repo.nextDemandNo.mockResolvedValue('TC20260930-001')
|
||||
repo.listConfirmationVotes.mockResolvedValue([])
|
||||
repo.listActiveApprovers.mockResolvedValue([])
|
||||
repo.listOverduePendingDemands.mockResolvedValue([])
|
||||
repo.findApproverByUserId.mockResolvedValue(null)
|
||||
repo.findExistingArchiveCodes.mockResolvedValue(new Set())
|
||||
repo.findSuggestionsByBarcodes.mockResolvedValue(new Map())
|
||||
todoService.isEnabled.mockReturnValue(false)
|
||||
permissionService.getUserAccess.mockResolvedValue({ view: true, download: true, edit: true })
|
||||
})
|
||||
|
||||
describe('停产需求:创建', () => {
|
||||
it('原因分类不在已配置范围内时不创建需求', async () => {
|
||||
repo.findReasonCategoryByName.mockResolvedValue(null)
|
||||
|
||||
await expect(
|
||||
productionStopDemandsService.createDemand(
|
||||
{ reasonCategory: '未配置分类', barcodesText: 'A1' },
|
||||
user,
|
||||
),
|
||||
).rejects.toThrow('不在已配置范围内')
|
||||
|
||||
expect(repo.createDemand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('只写入销售条码,非销售条码返回异常,并在流程中条码给出提醒', async () => {
|
||||
repo.findReasonCategoryByName.mockResolvedValue({ id: 'c1', name: '滞销款', isActive: true })
|
||||
repo.findArchivedProfiles.mockResolvedValue(new Map([
|
||||
['A1', archiveProfile()],
|
||||
['B2', archiveProfile({ barcode: 'B2', sku: 'SKU2', productStatus: '停产' })],
|
||||
]))
|
||||
repo.listActiveProcessBarcodes.mockResolvedValue(new Map([['A1', { demandNo: 'TC20260930-001' }]]))
|
||||
repo.listSkuSalesBarcodes.mockResolvedValue(new Map([['SKU1', ['A1', 'A9']]]))
|
||||
repo.findOwnerDepartmentByName.mockResolvedValue('品类一部')
|
||||
repo.nextDemandNo.mockResolvedValue('TC20260930-002')
|
||||
repo.createDemand.mockResolvedValue({ id: 'demand1', demandNo: 'TC20260930-002' })
|
||||
|
||||
const result = await productionStopDemandsService.createDemand(
|
||||
{ reasonCategory: '滞销款', reasonDetail: ' 清货 ', barcodesText: 'a1\nB2\nA1' },
|
||||
user,
|
||||
)
|
||||
|
||||
expect(result.accepted).toEqual(['A1'])
|
||||
expect(result.rejected).toEqual([{ barcode: 'B2', reason: '非销售条码' }])
|
||||
expect(result.warnings).toEqual([
|
||||
{ barcode: 'A1', message: '已在停产流程中(需求单 TC20260930-001)' },
|
||||
])
|
||||
expect(repo.createDemand).toHaveBeenCalledWith(expect.objectContaining({
|
||||
demandNo: 'TC20260930-002',
|
||||
requesterName: '员工1',
|
||||
department: '品类一部',
|
||||
reasonCategory: '滞销款',
|
||||
reasonDetail: '清货',
|
||||
confirmationDeadline: expect.any(Date),
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
barcode: 'A1',
|
||||
sku: 'SKU1',
|
||||
productStatus: '销售',
|
||||
purchaseOwner: '采购1',
|
||||
postStopSkuStatus: '销售',
|
||||
}),
|
||||
],
|
||||
}))
|
||||
})
|
||||
|
||||
it('全部条码都不是销售条码时不创建需求', async () => {
|
||||
repo.findReasonCategoryByName.mockResolvedValue({ id: 'c1', name: '滞销款', isActive: true })
|
||||
repo.findArchivedProfiles.mockResolvedValue(new Map())
|
||||
|
||||
await expect(
|
||||
productionStopDemandsService.createDemand(
|
||||
{ reasonCategory: '滞销款', barcodesText: 'X1\nX2' },
|
||||
user,
|
||||
),
|
||||
).rejects.toThrow('均非销售条码')
|
||||
|
||||
expect(repo.createDemand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('产品档案数据源连不上时给出中文提示且不创建需求', async () => {
|
||||
repo.findReasonCategoryByName.mockResolvedValue({ id: 'c1', name: '滞销款', isActive: true })
|
||||
repo.findArchivedProfiles.mockRejectedValue(
|
||||
new ProductionStopArchiveUnavailableError(
|
||||
new Error('connect ECONNREFUSED 218.93.9.38:25432'),
|
||||
),
|
||||
)
|
||||
|
||||
await expect(
|
||||
productionStopDemandsService.createDemand(
|
||||
{ reasonCategory: '滞销款', barcodesText: 'A1' },
|
||||
user,
|
||||
),
|
||||
).rejects.toThrow('产品档案数据源暂时无法连接')
|
||||
|
||||
expect(repo.createDemand).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('停产需求:是否同意停产确认', () => {
|
||||
it('非指定确认人不能提交确认', async () => {
|
||||
repo.findApproverByUserId.mockResolvedValue(null)
|
||||
|
||||
await expect(
|
||||
productionStopDemandsService.submitConfirmation(
|
||||
{ demandId: 'demand1', decision: 'AGREED', reason: '' },
|
||||
{ id: 'u9', name: '普通人' },
|
||||
),
|
||||
).rejects.toThrow('只有指定的确认人可以提交是否同意停产确认')
|
||||
|
||||
expect(repo.upsertConfirmationVote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('不同意停产必须填写理由', async () => {
|
||||
repo.findApproverByUserId.mockResolvedValue(approver())
|
||||
|
||||
await expect(
|
||||
productionStopDemandsService.submitConfirmation(
|
||||
{ demandId: 'demand1', decision: 'REJECTED', reason: ' ' },
|
||||
{ id: 'u2', name: '总监甲' },
|
||||
),
|
||||
).rejects.toThrow('不同意停产必须填写理由')
|
||||
|
||||
expect(repo.upsertConfirmationVote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('任意一人不同意即最终不同意,并合并展示全部理由', async () => {
|
||||
repo.findApproverByUserId.mockResolvedValue(approver())
|
||||
repo.findDemandById.mockResolvedValue(demandRecord())
|
||||
repo.listConfirmationVotes.mockResolvedValue([
|
||||
vote({ id: 'v1', approverId: 'approver1', approverName: '员工1', decision: 'REJECTED', reason: '原因1' }),
|
||||
vote({ id: 'v2', approverId: 'approver2', approverUserId: 'u3', approverName: '员工2', decision: 'REJECTED', reason: '原因2' }),
|
||||
])
|
||||
|
||||
await productionStopDemandsService.submitConfirmation(
|
||||
{ demandId: 'demand1', decision: 'REJECTED', reason: '原因2' },
|
||||
{ id: 'u2', name: '员工2' },
|
||||
)
|
||||
|
||||
expect(repo.updateDemandConfirmation).toHaveBeenCalledWith('demand1', expect.objectContaining({
|
||||
status: 'REJECTED',
|
||||
reasons: '员工1:原因1\n员工2:原因2',
|
||||
}))
|
||||
})
|
||||
|
||||
it('全部确认人同意后需求单自动变为同意停产', async () => {
|
||||
repo.findApproverByUserId.mockResolvedValue(approver())
|
||||
repo.findDemandById.mockResolvedValue(demandRecord())
|
||||
repo.listActiveApprovers.mockResolvedValue([approver()])
|
||||
repo.listConfirmationVotes.mockResolvedValue([
|
||||
vote({ id: 'v1', approverId: 'approver1', decision: 'AGREED' }),
|
||||
])
|
||||
|
||||
await productionStopDemandsService.submitConfirmation(
|
||||
{ demandId: 'demand1', decision: 'AGREED', reason: '' },
|
||||
{ id: 'u2', name: '总监甲' },
|
||||
)
|
||||
|
||||
expect(repo.updateDemandConfirmation).toHaveBeenCalledWith('demand1', expect.objectContaining({
|
||||
status: 'AGREED',
|
||||
reasons: '',
|
||||
}))
|
||||
})
|
||||
|
||||
it('需求已完成确认后不能再次提交', async () => {
|
||||
repo.findApproverByUserId.mockResolvedValue(approver())
|
||||
repo.findDemandById
|
||||
.mockResolvedValueOnce(demandRecord())
|
||||
.mockResolvedValueOnce(demandRecord({ confirmationStatus: 'AGREED' }))
|
||||
|
||||
await expect(
|
||||
productionStopDemandsService.submitConfirmation(
|
||||
{ demandId: 'demand1', decision: 'REJECTED', reason: '反悔' },
|
||||
{ id: 'u2', name: '总监甲' },
|
||||
),
|
||||
).rejects.toThrow('该需求已完成确认(同意停产)')
|
||||
})
|
||||
|
||||
it('超过截止时间未反馈按默认同意落库,并记录超时票', async () => {
|
||||
repo.listOverduePendingDemands.mockResolvedValue([demandRecord()])
|
||||
repo.listConfirmationVotes.mockResolvedValue([
|
||||
vote({ id: 'v1', approverId: 'approver1', decision: 'AGREED' }),
|
||||
])
|
||||
repo.listActiveApprovers.mockResolvedValue([
|
||||
approver(),
|
||||
approver({ id: 'approver2', userId: 'u3', userName: '总监乙' }),
|
||||
])
|
||||
|
||||
const finalized = await productionStopDemandsService.finalizeOverdueConfirmations(
|
||||
new Date('2026-09-30T09:00:00.000Z'),
|
||||
)
|
||||
|
||||
expect(finalized).toBe(1)
|
||||
expect(repo.insertTimeoutVotes).toHaveBeenCalledWith(expect.objectContaining({
|
||||
demandId: 'demand1',
|
||||
approvers: [expect.objectContaining({ id: 'approver2' })],
|
||||
}))
|
||||
expect(repo.updateDemandConfirmation).toHaveBeenCalledWith('demand1', expect.objectContaining({
|
||||
status: 'AGREED',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('停产需求:明细与删除', () => {
|
||||
it('替换类型只允许相同款或相似款', async () => {
|
||||
await expect(
|
||||
productionStopDemandsService.updateItem('item1', { replaceType: '全新款' }),
|
||||
).rejects.toThrow('替换类型')
|
||||
|
||||
expect(repo.updateItemFields).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('替换编码不存在于产品全量档案时不允许填写', async () => {
|
||||
repo.findExistingArchiveCodes.mockResolvedValue(new Set())
|
||||
|
||||
await expect(
|
||||
productionStopDemandsService.updateItem('item1', {
|
||||
replaceType: '相同款',
|
||||
replaceBarcode: 'NOT-EXIST-001',
|
||||
}),
|
||||
).rejects.toThrow('不存在于产品全量中,有疑问联系系统管理员')
|
||||
|
||||
expect(repo.updateItemFields).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('替换编码存在时正常保存,空值不校验', async () => {
|
||||
repo.findExistingArchiveCodes.mockResolvedValue(new Set(['DF-A3ZP000606']))
|
||||
const expected = itemRowFixture({
|
||||
replaceType: '相同款',
|
||||
replaceBarcode: 'DF-A3ZP000606',
|
||||
})
|
||||
repo.updateItemFields.mockResolvedValue(expected)
|
||||
|
||||
await expect(
|
||||
productionStopDemandsService.updateItem('item1', {
|
||||
replaceType: '相同款',
|
||||
replaceBarcode: 'df-a3zp000606',
|
||||
}),
|
||||
).resolves.toEqual(expected)
|
||||
expect(repo.updateItemFields).toHaveBeenCalledWith('item1', expect.objectContaining({
|
||||
replaceBarcode: 'df-a3zp000606',
|
||||
}))
|
||||
|
||||
repo.updateItemFields.mockClear()
|
||||
repo.findExistingArchiveCodes.mockClear()
|
||||
repo.updateItemFields.mockResolvedValue(expected)
|
||||
await productionStopDemandsService.updateItem('item1', { replaceType: '相同款', replaceBarcode: '' })
|
||||
expect(repo.findExistingArchiveCodes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('更新替换信息时按明细不存在报错', async () => {
|
||||
repo.updateItemFields.mockResolvedValue(null)
|
||||
|
||||
await expect(
|
||||
productionStopDemandsService.updateItem('missing', { replaceType: '相同款' }),
|
||||
).rejects.toThrow('停产需求明细不存在')
|
||||
})
|
||||
|
||||
it('未勾选任何明细时拒绝删除', async () => {
|
||||
await expect(
|
||||
productionStopDemandsService.deleteItems({ ids: [] }, user),
|
||||
).rejects.toThrow('请先勾选要删除的停产需求明细')
|
||||
|
||||
expect(repo.deleteItems).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('删除成功后返回删除条数并写入操作日志', async () => {
|
||||
repo.deleteItems.mockResolvedValue({ deleted: 2, deletedDemands: 1 })
|
||||
|
||||
await expect(
|
||||
productionStopDemandsService.deleteItems({ ids: ['item-1', 'item-1', 'item-2'] }, user),
|
||||
).resolves.toEqual({ deleted: 2, deletedDemands: 1 })
|
||||
|
||||
expect(repo.deleteItems).toHaveBeenCalledWith(['item-1', 'item-2'])
|
||||
expect(operationLogService.record).toHaveBeenCalledWith(expect.objectContaining({
|
||||
actorName: '员工1',
|
||||
message: '删除 2 条停产需求明细',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('停产需求:修改停产原因', () => {
|
||||
it('原因分类不在配置中时给出提示和解决办法', async () => {
|
||||
repo.findReasonCategoryByName.mockResolvedValue(null)
|
||||
|
||||
await expect(
|
||||
productionStopDemandsService.updateDemandReason(
|
||||
'demand1',
|
||||
{ reasonCategory: '已删除的分类', reasonDetail: '' },
|
||||
user,
|
||||
),
|
||||
).rejects.toThrow('不在原因分类配置中,请找系统管理员处理')
|
||||
|
||||
expect(repo.updateDemandReason).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('停用中的原因分类同样不允许使用', async () => {
|
||||
repo.findReasonCategoryByName.mockResolvedValue({ id: 'c1', name: '滞销款', isActive: false })
|
||||
|
||||
await expect(
|
||||
productionStopDemandsService.updateDemandReason(
|
||||
'demand1',
|
||||
{ reasonCategory: '滞销款', reasonDetail: '' },
|
||||
user,
|
||||
),
|
||||
).rejects.toThrow('不在原因分类配置中,请找系统管理员处理')
|
||||
})
|
||||
|
||||
it('已完成确认的需求不能再修改停产原因', async () => {
|
||||
repo.findReasonCategoryByName.mockResolvedValue({ id: 'c1', name: '滞销款', isActive: true })
|
||||
repo.findDemandById.mockResolvedValue(demandRecord({ confirmationStatus: 'AGREED' }))
|
||||
|
||||
await expect(
|
||||
productionStopDemandsService.updateDemandReason(
|
||||
'demand1',
|
||||
{ reasonCategory: '滞销款', reasonDetail: '' },
|
||||
user,
|
||||
),
|
||||
).rejects.toThrow('不能再修改停产原因')
|
||||
|
||||
expect(repo.updateDemandReason).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('修改成功时写入操作日志并返回最新口径', async () => {
|
||||
repo.findReasonCategoryByName.mockResolvedValue({ id: 'c1', name: '精简SKU', isActive: true })
|
||||
repo.findDemandById.mockResolvedValue(demandRecord())
|
||||
repo.updateDemandReason.mockResolvedValue({
|
||||
id: 'demand1',
|
||||
demandNo: 'TC20260930-001',
|
||||
reasonCategory: '精简SKU',
|
||||
reasonDetail: '填错了,改成精简SKU',
|
||||
})
|
||||
|
||||
await expect(
|
||||
productionStopDemandsService.updateDemandReason(
|
||||
'demand1',
|
||||
{ reasonCategory: '精简SKU', reasonDetail: ' 填错了,改成精简SKU ' },
|
||||
user,
|
||||
),
|
||||
).resolves.toEqual(expect.objectContaining({ reasonCategory: '精简SKU' }))
|
||||
|
||||
expect(repo.updateDemandReason).toHaveBeenCalledWith('demand1', {
|
||||
reasonCategory: '精简SKU',
|
||||
reasonDetail: '填错了,改成精简SKU',
|
||||
})
|
||||
expect(operationLogService.record).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: '修改停产需求 TC20260930-001 的停产原因',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('停产需求:原因分类与确认人配置', () => {
|
||||
it('原因分类选项对非管理员只返回启用项,管理员可取全部', async () => {
|
||||
repo.listReasonCategories.mockResolvedValue([
|
||||
{ id: 'c1', name: '滞销款', sortOrder: 10, isActive: true, usedByDemandCount: 0 },
|
||||
{ id: 'c2', name: '旧分类', sortOrder: 20, isActive: false, usedByDemandCount: 1 },
|
||||
])
|
||||
|
||||
await expect(
|
||||
productionStopDemandsService.listReasonCategories({ role: 'USER' }),
|
||||
).resolves.toEqual([expect.objectContaining({ name: '滞销款' })])
|
||||
await expect(
|
||||
productionStopDemandsService.listReasonCategories({ role: 'ADMIN' }, { includeInactive: true }),
|
||||
).resolves.toHaveLength(2)
|
||||
await expect(
|
||||
productionStopDemandsService.listReasonCategories({ role: 'USER' }, { includeInactive: true }),
|
||||
).rejects.toThrow('只有系统管理员')
|
||||
})
|
||||
|
||||
it('仅系统管理员可以维护停产原因分类', async () => {
|
||||
await expect(
|
||||
productionStopDemandsService.createReasonCategory({ name: '新分类' }, { role: 'USER' }),
|
||||
).rejects.toThrow('只有系统管理员')
|
||||
|
||||
expect(repo.createReasonCategory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('仅系统管理员可以维护确认人名单', async () => {
|
||||
await expect(
|
||||
productionStopDemandsService.createConfirmationApprover({ userId: 'u2' }, { role: 'USER' }),
|
||||
).rejects.toThrow('只有系统管理员')
|
||||
|
||||
expect(repo.createConfirmationApprover).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('已有确认记录的确认人不能删除', async () => {
|
||||
repo.findApproverById.mockResolvedValue(approver())
|
||||
repo.countApproverVotes.mockResolvedValue(2)
|
||||
|
||||
await expect(
|
||||
productionStopDemandsService.deleteConfirmationApprover('approver1', { role: 'ADMIN' }),
|
||||
).rejects.toThrow('不能删除,可改为停用')
|
||||
|
||||
expect(repo.deleteConfirmationApprover).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('管理员添加确认人时校验用户存在与重复', async () => {
|
||||
repo.findUserById.mockResolvedValue({ id: 'u2', name: '总监甲', dingtalkUnionId: 'union-1' })
|
||||
repo.findApproverByUserId.mockResolvedValue(approver())
|
||||
|
||||
await expect(
|
||||
productionStopDemandsService.createConfirmationApprover({ userId: 'u2' }, { role: 'ADMIN' }),
|
||||
).rejects.toThrow('该人员已在停产确认人名单中')
|
||||
|
||||
expect(repo.createConfirmationApprover).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('停产需求:钉钉推送', () => {
|
||||
it('未启用钉钉待办时给出提示且不推送', async () => {
|
||||
repo.findDemandById.mockResolvedValue(demandRecord())
|
||||
repo.listActiveApprovers.mockResolvedValue([approver()])
|
||||
todoService.isEnabled.mockReturnValue(false)
|
||||
|
||||
const result = await productionStopDemandsService.dispatchConfirmationPush('demand1')
|
||||
|
||||
expect(result.pushed).toBe(0)
|
||||
expect(result.message).toContain('钉钉待办推送未启用')
|
||||
expect(todoService.createTask).not.toHaveBeenCalled()
|
||||
expect(repo.updateDemandConfirmation).toHaveBeenCalledWith('demand1', expect.objectContaining({
|
||||
pushedAt: null,
|
||||
}))
|
||||
})
|
||||
|
||||
it('启用后向所有绑定钉钉的确认人推送待办', async () => {
|
||||
repo.findDemandById.mockResolvedValue(demandRecord())
|
||||
repo.listActiveApprovers.mockResolvedValue([
|
||||
approver(),
|
||||
approver({ id: 'approver2', userId: 'u3', userName: '总监乙', dingtalkUnionId: '' }),
|
||||
])
|
||||
todoService.isEnabled.mockReturnValue(true)
|
||||
todoService.createTask.mockResolvedValue({ taskId: 'task-1' })
|
||||
|
||||
const result = await productionStopDemandsService.dispatchConfirmationPush('demand1')
|
||||
|
||||
expect(result.pushed).toBe(1)
|
||||
expect(todoService.createTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
executorUnionIds: ['union-1'],
|
||||
title: expect.stringContaining('TC20260930-001'),
|
||||
}))
|
||||
expect(repo.updateDemandConfirmation).toHaveBeenCalledWith('demand1', expect.objectContaining({
|
||||
pushedAt: expect.any(Date),
|
||||
}))
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user