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:
apple 2026-10-10 11:20:18 +08:00 committed by WuYongJie
parent de7518bb16
commit 8270ae9b38
52 changed files with 10689 additions and 0 deletions

View File

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

View File

@ -0,0 +1,2 @@
-- 停产需求明细删除支持写入操作日志(新增枚举值,不改动既有数据)。
ALTER TYPE "OperationLogAction" ADD VALUE 'PRODUCTION_STOP_DEMAND_DELETE';

View File

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

View File

@ -0,0 +1,2 @@
-- 停产需求修改停产原因支持写入操作日志(新增枚举值,不改动既有数据)。
ALTER TYPE "OperationLogAction" ADD VALUE 'PRODUCTION_STOP_DEMAND_UPDATE';

View File

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

View File

@ -0,0 +1,3 @@
-- 按条码拆单后,用批次号把同一次提交的需求单串起来(用于批量确认与推送聚合)。
ALTER TABLE "ProductionStopDemand" ADD COLUMN "batchNo" TEXT NOT NULL DEFAULT '';
CREATE INDEX "ProductionStopDemand_batchNo_idx" ON "ProductionStopDemand"("batchNo");

View File

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

View File

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

View File

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

View File

@ -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',
},
]

View File

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

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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')
})
})

View File

@ -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': '用户权限',

View File

@ -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',
})]),
)
})
})

View File

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

View File

@ -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(),
)
})
})
})

View File

@ -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}
/>
)
}

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

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

View File

@ -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()
})
})

View File

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

View File

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

View File

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

View File

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

View File

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