From 6e12f0318d37c11bd398e4111a60a29c39c74ec5 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 20 Oct 2026 14:26:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(production-stop-demands):=20=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E6=A1=A3=E6=A1=88=E5=AD=97=E6=AE=B5=E3=80=81=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E3=80=81=E8=A7=86=E5=9B=BE=E4=B8=8E=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 明细新增图片、产品标签、含大单近30日日销(条码/SKU 数值快照) - 表头漏斗筛选(模糊/多选/数值比较/日期区间)、批量搜索、筛选设置 - 列设置拖动排序、快捷查询保存与套用 - 视图统计:日/周/月趋势、部门/需求人/原因/状态分布、饼图与明细下钻 - 通知中心:停产确认/待走流程/需求驳回通知、待办快照同步、钉钉全屏链接 - 新增迁移与历史数据回填脚本 --- .../migration.sql | 2 + .../migration.sql | 17 + .../migration.sql | 11 + furniture-3d-system/prisma/schema.prisma | 20 + ...ckfill-production-stop-archive-snapshot.ts | 129 ++ .../analytics/route.ts | 139 ++ .../filter-options/route.ts | 37 + .../production-stop-demands/route.ts | 79 +- .../ProductionStopDemandsView.test.tsx | 383 ++++ .../ProductionStopDemandsView.tsx | 1948 +++++++++++++++-- .../production-stop-demands.module.css | 778 +++++++ .../src/lib/dingtalk-link.test.ts | 16 + furniture-3d-system/src/lib/dingtalk-link.ts | 10 + .../src/lib/production-stop-demands.test.ts | 57 + .../src/lib/production-stop-demands.ts | 157 +- .../lib/production-stop-notifications.test.ts | 65 + .../src/lib/production-stop-notifications.ts | 70 + .../notification-center/client.ts | 3 +- .../integrations/notification-center/types.ts | 5 +- .../production-stop-demands.repository.ts | 402 +++- .../production-stop-demands.service.test.ts | 88 +- .../production-stop-demands.service.ts | 142 +- ...duction-stop-notifications.service.test.ts | 179 ++ .../production-stop-notifications.service.ts | 273 +++ 24 files changed, 4831 insertions(+), 179 deletions(-) create mode 100644 furniture-3d-system/prisma/migrations/20261014200000_add_production_stop_demand_item_image/migration.sql create mode 100644 furniture-3d-system/prisma/migrations/20261020103000_add_production_stop_notification_sync/migration.sql create mode 100644 furniture-3d-system/prisma/migrations/20261020120000_add_production_stop_sales_values/migration.sql create mode 100644 furniture-3d-system/scripts/backfill-production-stop-archive-snapshot.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/analytics/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/filter-options/route.ts create mode 100644 furniture-3d-system/src/lib/dingtalk-link.test.ts create mode 100644 furniture-3d-system/src/lib/dingtalk-link.ts create mode 100644 furniture-3d-system/src/lib/production-stop-notifications.test.ts create mode 100644 furniture-3d-system/src/lib/production-stop-notifications.ts create mode 100644 furniture-3d-system/src/server/services/production-stop-notifications.service.test.ts create mode 100644 furniture-3d-system/src/server/services/production-stop-notifications.service.ts diff --git a/furniture-3d-system/prisma/migrations/20261014200000_add_production_stop_demand_item_image/migration.sql b/furniture-3d-system/prisma/migrations/20261014200000_add_production_stop_demand_item_image/migration.sql new file mode 100644 index 00000000..c33ba75b --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20261014200000_add_production_stop_demand_item_image/migration.sql @@ -0,0 +1,2 @@ +-- 「图片」列:提单时按条码从产品全量档案库快照图片地址,列表在条码前展示缩略图。 +ALTER TABLE "ProductionStopDemandItem" ADD COLUMN "image" TEXT NOT NULL DEFAULT ''; diff --git a/furniture-3d-system/prisma/migrations/20261020103000_add_production_stop_notification_sync/migration.sql b/furniture-3d-system/prisma/migrations/20261020103000_add_production_stop_notification_sync/migration.sql new file mode 100644 index 00000000..f8e47691 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20261020103000_add_production_stop_notification_sync/migration.sql @@ -0,0 +1,17 @@ +-- 通知中心待办同步状态(停产确认 / 待走流程两类)。 +CREATE TABLE "ProductionStopNotificationSync" ( + "id" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "recipientKey" TEXT NOT NULL, + "recipientName" TEXT NOT NULL DEFAULT '', + "pendingCount" INTEGER NOT NULL DEFAULT 0, + "snapshot" TEXT NOT NULL DEFAULT '', + "lastSentAt" TIMESTAMP(3), + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ProductionStopNotificationSync_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ProductionStopNotificationSync_kind_recipientKey_key" + ON "ProductionStopNotificationSync"("kind", "recipientKey"); diff --git a/furniture-3d-system/prisma/migrations/20261020120000_add_production_stop_sales_values/migration.sql b/furniture-3d-system/prisma/migrations/20261020120000_add_production_stop_sales_values/migration.sql new file mode 100644 index 00000000..2bbc81de --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20261020120000_add_production_stop_sales_values/migration.sql @@ -0,0 +1,11 @@ +-- 含大单近30日日销的数值快照列:用于 >、>=、<、<= 数值筛选与数值排序。 +ALTER TABLE "ProductionStopDemandItem" ADD COLUMN "sales30dWithBigValue" DECIMAL(18,4); +ALTER TABLE "ProductionStopDemandItem" ADD COLUMN "sales30dWithBigSkuValue" DECIMAL(18,4); + +UPDATE "ProductionStopDemandItem" + SET "sales30dWithBigValue" = "sales30dWithBig"::numeric + WHERE "sales30dWithBig" ~ '^-?[0-9]+(\.[0-9]+)?$'; + +UPDATE "ProductionStopDemandItem" + SET "sales30dWithBigSkuValue" = "sales30dWithBigSku"::numeric + WHERE "sales30dWithBigSku" ~ '^-?[0-9]+(\.[0-9]+)?$'; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 6a1c4296..fa006a1b 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -1215,10 +1215,13 @@ model ProductionStopDemandItem { brand String @default("") productTag String @default("") productLabel String @default("") + image String @default("") barcodeCreatedAt DateTime? postStopSkuStatus String @default("") sales30dWithBig String @default("") sales30dWithBigSku String @default("") + sales30dWithBigValue Decimal? @db.Decimal(18, 4) + sales30dWithBigSkuValue Decimal? @db.Decimal(18, 4) opsDirector String @default("") topSalesOpsDirector String @default("") stopSuggestion String @default("") @@ -1302,6 +1305,23 @@ model ProductionStopFlowOwner { @@index([isActive, sortOrder]) } +// 通知中心待办同步状态:按「待办类型 + 接收人」记录上次推送的待办快照。 +// 待办集合变化时推送新的条数卡片;集合清空时推送一次「已全部处理完成」。 +model ProductionStopNotificationSync { + id String @id @default(cuid()) + kind String + recipientKey String + recipientName String @default("") + pendingCount Int @default(0) + snapshot String @default("") + lastSentAt DateTime? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([kind, recipientKey]) +} + // 盘停建议配置:人工批量维护「条码 → 盘停建议」,提单时按条码自动带出。 model ProductionStopSuggestionConfig { diff --git a/furniture-3d-system/scripts/backfill-production-stop-archive-snapshot.ts b/furniture-3d-system/scripts/backfill-production-stop-archive-snapshot.ts new file mode 100644 index 00000000..d790481f --- /dev/null +++ b/furniture-3d-system/scripts/backfill-production-stop-archive-snapshot.ts @@ -0,0 +1,129 @@ +/** + * 回填停产需求明细的档案快照字段:「含大单近30日日销-条码 / -SKU」「产品标签」「图片」。 + * 口径:条码维度 = 产品全量档案库该条码行的「含大单近30日日销」; + * SKU 维度 = 同 SKU 全部条码求和; + * 产品标签 = SABC 标签优先,未打标按销售状态 × 考核周期真实业绩兜底; + * 图片 = 档案库该条码行的图片地址。 + * 只回填当前为空的值,不覆盖已有快照。 + * + * 用法:npx tsx scripts/backfill-production-stop-archive-snapshot.ts + */ +import 'dotenv/config' +import { prisma } from '../src/lib/prisma' +import { resolveProductionStopProductLabel } from '../src/lib/production-stop-demands' +import { + productionStopDemandRepository, + type ProductionStopArchiveProfile, +} from '../src/server/repositories/production-stop-demands.repository' + +const ARCHIVE_BATCH_SIZE = 300 + +function numericValue(value: string) { + const normalized = value.trim() + return /^-?\d+(\.\d+)?$/.test(normalized) ? normalized : null +} + +async function main() { + const items = await prisma.productionStopDemandItem.findMany({ + where: { + OR: [ + { sales30dWithBig: '' }, + { sales30dWithBigSku: '' }, + { productLabel: '' }, + { image: '' }, + ], + }, + select: { + id: true, + barcode: true, + sku: true, + productStatus: true, + productLabel: true, + image: true, + sales30dWithBig: true, + sales30dWithBigSku: true, + }, + orderBy: { createdAt: 'asc' }, + }) + if (!items.length) { + console.log('[production-stop-archive-snapshot] 没有需要回填的明细') + return + } + + const barcodes = [...new Set( + items.map((item) => item.barcode.trim().toUpperCase()).filter(Boolean), + )] + const skus = [...new Set(items.map((item) => item.sku.trim().toUpperCase()).filter(Boolean))] + + const profilesByBarcode = new Map() + for (let index = 0; index < barcodes.length; index += ARCHIVE_BATCH_SIZE) { + const chunk = barcodes.slice(index, index + ARCHIVE_BATCH_SIZE) + const profiles = await productionStopDemandRepository.findArchivedProfiles(chunk) + for (const [barcode, profile] of profiles) profilesByBarcode.set(barcode, profile) + } + + const skuTotals = new Map() + for (let index = 0; index < skus.length; index += ARCHIVE_BATCH_SIZE) { + const chunk = skus.slice(index, index + ARCHIVE_BATCH_SIZE) + const totals = await productionStopDemandRepository.sumSkuSales30dWithBig(chunk) + for (const [sku, total] of totals) { + if (total) skuTotals.set(sku, total) + } + } + + let updated = 0 + let unmatched = 0 + for (const item of items) { + const profile = profilesByBarcode.get(item.barcode.trim().toUpperCase()) + const barcodeValue = item.sales30dWithBig + || (profile?.sales30dWithBig ?? '') + const skuValue = item.sales30dWithBigSku + || skuTotals.get(item.sku.trim().toUpperCase()) + || '' + const productLabel = item.productLabel + || (profile + ? resolveProductionStopProductLabel({ + sabcRating: profile.sabcRating, + productStatus: profile.productStatus, + realPerformance: profile.sabcRealPerformance, + }) + : '') + const image = item.image || (profile?.image ?? '') + if (!barcodeValue && !skuValue && !productLabel && !image) { + unmatched += 1 + continue + } + if ( + barcodeValue === item.sales30dWithBig + && skuValue === item.sales30dWithBigSku + && productLabel === item.productLabel + && image === item.image + ) { + continue + } + await prisma.productionStopDemandItem.update({ + where: { id: item.id }, + data: { + sales30dWithBig: barcodeValue, + sales30dWithBigSku: skuValue, + sales30dWithBigValue: numericValue(barcodeValue), + sales30dWithBigSkuValue: numericValue(skuValue), + productLabel, + image, + }, + }) + updated += 1 + } + console.log( + `[production-stop-archive-snapshot] 待回填明细 ${items.length} 条:更新 ${updated} 条,档案未匹配 ${unmatched} 条`, + ) +} + +main() + .catch((error) => { + console.error('[production-stop-archive-snapshot] FAILED:', error) + process.exitCode = 1 + }) + .finally(async () => { + await prisma.$disconnect().catch(() => null) + }) diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/analytics/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/analytics/route.ts new file mode 100644 index 00000000..da8ebc50 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/analytics/route.ts @@ -0,0 +1,139 @@ +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_ANALYTICS_GRANULARITIES, + PRODUCTION_STOP_CONFIRMATION_STATUSES, + PRODUCTION_STOP_DEMAND_STATUSES, + isProductionStopItemMultiFilterKey, + isProductionStopItemNumericFilterKey, + isProductionStopItemTextFilterKey, + type ProductionStopItemFilters, + type ProductionStopNumericFilterOperator, +} from '@/lib/production-stop-demands' +import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' + +const querySchema = z.object({ + granularity: z.enum(PRODUCTION_STOP_ANALYTICS_GRANULARITIES), + from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + filters: z.string().trim().max(12000).optional(), +}) + +type AnalyticsFilters = Omit< + ProductionStopItemFilters, + 'page' | 'pageSize' | 'sortKey' | 'sortDirection' | 'dateFrom' | 'dateTo' +> + +const NUMERIC_OPERATORS = new Set(['gt', 'gte', 'lt', 'lte']) + +function cleanText(value: unknown, max: number) { + return String(value ?? '').trim().slice(0, max) +} + +/** 解析主表当前筛选条件(与列表共用同一套字段白名单)。 */ +function parseAnalyticsFilters(raw?: string): AnalyticsFilters | undefined { + if (!raw) return undefined + try { + const parsed = JSON.parse(raw) as Record + if (!parsed || typeof parsed !== 'object') return undefined + const result: AnalyticsFilters = {} + + const keyword = cleanText(parsed.keyword, 2000) + if (keyword) result.keyword = keyword + const confirmationStatus = cleanText(parsed.confirmationStatus, 20) + if ((PRODUCTION_STOP_CONFIRMATION_STATUSES as readonly string[]).includes(confirmationStatus)) { + result.confirmationStatus = confirmationStatus as ProductionStopItemFilters['confirmationStatus'] + } + const demandStatus = cleanText(parsed.demandStatus, 30) + if ((PRODUCTION_STOP_DEMAND_STATUSES as readonly string[]).includes(demandStatus)) { + result.demandStatus = demandStatus as ProductionStopItemFilters['demandStatus'] + } + if (Array.isArray(parsed.demandStatuses)) { + const statuses = parsed.demandStatuses + .map((value) => cleanText(value, 30)) + .filter((value): value is NonNullable => + (PRODUCTION_STOP_DEMAND_STATUSES as readonly string[]).includes(value)) + if (statuses.length) result.demandStatuses = statuses + } + for (const key of ['reasonCategory', 'purchaseOwner', 'requesterName'] as const) { + const value = cleanText(parsed[key], 60) + if (value) result[key] = value + } + if (parsed.fieldFilters && typeof parsed.fieldFilters === 'object') { + const fieldFilters: NonNullable = {} + for (const [key, value] of Object.entries(parsed.fieldFilters as Record)) { + if (!isProductionStopItemTextFilterKey(key)) continue + const text = cleanText(value, 120) + if (text) fieldFilters[key] = text + } + if (Object.keys(fieldFilters).length) result.fieldFilters = fieldFilters + } + if (parsed.numericFilters && typeof parsed.numericFilters === 'object') { + const numericFilters: NonNullable = {} + for (const [key, value] of Object.entries(parsed.numericFilters as Record)) { + if (!isProductionStopItemNumericFilterKey(key)) continue + const entry = value as { op?: unknown; value?: unknown } | null + const op = cleanText(entry?.op, 8) as ProductionStopNumericFilterOperator + const numeric = Number(entry?.value) + if (!NUMERIC_OPERATORS.has(op) || !Number.isFinite(numeric)) continue + numericFilters[key] = { op, value: numeric } + } + if (Object.keys(numericFilters).length) result.numericFilters = numericFilters + } + if (parsed.multiFilters && typeof parsed.multiFilters === 'object') { + const multiFilters: NonNullable = {} + for (const [key, value] of Object.entries(parsed.multiFilters as Record)) { + if (!isProductionStopItemMultiFilterKey(key) || !Array.isArray(value)) continue + const values = value.map((item) => cleanText(item, 120)).slice(0, 100) + if (values.length) multiFilters[key] = values + } + if (Object.keys(multiFilters).length) result.multiFilters = multiFilters + } + for (const key of ['barcodeCreatedAt', 'barcodeCreatedFrom', 'barcodeCreatedTo'] as const) { + const value = cleanText(parsed[key], 10) + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) result[key] = value + } + return Object.keys(result).length ? result : undefined + } catch { + return undefined + } +} + +/** 领导视图:日/周/月需求趋势与部门、需求人、状态分布。 */ +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 access = await productionStopDemandsService.resolveListAccess(ctx.user) + if (!access.canView) { + return jsonError( + ErrorCode.AUTH_PERMISSION_DENIED, + '当前账号没有停产需求管理的查看权限', + 403, + ctx.requestId, + ) + } + const result = await productionStopDemandsService.getAnalytics({ + granularity: parsed.data.granularity, + from: parsed.data.from, + to: parsed.data.to, + filters: parseAnalyticsFilters(parsed.data.filters), + }) + return jsonSuccess(result, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/filter-options/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/filter-options/route.ts new file mode 100644 index 00000000..cc8cbdb5 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/filter-options/route.ts @@ -0,0 +1,37 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { isProductionStopItemTextFilterKey } from '@/lib/production-stop-demands' +import { productionStopDemandRepository } from '@/server/repositories/production-stop-demands.repository' + +const querySchema = z.object({ + field: z.string().trim().min(1).max(64), + limit: z.coerce.number().int().min(1).max(500).default(200), +}) + +/** 表头漏斗筛选项:返回某字段的去重可选值。 */ +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const parsed = querySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams.entries()), + ) + if (!parsed.success || !isProductionStopItemTextFilterKey(parsed.data.field)) { + return jsonError( + ErrorCode.VALIDATION_INVALID_FORMAT, + '筛选字段不正确', + 400, + ctx.requestId, + ) + } + const values = await productionStopDemandRepository.listDistinctFilterValues( + parsed.data.field, + parsed.data.limit, + ) + return jsonSuccess({ values }, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/route.ts index f8319ecb..c62ef242 100644 --- a/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/management/production-stop-demands/route.ts @@ -5,7 +5,13 @@ import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' import { PRODUCTION_STOP_CONFIRMATION_STATUSES, PRODUCTION_STOP_DEMAND_STATUSES, + isProductionStopItemMultiFilterKey, + isProductionStopItemNumericFilterKey, isProductionStopItemSortKey, + isProductionStopItemTextFilterKey, + type ProductionStopNumericFilterOperator, + type ProductionStopItemFilters, + type ProductionStopItemTextFilterKey, type ProductionStopItemSortKey, } from '@/lib/production-stop-demands' import { productionStopDemandsService } from '@/server/services/production-stop-demands.service' @@ -13,7 +19,7 @@ import { productionStopDemandsService } from '@/server/services/production-stop- 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(), + keyword: z.string().trim().max(2000).optional(), confirmationStatus: z.enum(PRODUCTION_STOP_CONFIRMATION_STATUSES).optional(), demandStatus: z.enum(PRODUCTION_STOP_DEMAND_STATUSES).optional(), demandStatuses: z.string().trim().max(240).optional(), @@ -22,6 +28,12 @@ const listQuerySchema = z.object({ 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(), + fieldFilters: z.string().trim().max(4000).optional(), + numericFilters: z.string().trim().max(600).optional(), + multiFilters: z.string().trim().max(6000).optional(), + barcodeCreatedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + barcodeCreatedFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + barcodeCreatedTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), sortKey: z.string().trim().max(64).optional(), sortDirection: z.enum(['asc', 'desc']).optional(), }) @@ -30,6 +42,65 @@ const listQuerySchema = z.object({ let lastFlowSyncAt = 0 const FLOW_SYNC_INTERVAL_MS = 5 * 60 * 1000 +function parseFieldFilters(raw?: string) { + if (!raw) return undefined + try { + const parsed = JSON.parse(raw) as Record + if (!parsed || typeof parsed !== 'object') return undefined + const result: Partial> = {} + for (const [key, value] of Object.entries(parsed)) { + if (!isProductionStopItemTextFilterKey(key)) continue + const text = String(value ?? '').trim().slice(0, 120) + if (text) result[key] = text + } + return Object.keys(result).length ? result : undefined + } catch { + return undefined + } +} + +const NUMERIC_FILTER_OPERATORS = new Set(['gt', 'gte', 'lt', 'lte']) + +function parseNumericFilters(raw?: string) { + if (!raw) return undefined + try { + const parsed = JSON.parse(raw) as Record + if (!parsed || typeof parsed !== 'object') return undefined + const result: NonNullable = {} + for (const [key, value] of Object.entries(parsed)) { + if (!isProductionStopItemNumericFilterKey(key)) continue + const entry = value as { op?: unknown; value?: unknown } | null + const op = String(entry?.op ?? '') + const numeric = Number(entry?.value) + if (!NUMERIC_FILTER_OPERATORS.has(op as ProductionStopNumericFilterOperator)) continue + if (!Number.isFinite(numeric)) continue + result[key] = { op: op as ProductionStopNumericFilterOperator, value: numeric } + } + return Object.keys(result).length ? result : undefined + } catch { + return undefined + } +} + +function parseMultiFilters(raw?: string) { + if (!raw) return undefined + try { + const parsed = JSON.parse(raw) as Record + if (!parsed || typeof parsed !== 'object') return undefined + const result: NonNullable = {} + for (const [key, value] of Object.entries(parsed)) { + if (!isProductionStopItemMultiFilterKey(key) || !Array.isArray(value)) continue + const values = value + .map((item) => String(item ?? '').slice(0, 120)) + .slice(0, 100) + if (values.length) result[key] = values + } + return Object.keys(result).length ? result : undefined + } catch { + return undefined + } +} + export async function GET(request: NextRequest) { return handleRequest( request, @@ -79,6 +150,12 @@ export async function GET(request: NextRequest) { requesterName: parsed.data.requesterName || undefined, dateFrom: parsed.data.dateFrom || undefined, dateTo: parsed.data.dateTo || undefined, + fieldFilters: parseFieldFilters(parsed.data.fieldFilters), + numericFilters: parseNumericFilters(parsed.data.numericFilters), + multiFilters: parseMultiFilters(parsed.data.multiFilters), + barcodeCreatedAt: parsed.data.barcodeCreatedAt || undefined, + barcodeCreatedFrom: parsed.data.barcodeCreatedFrom || undefined, + barcodeCreatedTo: parsed.data.barcodeCreatedTo || undefined, sortKey: parsed.data.sortKey as ProductionStopItemSortKey | undefined, sortDirection: parsed.data.sortDirection, page: parsed.data.page, diff --git a/furniture-3d-system/src/app/supply-chain/management/production-stop-demands/ProductionStopDemandsView.test.tsx b/furniture-3d-system/src/app/supply-chain/management/production-stop-demands/ProductionStopDemandsView.test.tsx index f6c43405..3c42775b 100644 --- a/furniture-3d-system/src/app/supply-chain/management/production-stop-demands/ProductionStopDemandsView.test.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/production-stop-demands/ProductionStopDemandsView.test.tsx @@ -37,6 +37,7 @@ function itemRow(overrides: Partial): ProductionStopItemR brand: '品牌1', productTag: '仓库-崔桥工厂仓', productLabel: '', + image: '', postStopSkuStatus: '非销售', sales30dWithBig: '', sales30dWithBigSku: '', @@ -60,6 +61,10 @@ function itemRow(overrides: Partial): ProductionStopItemR } describe('停产需求管理视图', () => { + beforeEach(() => { + window.localStorage.clear() + }) + it('只读用户看到列表与空状态,不显示新建和配置入口', () => { render( , @@ -90,6 +95,384 @@ describe('停产需求管理视图', () => { expect(screen.getByRole('separator', { name: '调整条码列宽' })).toBeInTheDocument() }) + it('设置面板支持拖动调整列顺序,列设置不再提供显隐勾选框', () => { + const { container } = render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: '设置' })) + const dialog = screen.getByRole('dialog', { name: '设置' }) + const columnList = within(dialog).getByRole('list', { name: '列设置' }) + expect(within(columnList).getAllByRole('listitem')).toHaveLength(PRODUCTION_STOP_ITEM_COLUMNS.length) + expect(within(columnList).queryAllByRole('checkbox')).toHaveLength(0) + + const dataTransfer = { + setData: jest.fn(), + getData: jest.fn(() => 'barcode'), + effectAllowed: '', + dropEffect: '', + } + const barcodeRow = within(columnList).getByText('条码').closest('li') as HTMLElement + const firstRow = within(columnList).getByText('需求时间').closest('li') as HTMLElement + fireEvent.dragStart(barcodeRow, { dataTransfer }) + fireEvent.dragOver(firstRow, { dataTransfer }) + fireEvent.drop(firstRow, { dataTransfer }) + + const fieldRow = container.querySelectorAll('thead tr')[1] + const headers = [...fieldRow.querySelectorAll('th')].map((cell) => cell.textContent?.trim()) + expect(headers[1]).toBe('条码') + }) + + it('筛选设置支持增减快速查询框,隐藏后同步清空该筛选条件', () => { + render( + , + ) + + expect(screen.getByPlaceholderText('需求人(可输入搜索)')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '设置' })) + const dialog = screen.getByRole('dialog', { name: '设置' }) + const filterList = within(dialog).getByRole('list', { name: '筛选设置' }) + const requesterRow = within(filterList).getByText('需求人').closest('li') as HTMLElement + fireEvent.click(within(requesterRow).getByRole('checkbox')) + fireEvent.click(within(dialog).getByRole('button', { name: '完成' })) + + expect(screen.queryByPlaceholderText('需求人(可输入搜索)')).toBeNull() + expect(screen.getByPlaceholderText('采购(可输入搜索)')).toBeInTheDocument() + }) + + it('筛选设置与列设置字段一致:启用列字段后可加入快速查询并按包含筛选', async () => { + const fetchMock = jest.fn(async () => ({ + ok: true, + json: async () => ({ success: true, data: emptyPage }), + })) + global.fetch = fetchMock as unknown as typeof fetch + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: '设置' })) + const dialog = screen.getByRole('dialog', { name: '设置' }) + const filterList = within(dialog).getByRole('list', { name: '筛选设置' }) + const brandRow = within(filterList).getByText('品牌').closest('li') as HTMLElement + fireEvent.click(within(brandRow).getByRole('checkbox')) + fireEvent.click(within(dialog).getByRole('button', { name: '完成' })) + + fireEvent.change(screen.getByPlaceholderText('品牌(包含)'), { target: { value: '左印' } }) + fireEvent.click(screen.getByRole('button', { name: '查询' })) + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()) + const url = decodeURIComponent(String(fetchMock.mock.calls.at(-1)?.[0])) + expect(url).toContain('"brand":"左印"') + }) + + it('数值字段支持 >、>=、<、<= 比较筛选', async () => { + const fetchMock = jest.fn(async () => ({ + ok: true, + json: async () => ({ success: true, data: emptyPage }), + })) + global.fetch = fetchMock as unknown as typeof fetch + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: '设置' })) + const dialog = screen.getByRole('dialog', { name: '设置' }) + const filterList = within(dialog).getByRole('list', { name: '筛选设置' }) + const row = within(filterList).getByText('含大单近30日日销-条码').closest('li') as HTMLElement + fireEvent.click(within(row).getByRole('checkbox')) + fireEvent.click(within(dialog).getByRole('button', { name: '完成' })) + + fireEvent.change(screen.getByLabelText('快速筛选含大单近30日日销-条码比较方式'), { + target: { value: 'lt' }, + }) + fireEvent.change(screen.getByLabelText('快速筛选含大单近30日日销-条码数值'), { + target: { value: '12.5' }, + }) + fireEvent.click(screen.getByRole('button', { name: '查询' })) + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()) + const url = decodeURIComponent(String(fetchMock.mock.calls.at(-1)?.[0])) + expect(url).toContain('"sales30dWithBig":{"op":"lt","value":12.5}') + }) + + it('关键词搜索支持批量粘贴,多行自动转为空格分隔', () => { + render( + , + ) + + const input = screen.getByPlaceholderText(/支持批量/) + fireEvent.paste(input, { + clipboardData: { getData: () => 'A1\nA2\nA3' }, + }) + expect(input).toHaveValue('A1 A2 A3') + }) + + it('支持另存当前筛选为快捷查询并一键套用', async () => { + const fetchMock = jest.fn(async () => ({ + ok: true, + json: async () => ({ success: true, data: emptyPage }), + })) + global.fetch = fetchMock as unknown as typeof fetch + + render( + , + ) + + fireEvent.change(screen.getByPlaceholderText(/支持批量/), { target: { value: '27014E' } }) + fireEvent.click(screen.getByRole('button', { name: '查询' })) + await waitFor(() => expect(fetchMock).toHaveBeenCalled()) + + fireEvent.click(screen.getByRole('button', { name: '快捷查询' })) + fireEvent.click(screen.getByRole('button', { name: '另存当前筛选为快捷查询' })) + const dialog = screen.getByRole('dialog', { name: '另存为快捷查询' }) + fireEvent.change(within(dialog).getByPlaceholderText('例如:待我确认的滞销款'), { + target: { value: '我的常用查询' }, + }) + fireEvent.click(within(dialog).getByRole('button', { name: '保存' })) + + fireEvent.click(screen.getByRole('button', { name: '重置' })) + fireEvent.click(screen.getByRole('button', { name: '快捷查询' })) + fireEvent.click(screen.getByRole('button', { name: '我的常用查询' })) + + await waitFor(() => { + const url = decodeURIComponent(String(fetchMock.mock.calls.at(-1)?.[0])) + expect(url).toContain('keyword=27014E') + }) + }) + + it('视图展示日/周/月趋势与部门、需求人、状态统计,并支持下钻明细', async () => { + const analyticsData = { + granularity: 'day', + from: '2026-10-07', + to: '2026-10-20', + total: 3, + series: [{ bucket: '2026-10-20', count: 3 }], + byDepartment: [{ key: '运营部', count: 2 }, { key: '采购部', count: 1 }], + byRequester: [{ key: '系统管理员', count: 3 }], + byReasonCategory: [{ key: '滞销款', count: 3 }], + byDemandStatus: [ + { status: 'PENDING_CONFIRMATION', count: 1 }, + { status: 'CONFIRMED_WAIT_FLOW', count: 2 }, + ], + byConfirmationStatus: [ + { status: 'PENDING', count: 1 }, + { status: 'AGREED', count: 2 }, + ], + } + const fetchMock = jest.fn(async (input: RequestInfo | URL) => { + if (String(input).includes('/analytics')) { + return { ok: true, json: async () => ({ success: true, data: analyticsData }) } + } + return { ok: true, json: async () => ({ success: true, data: emptyPage }) } + }) + global.fetch = fetchMock as unknown as typeof fetch + + render( + , + ) + + fireEvent.change(screen.getByPlaceholderText(/支持批量/), { target: { value: '27014E' } }) + fireEvent.click(screen.getByRole('button', { name: '查询' })) + await waitFor(() => expect(fetchMock).toHaveBeenCalled()) + + fireEvent.click(screen.getByRole('button', { name: '视图' })) + const dialog = await screen.findByRole('dialog', { name: '停产需求分析' }) + await waitFor(() => expect(within(dialog).getByText('总需求数')).toBeInTheDocument()) + const analyticsCall = fetchMock.mock.calls.find(([input]) => String(input).includes('/analytics')) + expect(decodeURIComponent(String(analyticsCall?.[0]))).toContain('"keyword":"27014E"') + expect(within(dialog).getByText('需求趋势(按日)')).toBeInTheDocument() + expect(within(dialog).getByText('运营部')).toBeInTheDocument() + expect(within(dialog).getByText('系统管理员')).toBeInTheDocument() + expect(within(dialog).getAllByText('待确认').length).toBeGreaterThan(0) + + fireEvent.click(within(dialog).getByRole('button', { name: '周' })) + await waitFor(() => { + expect(String(fetchMock.mock.calls.at(-1)?.[0])).toContain('granularity=week') + }) + + const pendingCard = await within(dialog).findByRole('button', { name: '查看待确认明细' }) + fireEvent.click(pendingCard) + await waitFor(() => { + const url = decodeURIComponent(String(fetchMock.mock.calls.at(-1)?.[0])) + expect(url).toContain('demandStatus=PENDING_CONFIRMATION') + expect(url).toContain('dateFrom=2026-10-07') + expect(url).toContain('dateTo=2026-10-20') + }) + expect(screen.queryByRole('dialog', { name: '停产需求分析' })).toBeNull() + }) + + it('表头漏斗支持模糊输入与多选值筛选', async () => { + const fetchMock = jest.fn(async (input: RequestInfo | URL) => { + if (String(input).includes('/filter-options')) { + return { + ok: true, + json: async () => ({ success: true, data: { values: ['左印', '其他品牌'] } }), + } + } + return { ok: true, json: async () => ({ success: true, data: emptyPage }) } + }) + global.fetch = fetchMock as unknown as typeof fetch + + render( + , + ) + + fireEvent.click(screen.getByLabelText('筛选品牌')) + fireEvent.change(screen.getByLabelText('品牌模糊筛选'), { target: { value: '左印' } }) + fireEvent.click(screen.getByRole('button', { name: '确定' })) + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()) + expect(decodeURIComponent(String(fetchMock.mock.calls.at(-1)?.[0]))) + .toContain('"brand":"左印"') + + // 多选值:勾选「销售」后确定。 + fireEvent.click(screen.getByLabelText('筛选条码生产状态')) + fireEvent.click(screen.getByRole('checkbox', { name: '销售' })) + fireEvent.click(screen.getByRole('button', { name: '确定' })) + await waitFor(() => { + const url = decodeURIComponent(String(fetchMock.mock.calls.at(-1)?.[0])) + expect(url).toContain('"productStatus":["销售"]') + }) + }) + + it('表头数值字段支持运算符 + 数值筛选', async () => { + const fetchMock = jest.fn(async () => ({ + ok: true, + json: async () => ({ success: true, data: emptyPage }), + })) + global.fetch = fetchMock as unknown as typeof fetch + + render( + , + ) + + fireEvent.click(screen.getByLabelText('筛选含大单近30日日销-条码')) + fireEvent.change(screen.getByLabelText('含大单近30日日销-条码比较方式'), { + target: { value: 'lte' }, + }) + fireEvent.change(screen.getByLabelText('含大单近30日日销-条码数值'), { + target: { value: '5' }, + }) + fireEvent.click(screen.getByRole('button', { name: '确定' })) + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()) + expect(decodeURIComponent(String(fetchMock.mock.calls.at(-1)?.[0]))) + .toContain('"sales30dWithBig":{"op":"lte","value":5}') + }) + + it('需求时间表头筛选支持日期区间', async () => { + const fetchMock = jest.fn(async () => ({ + ok: true, + json: async () => ({ success: true, data: emptyPage }), + })) + global.fetch = fetchMock as unknown as typeof fetch + + render( + , + ) + + fireEvent.click(screen.getByLabelText('筛选需求时间')) + fireEvent.change(screen.getByLabelText('需求时间起始'), { + target: { value: '2026-10-01' }, + }) + fireEvent.change(screen.getByLabelText('需求时间截止'), { + target: { value: '2026-10-20' }, + }) + fireEvent.click(screen.getByRole('button', { name: '确定' })) + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()) + const url = decodeURIComponent(String(fetchMock.mock.calls.at(-1)?.[0])) + expect(url).toContain('dateFrom=2026-10-01') + expect(url).toContain('dateTo=2026-10-20') + }) + + it('图片列展示条码缩略图,点击打开大图预览且不打开明细', () => { + render( + , + ) + + const thumb = screen.getByRole('button', { name: '查看A1图片' }) + expect(thumb.querySelector('img')).toHaveAttribute( + 'src', + 'https://static.jiansun.vip:3333/Resource/SkuImg/A1.jpg', + ) + + fireEvent.click(thumb) + const preview = screen.getByRole('dialog', { name: '图片预览' }) + expect(within(preview).getByRole('img')).toHaveAttribute( + 'src', + 'https://static.jiansun.vip:3333/Resource/SkuImg/A1.jpg', + ) + expect(screen.queryByRole('dialog', { name: '停产需求明细' })).toBeNull() + }) + it('勾选单行或多行后出现删除入口', () => { render( + numericFilters: Record + multiFilters: Record + barcodeCreatedAt: string } const EMPTY_FILTERS: AppliedFilters = { @@ -108,6 +130,19 @@ const EMPTY_FILTERS: AppliedFilters = { sortDirection: 'desc', dateFrom: '', dateTo: '', + barcodeCreatedFrom: '', + barcodeCreatedTo: '', + fieldFilters: {}, + numericFilters: {}, + multiFilters: {}, + barcodeCreatedAt: '', +} + +type SavedQuickQuery = { + id: string + name: string + filters: AppliedFilters + createdAt: string } const PAGE_SIZE = 50 @@ -137,6 +172,9 @@ const PROCESS_STEPS: Array<{ }, ] const COLUMN_WIDTHS_STORAGE_KEY = 'supply-chain.production-stop-demands.column-widths.v1' +const COLUMN_SETTINGS_STORAGE_KEY = 'supply-chain.production-stop-demands.column-settings.v1' +const FILTER_SETTINGS_STORAGE_KEY = 'supply-chain.production-stop-demands.filter-settings.v1' +const QUICK_QUERIES_STORAGE_KEY = 'supply-chain.production-stop-demands.quick-queries.v1' const EMPTY_DETAIL_DRAFT = { replaceType: '', replaceBarcode: '', @@ -144,14 +182,111 @@ const EMPTY_DETAIL_DRAFT = { modificationPoint: '', } -const COLUMN_GROUPS = PRODUCTION_STOP_ITEM_COLUMNS.reduce< - Array<{ group: string; span: number }> ->((groups, column) => { - const last = groups[groups.length - 1] - if (last && last.group === column.group) last.span += 1 - else groups.push({ group: column.group, span: 1 }) - return groups -}, []) +type ColumnSetting = { key: string } + +type FilterSettingKey = string + +type FilterSetting = { key: FilterSettingKey; visible: boolean } + +/** 筛选设置选项:需求日期 + 关键词搜索 + 与列设置一致的明细字段(需求时间由需求日期覆盖,图片不参与筛选)。 */ +const PRODUCTION_STOP_FILTER_FIELDS: ReadonlyArray<{ key: FilterSettingKey; label: string }> = [ + { key: 'dateRange', label: '需求日期' }, + { key: 'keyword', label: '关键词搜索(条码 / SPU / SKU / 供应商 / 采购 / 需求单号)' }, + ...PRODUCTION_STOP_ITEM_COLUMNS + .filter((column) => column.key !== 'submittedAt' && column.key !== 'image') + .map((column) => ({ key: column.key, label: column.label })), +] + +/** 默认展示的快速筛选(与升级前保持一致),其余字段需在设置中手动勾选。 */ +const DEFAULT_VISIBLE_FILTER_KEYS = new Set([ + 'dateRange', + 'keyword', + 'requesterName', + 'purchaseOwner', + 'confirmationStatus', + 'demandStatus', + 'reasonCategory', +]) + +const HEADER_CONFIRMATION_OPTIONS = [ + { label: '待确认', code: 'PENDING' }, + { label: '同意停产', code: 'AGREED' }, + { label: '不同意停产', code: 'REJECTED' }, +] as const + +const HEADER_DEMAND_STATUS_OPTIONS = [ + { label: '待确认', code: 'PENDING_CONFIRMATION' }, + { label: '需求驳回', code: 'REJECTED' }, + { label: '已确认待走流程', code: 'CONFIRMED_WAIT_FLOW' }, + { label: '已走流程待审批', code: 'FLOW_IN_APPROVAL' }, + { label: '流程审批通过', code: 'FLOW_APPROVED' }, +] as const + +const HEADER_PRODUCT_STATUS_OPTIONS = ['销售', '停产', '退市'] +const HEADER_POST_STOP_STATUS_OPTIONS = ['销售', '非销售'] + +function defaultColumnSettings(): ColumnSetting[] { + return PRODUCTION_STOP_ITEM_COLUMNS.map((column) => ({ key: column.key })) +} + +function defaultFilterSettings(): FilterSetting[] { + return PRODUCTION_STOP_FILTER_FIELDS.map((field) => ({ + key: field.key, + visible: DEFAULT_VISIBLE_FILTER_KEYS.has(field.key), + })) +} + +/** 合并本地缓存与当前列定义:保留已保存顺序,丢弃失效字段,新字段追加到末尾。 */ +function normalizeColumnSettings(saved: unknown): ColumnSetting[] { + const known = new Set(PRODUCTION_STOP_ITEM_COLUMNS.map((column) => column.key)) + const result: ColumnSetting[] = [] + if (Array.isArray(saved)) { + for (const item of saved) { + if (!item || typeof item !== 'object') continue + const key = String((item as { key?: unknown }).key ?? '') + if (!known.has(key) || result.some((entry) => entry.key === key)) continue + result.push({ key }) + } + } + for (const column of PRODUCTION_STOP_ITEM_COLUMNS) { + if (!result.some((entry) => entry.key === column.key)) { + result.push({ key: column.key }) + } + } + return result +} + +function groupColumns(columns: readonly ProductionStopItemColumn[]) { + return columns.reduce>((groups, column) => { + const last = groups[groups.length - 1] + if (last && last.group === column.group) last.span += 1 + else groups.push({ group: column.group, span: 1 }) + return groups + }, []) +} + +function normalizeFilterSettings(saved: unknown): FilterSetting[] { + if (!Array.isArray(saved)) return defaultFilterSettings() + const legacyKeys: Record = { + requester: 'requesterName', + purchase: 'purchaseOwner', + } + const known = new Set(PRODUCTION_STOP_FILTER_FIELDS.map((field) => field.key)) + const result: FilterSetting[] = [] + for (const item of saved) { + if (!item || typeof item !== 'object') continue + const rawKey = String((item as { key?: unknown }).key ?? '') + const key = legacyKeys[rawKey] ?? rawKey + if (!known.has(key) || result.some((entry) => entry.key === key)) continue + result.push({ key, visible: (item as { visible?: unknown }).visible !== false }) + } + for (const field of PRODUCTION_STOP_FILTER_FIELDS) { + if (!result.some((entry) => entry.key === field.key)) { + result.push({ key: field.key, visible: false }) + } + } + return result +} async function requestJson(url: string, init?: RequestInit): Promise { const response = await fetch(url, { credentials: 'include', ...init }) @@ -181,6 +316,78 @@ function formatDateTime(value: string) { }).format(date) } +function formatDateOnly(date: Date) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +/** 领导视图的默认统计区间:日=近14天,周=近12周,月=近12个月。 */ +function analyticsDateRange(granularity: ProductionStopAnalyticsGranularity) { + const end = new Date() + const start = new Date(end.getFullYear(), end.getMonth(), end.getDate()) + if (granularity === 'day') { + start.setDate(start.getDate() - 13) + } else if (granularity === 'week') { + start.setDate(start.getDate() - 7 * 11 - ((start.getDay() + 6) % 7)) + } else { + start.setDate(1) + start.setMonth(start.getMonth() - 11) + } + return { from: formatDateOnly(start), to: formatDateOnly(end) } +} + +/** 补齐没有数据的日期/周/月,保证趋势图连续。 */ +function buildAnalyticsSeries(data: ProductionStopAnalytics) { + const counts = new Map(data.series.map((point) => [point.bucket, point.count])) + const start = new Date(`${data.from}T00:00:00`) + const end = new Date(`${data.to}T00:00:00`) + const buckets: string[] = [] + if (data.granularity === 'day') { + for (const cursor = new Date(start); cursor <= end; cursor.setDate(cursor.getDate() + 1)) { + buckets.push(formatDateOnly(cursor)) + } + } else if (data.granularity === 'week') { + const cursor = new Date(start) + cursor.setDate(cursor.getDate() - ((cursor.getDay() + 6) % 7)) + for (; cursor <= end; cursor.setDate(cursor.getDate() + 7)) { + buckets.push(formatDateOnly(cursor)) + } + } else { + const cursor = new Date(start.getFullYear(), start.getMonth(), 1) + for (; cursor <= end; cursor.setMonth(cursor.getMonth() + 1)) { + buckets.push(formatDateOnly(cursor)) + } + } + return buckets.map((bucket) => ({ bucket, count: counts.get(bucket) ?? 0 })) +} + +const ANALYTICS_GRANULARITY_LABELS: Record = { + day: '日', + week: '周', + month: '月', +} + +const DEMAND_STATUS_COLORS: Record = { + PENDING_CONFIRMATION: '#f59e0b', + REJECTED: '#ef4444', + CONFIRMED_WAIT_FLOW: '#6366f1', + FLOW_IN_APPROVAL: '#0ea5e9', + FLOW_APPROVED: '#10b981', +} + +function analyticsBucketRange( + granularity: ProductionStopAnalyticsGranularity, + bucket: string, +) { + const start = new Date(`${bucket}T00:00:00`) + const end = new Date(start) + if (granularity === 'week') end.setDate(end.getDate() + 6) + else if (granularity === 'month') end.setMonth(end.getMonth() + 1, 0) + return { from: formatDateOnly(start), to: formatDateOnly(end) } +} + function confirmationBadgeClass(status: ProductionStopConfirmationStatus) { if (status === 'AGREED') return `${styles.badge} ${styles.badgeAgreed}` if (status === 'REJECTED') return `${styles.badge} ${styles.badgeRejected}` @@ -210,6 +417,18 @@ function filterParams(filters: AppliedFilters, page: number) { } if (filters.dateFrom) params.set('dateFrom', filters.dateFrom) if (filters.dateTo) params.set('dateTo', filters.dateTo) + if (filters.barcodeCreatedFrom) params.set('barcodeCreatedFrom', filters.barcodeCreatedFrom) + if (filters.barcodeCreatedTo) params.set('barcodeCreatedTo', filters.barcodeCreatedTo) + if (Object.keys(filters.fieldFilters).length) { + params.set('fieldFilters', JSON.stringify(filters.fieldFilters)) + } + if (Object.keys(filters.numericFilters).length) { + params.set('numericFilters', JSON.stringify(filters.numericFilters)) + } + if (Object.keys(filters.multiFilters).length) { + params.set('multiFilters', JSON.stringify(filters.multiFilters)) + } + if (filters.barcodeCreatedAt) params.set('barcodeCreatedAt', filters.barcodeCreatedAt) return params } @@ -232,6 +451,24 @@ export function ProductionStopDemandsView({ const [keywordDraft, setKeywordDraft] = useState('') const [requesterDraft, setRequesterDraft] = useState('') const [purchaseDraft, setPurchaseDraft] = useState('') + const [fieldFilterDrafts, setFieldFilterDrafts] = useState>({}) + const [quickQueries, setQuickQueries] = useState([]) + const [quickQueryOpen, setQuickQueryOpen] = useState(false) + const [saveQueryOpen, setSaveQueryOpen] = useState(false) + const [quickQueryName, setQuickQueryName] = useState('') + const [quickQueryError, setQuickQueryError] = useState('') + const quickQueriesHydrated = useRef(false) + const [analyticsOpen, setAnalyticsOpen] = useState(false) + const [analyticsGranularity, setAnalyticsGranularity] = + useState('day') + const [analyticsData, setAnalyticsData] = useState(null) + const [analyticsLoading, setAnalyticsLoading] = useState(false) + const [analyticsError, setAnalyticsError] = useState('') + const [numericFilterDrafts, setNumericFilterDrafts] = useState>({}) + const [barcodeCreatedAtDraft, setBarcodeCreatedAtDraft] = useState('') const [dateFromDraft, setDateFromDraft] = useState('') const [dateToDraft, setDateToDraft] = useState('') const [loading, setLoading] = useState(false) @@ -313,9 +550,423 @@ export function ProductionStopDemandsView({ } }, [columnWidths]) + // 列设置:显隐 + 拖动排序,按浏览器本地记忆(个人偏好,不影响其他用户)。 + const [columnSettings, setColumnSettings] = useState(defaultColumnSettings) + const [settingsOpen, setSettingsOpen] = useState(false) + const [draggingColumnKey, setDraggingColumnKey] = useState('') + const columnSettingsHydrated = useRef(false) + + useEffect(() => { + try { + const raw = window.localStorage.getItem(COLUMN_SETTINGS_STORAGE_KEY) + if (raw) setColumnSettings(normalizeColumnSettings(JSON.parse(raw))) + } catch { + // 本地列设置不可用时使用默认列顺序。 + } finally { + columnSettingsHydrated.current = true + } + }, []) + + useEffect(() => { + if (!columnSettingsHydrated.current) return + try { + window.localStorage.setItem(COLUMN_SETTINGS_STORAGE_KEY, JSON.stringify(columnSettings)) + } catch { + // 本地存储不可用时忽略列设置记忆。 + } + }, [columnSettings]) + + const columnByKey = useMemo( + () => new Map( + PRODUCTION_STOP_ITEM_COLUMNS.map((column) => [column.key, column]), + ), + [], + ) + const orderedColumns = useMemo( + () => columnSettings + .map((setting) => columnByKey.get(setting.key)) + .filter((column): column is ProductionStopItemColumn => Boolean(column)), + [columnByKey, columnSettings], + ) + const columnGroups = useMemo(() => groupColumns(orderedColumns), [orderedColumns]) + + // 筛选设置:勾选需要在快速查询栏展示的筛选项(个人偏好)。 + const [filterSettings, setFilterSettings] = useState(defaultFilterSettings) + const filterSettingsHydrated = useRef(false) + + useEffect(() => { + try { + const raw = window.localStorage.getItem(FILTER_SETTINGS_STORAGE_KEY) + if (raw) setFilterSettings(normalizeFilterSettings(JSON.parse(raw))) + } catch { + // 本地筛选设置不可用时使用默认值。 + } finally { + filterSettingsHydrated.current = true + } + }, []) + + useEffect(() => { + if (!filterSettingsHydrated.current) return + try { + window.localStorage.setItem(FILTER_SETTINGS_STORAGE_KEY, JSON.stringify(filterSettings)) + } catch { + // 本地存储不可用时忽略筛选设置记忆。 + } + }, [filterSettings]) + + useEffect(() => { + try { + const raw = window.localStorage.getItem(QUICK_QUERIES_STORAGE_KEY) + if (raw) { + const parsed = JSON.parse(raw) as unknown + if (Array.isArray(parsed)) { + setQuickQueries(parsed.filter((item): item is SavedQuickQuery => + Boolean(item) + && typeof item === 'object' + && typeof (item as SavedQuickQuery).name === 'string' + && Boolean((item as SavedQuickQuery).filters))) + } + } + } catch { + // 本地快捷查询不可用时忽略。 + } finally { + quickQueriesHydrated.current = true + } + }, []) + + useEffect(() => { + if (!quickQueriesHydrated.current) return + try { + window.localStorage.setItem(QUICK_QUERIES_STORAGE_KEY, JSON.stringify(quickQueries)) + } catch { + // 本地存储不可用时忽略快捷查询记忆。 + } + }, [quickQueries]) + + useEffect(() => { + if (!quickQueryOpen) return + const onPointerDown = (event: MouseEvent) => { + const target = event.target as HTMLElement | null + if (target?.closest('[data-quick-query-menu]')) return + setQuickQueryOpen(false) + } + document.addEventListener('mousedown', onPointerDown) + return () => document.removeEventListener('mousedown', onPointerDown) + }, [quickQueryOpen]) + + const isFilterVisible = (key: FilterSettingKey) => + filterSettings.some((setting) => setting.key === key && setting.visible) + + /** 隐藏筛选项时同步清空已生效条件,避免“看不见但仍在过滤”。 */ + const clearFilterField = (key: FilterSettingKey) => { + const clear = (patch: Partial) => { + setPage(1) + setFilters((current) => ({ ...current, ...patch })) + } + switch (key) { + case 'dateRange': + setDateFromDraft('') + setDateToDraft('') + clear({ dateFrom: '', dateTo: '' }) + break + case 'keyword': + setKeywordDraft('') + clear({ keyword: '' }) + break + case 'requesterName': + setRequesterDraft('') + clear({ requesterName: '' }) + break + case 'purchaseOwner': + setPurchaseDraft('') + clear({ purchaseOwner: '' }) + break + case 'confirmationStatus': + clear({ confirmationStatus: '' }) + break + case 'demandStatus': + clear({ demandStatus: '', demandStatuses: [] }) + break + case 'reasonCategory': + clear({ reasonCategory: '' }) + break + case 'barcodeCreatedAt': + setBarcodeCreatedAtDraft('') + clear({ barcodeCreatedAt: '' }) + break + default: + if (isProductionStopItemNumericFilterKey(key)) { + setNumericFilterDrafts((current) => { + const next = { ...current } + delete next[key] + return next + }) + setPage(1) + setFilters((current) => { + const next = { ...current.numericFilters } + delete next[key] + return { ...current, numericFilters: next } + }) + } else if (isProductionStopItemTextFilterKey(key)) { + setFieldFilterDrafts((current) => ({ ...current, [key]: '' })) + setPage(1) + setFilters((current) => { + const next = { ...current.fieldFilters } + delete next[key] + return { ...current, fieldFilters: next } + }) + } + break + } + } + + const toggleFilterVisible = (key: FilterSettingKey) => { + const target = filterSettings.find((setting) => setting.key === key) + if (!target) return + setFilterSettings((current) => current.map((setting) => + setting.key === key ? { ...setting, visible: !setting.visible } : setting)) + if (target.visible) clearFilterField(key) + } + + // 表头漏斗筛选:模糊输入 + 多选值;数值字段为运算符 + 数值,日期字段为日期。 + const [columnFilterMenu, setColumnFilterMenu] = useState(null) + const [columnFilterSearch, setColumnFilterSearch] = useState>({}) + const [columnFilterSelection, setColumnFilterSelection] = useState>({}) + const [columnFilterNumeric, setColumnFilterNumeric] = useState>({}) + const [columnFilterDateRange, setColumnFilterDateRange] = useState< + Record + >({}) + const [columnFilterOptions, setColumnFilterOptions] = useState>({}) + + useEffect(() => { + if (!columnFilterMenu) return + const onPointerDown = (event: MouseEvent) => { + const target = event.target as HTMLElement | null + if (target?.closest('[data-column-filter-menu]')) return + setColumnFilterMenu(null) + } + document.addEventListener('mousedown', onPointerDown) + return () => document.removeEventListener('mousedown', onPointerDown) + }, [columnFilterMenu]) + + const columnFilterStaticOptions = (key: string): Array<{ value: string; label: string }> => { + if (key === 'requesterName') { + return data.filterOptions.requesterNames.map((value) => ({ value, label: value })) + } + if (key === 'purchaseOwner') { + return data.filterOptions.purchaseOwners.map((value) => ({ value, label: value })) + } + if (key === 'reasonCategory') { + return data.filterOptions.reasonCategories.map((value) => ({ value, label: value })) + } + if (key === 'confirmationStatus') { + return HEADER_CONFIRMATION_OPTIONS.map((option) => ({ + value: option.code, + label: option.label, + })) + } + if (key === 'demandStatus') { + return HEADER_DEMAND_STATUS_OPTIONS.map((option) => ({ + value: option.code, + label: option.label, + })) + } + if (key === 'productStatus') { + return HEADER_PRODUCT_STATUS_OPTIONS.map((value) => ({ value, label: value })) + } + if (key === 'postStopSkuStatus') { + return HEADER_POST_STOP_STATUS_OPTIONS.map((value) => ({ value, label: value })) + } + if (key === 'replaceType') { + return PRODUCTION_STOP_REPLACE_TYPES.map((value) => ({ value, label: value })) + } + if (key === 'imageChange') { + return PRODUCTION_STOP_IMAGE_CHANGE_OPTIONS.map((value) => ({ value, label: value })) + } + return (columnFilterOptions[key] ?? []).map((value) => ({ value, label: value })) + } + + const loadColumnFilterOptions = (key: string) => { + if (key === 'requesterName' || key === 'purchaseOwner' || key === 'reasonCategory') return + if (!isProductionStopItemTextFilterKey(key) || columnFilterOptions[key]) return + void requestJson<{ values: string[] }>( + `/api/supply-chain/management/production-stop-demands/filter-options?field=${encodeURIComponent(key)}`, + ) + .then((result) => { + setColumnFilterOptions((current) => ({ + ...current, + [key]: Array.isArray(result.values) ? result.values : [], + })) + }) + .catch(() => null) + } + + const openColumnFilter = (key: string) => { + if (columnFilterMenu === key) { + setColumnFilterMenu(null) + return + } + setColumnFilterMenu(key) + const initialSearch = key === 'requesterName' + ? filters.requesterName + : key === 'purchaseOwner' + ? filters.purchaseOwner + : key === 'reasonCategory' + ? filters.reasonCategory + : filters.fieldFilters[key] ?? '' + setColumnFilterSearch((current) => ({ ...current, [key]: initialSearch })) + setColumnFilterSelection((current) => ({ + ...current, + [key]: filters.multiFilters[key] ?? [], + })) + setColumnFilterNumeric((current) => ({ + ...current, + [key]: filters.numericFilters[key] + ? { op: filters.numericFilters[key].op, value: String(filters.numericFilters[key].value) } + : current[key] ?? { op: 'gte', value: '' }, + })) + setColumnFilterDateRange((current) => ({ + ...current, + [key]: key === 'barcodeCreatedAt' + ? (filters.barcodeCreatedFrom || filters.barcodeCreatedTo + ? { from: filters.barcodeCreatedFrom, to: filters.barcodeCreatedTo } + : { from: filters.barcodeCreatedAt, to: filters.barcodeCreatedAt }) + : { from: filters.dateFrom, to: filters.dateTo }, + })) + loadColumnFilterOptions(key) + } + + const toggleColumnFilterValue = (key: string, value: string) => { + setColumnFilterSelection((current) => { + const selected = current[key] ?? [] + const next = selected.includes(value) + ? selected.filter((item) => item !== value) + : [...selected, value] + return { ...current, [key]: next } + }) + } + + const applyColumnFilter = (key: string) => { + setPage(1) + setFilters((current) => { + const next = { ...current } + if (key === 'submittedAt') { + const range = columnFilterDateRange[key] ?? { from: '', to: '' } + next.dateFrom = range.from + next.dateTo = range.to + return next + } + if (key === 'barcodeCreatedAt') { + const range = columnFilterDateRange[key] ?? { from: '', to: '' } + next.barcodeCreatedFrom = range.from + next.barcodeCreatedTo = range.to + return next + } + if (isProductionStopItemNumericFilterKey(key)) { + const draft = columnFilterNumeric[key] + const numericFilters = { ...current.numericFilters } + const value = Number(draft?.value ?? '') + if (draft && draft.value.trim() !== '' && Number.isFinite(value)) { + numericFilters[key] = { op: draft.op, value } + } else { + delete numericFilters[key] + } + next.numericFilters = numericFilters + return next + } + if (isProductionStopItemMultiFilterKey(key)) { + const selected = columnFilterSelection[key] ?? [] + const multiFilters = { ...current.multiFilters } + if (selected.length) multiFilters[key] = selected + else delete multiFilters[key] + next.multiFilters = multiFilters + const search = (columnFilterSearch[key] ?? '').trim() + if (!selected.length && isProductionStopItemTextFilterKey(key)) { + const fieldFilters = { ...current.fieldFilters } + if (search) fieldFilters[key] = search + else delete fieldFilters[key] + next.fieldFilters = fieldFilters + } else if (!selected.length) { + if (key === 'requesterName') next.requesterName = search + if (key === 'purchaseOwner') next.purchaseOwner = search + if (key === 'reasonCategory') next.reasonCategory = search + } + } + return next + }) + setColumnFilterMenu(null) + } + + const clearColumnFilter = (key: string) => { + setColumnFilterSearch((current) => ({ ...current, [key]: '' })) + setColumnFilterSelection((current) => ({ ...current, [key]: [] })) + setColumnFilterNumeric((current) => ({ ...current, [key]: { op: 'gte', value: '' } })) + setColumnFilterDateRange((current) => ({ ...current, [key]: { from: '', to: '' } })) + setPage(1) + setFilters((current) => { + const next = { ...current } + if (key === 'submittedAt') { + next.dateFrom = '' + next.dateTo = '' + } else if (key === 'barcodeCreatedAt') { + next.barcodeCreatedAt = '' + next.barcodeCreatedFrom = '' + next.barcodeCreatedTo = '' + } else if (isProductionStopItemNumericFilterKey(key)) { + const numericFilters = { ...current.numericFilters } + delete numericFilters[key] + next.numericFilters = numericFilters + } else if (isProductionStopItemMultiFilterKey(key)) { + const multiFilters = { ...current.multiFilters } + delete multiFilters[key] + next.multiFilters = multiFilters + if (isProductionStopItemTextFilterKey(key)) { + const fieldFilters = { ...current.fieldFilters } + delete fieldFilters[key] + next.fieldFilters = fieldFilters + } + if (key === 'requesterName') next.requesterName = '' + if (key === 'purchaseOwner') next.purchaseOwner = '' + if (key === 'reasonCategory') next.reasonCategory = '' + } + return next + }) + setColumnFilterMenu(null) + } + + const hasColumnFilter = (key: string) => { + if (key === 'submittedAt') return Boolean(filters.dateFrom || filters.dateTo) + if (key === 'barcodeCreatedAt') { + return Boolean(filters.barcodeCreatedAt || filters.barcodeCreatedFrom || filters.barcodeCreatedTo) + } + if (isProductionStopItemNumericFilterKey(key)) return Boolean(filters.numericFilters[key]) + if (isProductionStopItemMultiFilterKey(key)) { + return Boolean(filters.multiFilters[key]?.length) + || (isProductionStopItemTextFilterKey(key) && Boolean(filters.fieldFilters[key])) + } + return false + } + + const moveColumnSetting = (sourceKey: string, targetKey: string) => { + if (!sourceKey || sourceKey === targetKey) return + setColumnSettings((current) => { + const from = current.findIndex((setting) => setting.key === sourceKey) + const to = current.findIndex((setting) => setting.key === targetKey) + if (from < 0 || to < 0) return current + const next = [...current] + const [moved] = next.splice(from, 1) + next.splice(to, 0, moved) + return next + }) + setDraggingColumnKey('') + } + const tableWidth = CHECKBOX_COLUMN_WIDTH + - PRODUCTION_STOP_ITEM_COLUMNS.reduce( + orderedColumns.reduce( (total, column) => total + (columnWidths[column.key] ?? column.width), 0, ) @@ -473,6 +1124,21 @@ export function ProductionStopDemandsView({ }, [page, filters, load]) const applyFilters = () => { + const fieldFilters: Record = {} + for (const key of PRODUCTION_STOP_ITEM_TEXT_FILTER_KEYS) { + if (!isFilterVisible(key)) continue + const value = (fieldFilterDrafts[key] ?? '').trim() + if (value) fieldFilters[key] = value + } + const numericFilters: Record = {} + for (const key of PRODUCTION_STOP_ITEM_NUMERIC_FILTER_KEYS) { + if (!isFilterVisible(key)) continue + const draft = numericFilterDrafts[key] + if (!draft || draft.value.trim() === '') continue + const value = Number(draft.value) + if (!Number.isFinite(value)) continue + numericFilters[key] = { op: draft.op, value } + } setPage(1) setFilters({ keyword: keywordDraft.trim(), @@ -485,6 +1151,12 @@ export function ProductionStopDemandsView({ sortDirection: filters.sortDirection, dateFrom: dateFromDraft, dateTo: dateToDraft, + fieldFilters, + numericFilters, + multiFilters: filters.multiFilters, + barcodeCreatedAt: barcodeCreatedAtDraft, + barcodeCreatedFrom: filters.barcodeCreatedFrom, + barcodeCreatedTo: filters.barcodeCreatedTo, demandStatuses: filters.demandStatuses, }) } @@ -515,12 +1187,174 @@ export function ProductionStopDemandsView({ setKeywordDraft('') setRequesterDraft('') setPurchaseDraft('') + setFieldFilterDrafts({}) + setNumericFilterDrafts({}) + setBarcodeCreatedAtDraft('') setDateFromDraft('') setDateToDraft('') setPage(1) setFilters(EMPTY_FILTERS) } + /** 套用已保存的快捷查询:恢复筛选条件与输入框草稿,排序保持不变。 */ + const applyQuickQuery = (query: SavedQuickQuery) => { + const restored: AppliedFilters = { + ...EMPTY_FILTERS, + ...query.filters, + sortKey: filters.sortKey, + sortDirection: filters.sortDirection, + } + setPage(1) + setFilters(restored) + setKeywordDraft(restored.keyword) + setRequesterDraft(restored.requesterName) + setPurchaseDraft(restored.purchaseOwner) + setDateFromDraft(restored.dateFrom) + setDateToDraft(restored.dateTo) + setBarcodeCreatedAtDraft(restored.barcodeCreatedAt) + setFieldFilterDrafts({ ...restored.fieldFilters }) + setNumericFilterDrafts(Object.fromEntries( + Object.entries(restored.numericFilters).map(([key, value]) => [ + key, + { op: value.op, value: String(value.value) }, + ]), + )) + setQuickQueryOpen(false) + } + + const openSaveQuickQuery = () => { + setQuickQueryName('') + setQuickQueryError('') + setSaveQueryOpen(true) + setQuickQueryOpen(false) + } + + const saveQuickQuery = () => { + const name = quickQueryName.trim() + if (!name) { + setQuickQueryError('请填写快捷查询名称') + return + } + if (name.length > 30) { + setQuickQueryError('名称不能超过 30 个字') + return + } + const existing = quickQueries.find((item) => item.name === name) + const entry: SavedQuickQuery = { + id: existing?.id ?? `qq_${Date.now()}`, + name, + filters: { ...filters }, + createdAt: new Date().toISOString(), + } + setQuickQueries((current) => existing + ? current.map((item) => (item.id === existing.id ? entry : item)) + : [...current, entry]) + setSaveQueryOpen(false) + setNotice(existing ? `已更新快捷查询「${name}」` : `已保存快捷查询「${name}」`) + } + + const deleteQuickQuery = (id: string) => { + setQuickQueries((current) => current.filter((item) => item.id !== id)) + } + + /** 视图跟随主表筛选:日期优先用主表需求日期,未设置时用当前维度默认区间。 */ + const resolveAnalyticsRange = (granularity: ProductionStopAnalyticsGranularity) => { + const fallback = analyticsDateRange(granularity) + return { + from: filters.dateFrom || fallback.from, + to: filters.dateTo || fallback.to, + } + } + + const loadAnalytics = async (granularity: ProductionStopAnalyticsGranularity) => { + setAnalyticsLoading(true) + setAnalyticsError('') + try { + const { from, to } = resolveAnalyticsRange(granularity) + const params = new URLSearchParams({ + granularity, + from, + to, + filters: JSON.stringify({ + keyword: filters.keyword, + confirmationStatus: filters.confirmationStatus, + demandStatus: filters.demandStatus, + demandStatuses: filters.demandStatuses, + reasonCategory: filters.reasonCategory, + purchaseOwner: filters.purchaseOwner, + requesterName: filters.requesterName, + fieldFilters: filters.fieldFilters, + numericFilters: filters.numericFilters, + multiFilters: filters.multiFilters, + barcodeCreatedAt: filters.barcodeCreatedAt, + barcodeCreatedFrom: filters.barcodeCreatedFrom, + barcodeCreatedTo: filters.barcodeCreatedTo, + }), + }) + const result = await requestJson( + `/api/supply-chain/management/production-stop-demands/analytics?${params.toString()}`, + ) + setAnalyticsData(result) + } catch (error) { + setAnalyticsError(error instanceof Error ? error.message : '统计加载失败,请稍后重试') + } finally { + setAnalyticsLoading(false) + } + } + + const openAnalytics = () => { + setAnalyticsOpen(true) + void loadAnalytics(analyticsGranularity) + } + + const switchAnalyticsGranularity = (granularity: ProductionStopAnalyticsGranularity) => { + setAnalyticsGranularity(granularity) + void loadAnalytics(granularity) + } + + /** 从统计卡片/分布项下钻到明细分页:套用统计区间 + 对应筛选条件。 */ + const drillDownAnalytics = ( + patch: Partial, + range?: { from: string; to: string }, + ) => { + const from = range?.from ?? analyticsData?.from ?? '' + const to = range?.to ?? analyticsData?.to ?? '' + const next: AppliedFilters = { + ...filters, + ...patch, + dateFrom: from, + dateTo: to, + sortKey: filters.sortKey, + sortDirection: filters.sortDirection, + } + if (patch.fieldFilters) { + next.fieldFilters = { ...filters.fieldFilters, ...patch.fieldFilters } + } + if (patch.numericFilters) { + next.numericFilters = { ...filters.numericFilters, ...patch.numericFilters } + } + if (patch.multiFilters) { + next.multiFilters = { ...filters.multiFilters, ...patch.multiFilters } + } + setPage(1) + setFilters(next) + setKeywordDraft(next.keyword) + setRequesterDraft(next.requesterName) + setPurchaseDraft(next.purchaseOwner) + setDateFromDraft(next.dateFrom) + setDateToDraft(next.dateTo) + setBarcodeCreatedAtDraft(next.barcodeCreatedAt) + setFieldFilterDrafts({ ...next.fieldFilters }) + setNumericFilterDrafts(Object.fromEntries( + Object.entries(next.numericFilters).map(([key, value]) => [ + key, + { op: value.op, value: String(value.value) }, + ]), + )) + setAnalyticsOpen(false) + setNotice('已按统计条件筛选明细,可在列表查看') + } + // 新建需求 const [createOpen, setCreateOpen] = useState(false) const [categories, setCategories] = useState([]) @@ -619,6 +1453,9 @@ export function ProductionStopDemandsView({ // 明细 + 停产确认 const [detailRow, setDetailRow] = useState(null) + // 图片列:大图预览 + 加载回退(直连档案图片地址失败时切到服务端图片代理)。 + const [imagePreview, setImagePreview] = useState<{ image: string; title: string } | null>(null) + const [imageSrcOverrides, setImageSrcOverrides] = useState>({}) const [detailDraft, setDetailDraft] = useState(EMPTY_DETAIL_DRAFT) const [detailBusy, setDetailBusy] = useState(false) const [detailError, setDetailError] = useState('') @@ -1719,7 +2556,261 @@ export function ProductionStopDemandsView({ }) } + /** 表头漏斗弹层:模糊输入 + 多选值;数值字段为运算符 + 数值,日期字段为日期。 */ + const renderColumnFilterMenu = (column: ProductionStopItemColumn) => { + const key = column.key + if (key === 'image' || columnFilterMenu !== key) return null + if (isProductionStopItemNumericFilterKey(key)) { + const draft = columnFilterNumeric[key] ?? { op: 'gte' as const, value: '' } + return ( +
event.stopPropagation()} + > +
{column.label}
+
+ + setColumnFilterNumeric((current) => ({ + ...current, + [key]: { ...draft, value: event.target.value }, + }))} + onKeyDown={(event) => { + if (event.key === 'Enter') applyColumnFilter(key) + }} + /> +
+
+ + +
+
+ ) + } + if (key === 'submittedAt' || key === 'barcodeCreatedAt') { + const range = columnFilterDateRange[key] ?? { from: '', to: '' } + return ( +
event.stopPropagation()} + > +
{column.label}
+
+ setColumnFilterDateRange((current) => ({ + ...current, + [key]: { ...range, from: event.target.value }, + }))} + /> + 至 + setColumnFilterDateRange((current) => ({ + ...current, + [key]: { ...range, to: event.target.value }, + }))} + /> +
+
+ + +
+
+ ) + } + const search = columnFilterSearch[key] ?? '' + const selected = columnFilterSelection[key] ?? [] + const options = columnFilterStaticOptions(key).filter((option) => + !search.trim() || option.label.toLowerCase().includes(search.trim().toLowerCase())) + return ( +
event.stopPropagation()} + > +
{column.label}
+ setColumnFilterSearch((current) => ({ + ...current, + [key]: event.target.value, + }))} + onKeyDown={(event) => { + if (event.key === 'Enter') applyColumnFilter(key) + }} + /> +
+ + {options.map((option) => ( + + ))} + {!options.length ? 没有匹配的选项 : null} +
+
+ + +
+
+ ) + } + + const analyticsSeries = analyticsData ? buildAnalyticsSeries(analyticsData) : [] + const analyticsMax = Math.max(1, ...analyticsSeries.map((point) => point.count)) + const analyticsTotal = analyticsData?.total ?? 0 + let pieCursor = 0 + const pieStops = (analyticsData?.byDemandStatus ?? []).map((item) => { + const start = analyticsTotal > 0 ? (pieCursor / analyticsTotal) * 100 : 0 + pieCursor += item.count + const end = analyticsTotal > 0 ? (pieCursor / analyticsTotal) * 100 : 0 + return `${DEMAND_STATUS_COLORS[item.status] ?? '#a1a1aa'} ${start}% ${end}%` + }) + const analyticsPieBackground = pieStops.length + ? `conic-gradient(${pieStops.join(', ')})` + : '#eef1f5' + + const renderAnalyticsDistribution = ( + title: string, + items: Array<{ key: string; count: number; value?: string }>, + onSelect: (item: { key: string; count: number; value?: string }) => void, + ) => { + const max = Math.max(1, ...items.map((item) => item.count)) + const total = analyticsData?.total ?? 0 + return ( +
+

{title}

+ {items.length ? ( +
+ {items.slice(0, 10).map((item) => ( + + ))} +
+ ) : ( +
暂无数据
+ )} +
+ ) + } + const renderCell = (row: ProductionStopItemRow, key: string) => { + if (key === 'image') { + const fallbackSrc = row.barcode.trim() + ? `/api/supply-chain/product-archive/source-image?${new URLSearchParams({ barcode: row.barcode }).toString()}` + : '' + const override = imageSrcOverrides[row.id] + const imageSrc = override !== undefined ? override : row.image + if (!imageSrc) return - + return ( + + + + ) + } const value = String((row as unknown as Record)[key] ?? '') if (key === 'confirmationStatus') { return ( @@ -1781,105 +2872,205 @@ export function ProductionStopDemandsView({
- -
- - ))} + {column.key !== 'image' ? ( + + ) : null} + {renderColumnFilterMenu(column)} + handleColumnResizeStart(event, column.key)} + onPointerMove={handleColumnResizeMove} + onPointerUp={handleColumnResizeEnd} + onPointerCancel={handleColumnResizeEnd} + onDoubleClick={() => resetColumnWidth(column.key)} + onKeyDown={(event) => handleColumnResizeKeyDown(event, column.key)} + /> + + + ) + })} @@ -2115,14 +3377,14 @@ export function ProductionStopDemandsView({ /> - {PRODUCTION_STOP_ITEM_COLUMNS.map((column) => ( + {orderedColumns.map((column) => ( {renderCell(row, column.key)} ))} ))} {!data.rows.length ? ( - + 暂无停产需求 @@ -2282,6 +3544,400 @@ export function ProductionStopDemandsView({ ) : null} + {imagePreview ? ( +
{ + if (event.target === event.currentTarget) setImagePreview(null) + }} + > +
+
+
+

图片

+

{imagePreview.title}

+
+
+ +
+
+
+ {imagePreview.title} +
+
+
+ ) : null} + + {analyticsOpen ? ( +
{ + if (event.target === event.currentTarget) setAnalyticsOpen(false) + }} + > +
+
+
+

停产需求分析

+

+ {analyticsData + ? `${analyticsData.from} 至 ${analyticsData.to} · 按需求单统计 · 跟随主表筛选` + : '按需求单统计 · 跟随主表筛选'} +

+
+
+ {(['day', 'week', 'month'] as const).map((granularity) => ( + + ))} + +
+
+
+
+ {analyticsLoading ? ( +
统计加载中…
+ ) : analyticsError ? ( +
+ + {analyticsError} +
+ ) : analyticsData ? ( + <> +
+ + {analyticsData.byDemandStatus.map((item) => ( + + ))} +
+
+
+
+

+ 需求趋势(按{ANALYTICS_GRANULARITY_LABELS[analyticsData.granularity]}) +

+ 需求数 +
+
+ {analyticsSeries.map((point) => ( + + ))} +
+
+
+
+

需求状态分布

+ 共 {analyticsData.total} 条 +
+ {analyticsData.total ? ( +
+
+
+ {analyticsData.total} + 总需求 +
+
+
+ {analyticsData.byDemandStatus.map((item) => ( + + ))} +
+
+ ) : ( +
暂无数据
+ )} +
+
+
+ {renderAnalyticsDistribution( + '需求部门', + analyticsData.byDepartment, + (item) => drillDownAnalytics({ + multiFilters: { department: [item.key === '未填写' ? '' : item.key] }, + }), + )} + {renderAnalyticsDistribution( + '需求人', + analyticsData.byRequester, + (item) => drillDownAnalytics({ + multiFilters: { requesterName: [item.key === '未填写' ? '' : item.key] }, + }), + )} + {renderAnalyticsDistribution( + '停产原因分类', + analyticsData.byReasonCategory, + (item) => drillDownAnalytics({ + multiFilters: { reasonCategory: [item.key === '未填写' ? '' : item.key] }, + }), + )} +
+ + ) : null} +
+
+
+
+ ) : null} + + {saveQueryOpen ? ( +
{ + if (event.target === event.currentTarget) setSaveQueryOpen(false) + }} + > +
+
+
+

另存为快捷查询

+

保存当前筛选条件,下次一键套用。

+
+
+ +
+
+
+ + {quickQueryError ? ( +
+ + {quickQueryError} +
+ ) : null} +
+
+ + +
+
+
+ ) : null} + + {settingsOpen ? ( +
{ + if (event.target === event.currentTarget) setSettingsOpen(false) + }} + > +
+
+
+

设置

+

配置列表展示字段与快速查询栏筛选项。

+
+
+ +
+
+
+
+

列设置

+

+ 按住任意字段拖动即可调整顺序。 +

+
    + {columnSettings.map((setting) => { + const column = columnByKey.get(setting.key) + if (!column) return null + return ( +
  • { + setDraggingColumnKey(setting.key) + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', setting.key) + }} + onDragEnd={() => setDraggingColumnKey('')} + onDragOver={(event) => { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + }} + onDrop={(event) => { + event.preventDefault() + const sourceKey = event.dataTransfer.getData('text/plain') || draggingColumnKey + moveColumnSetting(sourceKey, setting.key) + }} + > + + {column.label} +
  • + ) + })} +
+
+
+

筛选设置

+

+ 勾选需要出现在快速查询栏的筛选项;隐藏时会同步清空该筛选条件。 +

+
    + {filterSettings.map((setting) => { + const field = PRODUCTION_STOP_FILTER_FIELDS.find((item) => item.key === setting.key) + if (!field) return null + return ( +
  • + +
  • + ) + })} +
+
+
+
+ + +
+
+
+ ) : null} + {detailRow ? (
{ + it('把业务地址包装为 pc_slide=false 的钉钉客户端链接', () => { + expect(buildDingTalkFullscreenUrl('https://cp.jiansun.vip/a?b=1')) + .toBe( + 'dingtalk://dingtalkclient/page/link?url=https%3A%2F%2Fcp.jiansun.vip%2Fa%3Fb%3D1&pc_slide=false', + ) + }) + + it('不会重复包装或包装空地址', () => { + const wrapped = buildDingTalkFullscreenUrl('https://cp.jiansun.vip/a') + expect(buildDingTalkFullscreenUrl(wrapped)).toBe(wrapped) + expect(buildDingTalkFullscreenUrl('')).toBe('') + }) +}) diff --git a/furniture-3d-system/src/lib/dingtalk-link.ts b/furniture-3d-system/src/lib/dingtalk-link.ts new file mode 100644 index 00000000..6240831d --- /dev/null +++ b/furniture-3d-system/src/lib/dingtalk-link.ts @@ -0,0 +1,10 @@ +/** + * 钉钉 PC 客户端默认把机器人消息里的链接开在右侧「详情」侧栏(较窄)。 + * 通过钉钉客户端路由 `pc_slide=false` 让链接以独立窗口(全屏可用)打开。 + * 移动端仍由钉钉内置浏览器打开。 + */ +export function buildDingTalkFullscreenUrl(url: string) { + const value = String(url || '').trim() + if (!value || value.startsWith('dingtalk://')) return value + return `dingtalk://dingtalkclient/page/link?url=${encodeURIComponent(value)}&pc_slide=false` +} diff --git a/furniture-3d-system/src/lib/production-stop-demands.test.ts b/furniture-3d-system/src/lib/production-stop-demands.test.ts index 9d56e62e..104c432a 100644 --- a/furniture-3d-system/src/lib/production-stop-demands.test.ts +++ b/furniture-3d-system/src/lib/production-stop-demands.test.ts @@ -2,7 +2,9 @@ import { PRODUCTION_STOP_DEFAULT_REASON_CATEGORIES, PRODUCTION_STOP_ITEM_COLUMNS, parseBarcodeInput, + parseProductionStopSearchTerms, resolveProductionStopConfirmationDeadline, + resolveProductionStopProductLabel, } from './production-stop-demands' describe('停产需求管理口径', () => { @@ -10,6 +12,11 @@ describe('停产需求管理口径', () => { expect(parseBarcodeInput('A1\nb2,A1; B2、C3 c4')).toEqual(['A1', 'b2', 'C3', 'c4']) }) + it('批量搜索词支持换行、逗号、空格分隔并去重', () => { + expect(parseProductionStopSearchTerms('DNZ001,DNZ002\nDNZ003 dnz001;DNZ004')) + .toEqual(['DNZ001', 'DNZ002', 'DNZ003', 'DNZ004']) + }) + it('明细列覆盖需求表字段并按需求顺序排列', () => { expect(PRODUCTION_STOP_ITEM_COLUMNS[0]).toMatchObject({ key: 'submittedAt', label: '需求时间' }) expect(PRODUCTION_STOP_ITEM_COLUMNS.map((column) => column.label)).toEqual( @@ -47,6 +54,10 @@ describe('停产需求管理口径', () => { expect.objectContaining({ key: 'confirmationStatus', label: '停产确认' }), ]), ) + // 「图片」列插在条码前面,仅展示不参与排序。 + const imageIndex = PRODUCTION_STOP_ITEM_COLUMNS.findIndex((column) => column.key === 'image') + const barcodeIndex = PRODUCTION_STOP_ITEM_COLUMNS.findIndex((column) => column.key === 'barcode') + expect(imageIndex).toBe(barcodeIndex - 1) }) it('停产原因分类包含配置表里的初始选项', () => { @@ -95,3 +106,49 @@ describe('是否同意停产确认截止时间', () => { ).toBe('2026-10-08T08:00:00.000Z') }) }) + +describe('产品标签口径', () => { + it('SABC 标签优先返回打标值', () => { + expect(resolveProductionStopProductLabel({ + sabcRating: 'A', + productStatus: '销售', + realPerformance: '', + })).toBe('A') + expect(resolveProductionStopProductLabel({ + sabcRating: 'C', + productStatus: '停产', + realPerformance: '0', + })).toBe('C') + }) + + it('未打标的非销售商品返回「非销售商品未打标」', () => { + expect(resolveProductionStopProductLabel({ + sabcRating: '', + productStatus: '停产', + realPerformance: '123.45', + })).toBe('非销售商品未打标') + expect(resolveProductionStopProductLabel({ + sabcRating: '', + productStatus: '', + realPerformance: '', + })).toBe('非销售商品未打标') + }) + + it('未打标的销售商品按考核周期真实业绩兜底', () => { + expect(resolveProductionStopProductLabel({ + sabcRating: '', + productStatus: '销售', + realPerformance: '0.5000', + })).toBe('有业绩未打标') + expect(resolveProductionStopProductLabel({ + sabcRating: '', + productStatus: '销售', + realPerformance: '0.0000', + })).toBe('无业绩未打标') + expect(resolveProductionStopProductLabel({ + sabcRating: '', + productStatus: '销售', + realPerformance: '', + })).toBe('无业绩未打标') + }) +}) diff --git a/furniture-3d-system/src/lib/production-stop-demands.ts b/furniture-3d-system/src/lib/production-stop-demands.ts index 98eaad6e..fb768b39 100644 --- a/furniture-3d-system/src/lib/production-stop-demands.ts +++ b/furniture-3d-system/src/lib/production-stop-demands.ts @@ -105,8 +105,12 @@ export const PRODUCTION_STOP_DEFAULT_REASON_CATEGORIES = [ '高差评低销量产品淘汰', ] as const +/** 仅展示、不参与排序的列(如图片缩略图)。 */ +export const PRODUCTION_STOP_ITEM_DISPLAY_ONLY_KEYS = ['image'] as const +export type ProductionStopItemDisplayOnlyKey = (typeof PRODUCTION_STOP_ITEM_DISPLAY_ONLY_KEYS)[number] + export type ProductionStopItemColumn = { - key: ProductionStopItemSortKey + key: ProductionStopItemSortKey | ProductionStopItemDisplayOnlyKey label: string width: number group: '需求信息' | '条码档案' | '替换信息' | '状态跟踪' @@ -148,6 +152,79 @@ export function isProductionStopItemSortKey(value: string): value is ProductionS return (PRODUCTION_STOP_ITEM_SORT_KEYS as readonly string[]).includes(value) } +/** 明细列表支持「包含」匹配的文本字段(设置里可加为快速筛选)。 */ +export const PRODUCTION_STOP_ITEM_TEXT_FILTER_KEYS = [ + 'barcode', + 'department', + 'reasonDetail', + 'replaceType', + 'replaceBarcode', + 'imageChange', + 'modificationPoint', + 'categoryL2', + 'spu', + 'sku', + 'productStatus', + 'supplierName', + 'brand', + 'productTag', + 'productLabel', + 'postStopSkuStatus', + 'sales30dWithBig', + 'sales30dWithBigSku', + 'opsDirector', + 'topSalesOpsDirector', + 'stopSuggestion', +] as const +export type ProductionStopItemTextFilterKey = + (typeof PRODUCTION_STOP_ITEM_TEXT_FILTER_KEYS)[number] + +export function isProductionStopItemTextFilterKey( + value: string, +): value is ProductionStopItemTextFilterKey { + return (PRODUCTION_STOP_ITEM_TEXT_FILTER_KEYS as readonly string[]).includes(value) +} + +/** 这些「文本筛选字段」实际存放在需求单上(不是明细表字段)。 */ +export const PRODUCTION_STOP_DEMAND_TEXT_FILTER_KEYS = ['department', 'reasonDetail'] as const + +export function isProductionStopDemandTextField(value: string) { + return (PRODUCTION_STOP_DEMAND_TEXT_FILTER_KEYS as readonly string[]).includes(value) +} + +/** 支持多选值筛选的字段(表头漏斗弹层)。 */ +export const PRODUCTION_STOP_ITEM_MULTI_FILTER_KEYS = [ + ...PRODUCTION_STOP_ITEM_TEXT_FILTER_KEYS, + 'purchaseOwner', + 'requesterName', + 'reasonCategory', + 'confirmationStatus', + 'demandStatus', +] as const +export type ProductionStopItemMultiFilterKey = + (typeof PRODUCTION_STOP_ITEM_MULTI_FILTER_KEYS)[number] + +export function isProductionStopItemMultiFilterKey( + value: string, +): value is ProductionStopItemMultiFilterKey { + return (PRODUCTION_STOP_ITEM_MULTI_FILTER_KEYS as readonly string[]).includes(value) +} + +/** 数值字段筛选:支持 >、>=、<、<=。 */ +export const PRODUCTION_STOP_ITEM_NUMERIC_FILTER_KEYS = [ + 'sales30dWithBig', + 'sales30dWithBigSku', +] as const +export type ProductionStopItemNumericFilterKey = + (typeof PRODUCTION_STOP_ITEM_NUMERIC_FILTER_KEYS)[number] +export type ProductionStopNumericFilterOperator = 'gt' | 'gte' | 'lt' | 'lte' + +export function isProductionStopItemNumericFilterKey( + value: string, +): value is ProductionStopItemNumericFilterKey { + return (PRODUCTION_STOP_ITEM_NUMERIC_FILTER_KEYS as readonly string[]).includes(value) +} + // 明细列表列口径,顺序与需求表的字段顺序一致,末尾追加采购反馈列。 export const PRODUCTION_STOP_ITEM_COLUMNS: readonly ProductionStopItemColumn[] = [ { key: 'submittedAt', label: '需求时间', width: 150, group: '需求信息' }, @@ -155,6 +232,7 @@ export const PRODUCTION_STOP_ITEM_COLUMNS: readonly ProductionStopItemColumn[] = { key: 'department', label: '需求部门', width: 120, group: '需求信息' }, { key: 'reasonCategory', label: '停产原因分类', width: 150, group: '需求信息' }, { key: 'reasonDetail', label: '停产原因详细说明', width: 180, group: '需求信息' }, + { key: 'image', label: '图片', width: 80, group: '需求信息' }, { key: 'barcode', label: '条码', width: 170, group: '需求信息' }, { key: 'replaceType', label: '替换类型', width: 100, group: '替换信息' }, { key: 'replaceBarcode', label: '替换编码', width: 170, group: '替换信息' }, @@ -180,6 +258,32 @@ export const PRODUCTION_STOP_ITEM_COLUMNS: readonly ProductionStopItemColumn[] = { key: 'demandStatus', label: '需求状态', width: 140, group: '状态跟踪' }, ] +/** + * 「产品标签」口径与产品全量档案一致:SABC 标签(S/A/B/C/D…)优先; + * 未打标时按销售状态 × 考核周期真实业绩兜底,兜底文案与档案页保持一致。 + */ +export const PRODUCTION_STOP_PRODUCT_LABELS = { + nonSales: '非销售商品未打标', + hasPerformance: '有业绩未打标', + noPerformance: '无业绩未打标', +} as const + +export function resolveProductionStopProductLabel(input: { + /** 档案库「SABC 打标」值,未打标传空。 */ + sabcRating: string + productStatus: string + /** 档案库同步的最新考核周期真实业绩,未匹配到传空。 */ + realPerformance: string +}) { + const rating = input.sabcRating.trim() + if (rating) return rating + if (input.productStatus.trim() !== '销售') return PRODUCTION_STOP_PRODUCT_LABELS.nonSales + const performance = Number(input.realPerformance) + return Number.isFinite(performance) && performance > 0 + ? PRODUCTION_STOP_PRODUCT_LABELS.hasPerformance + : PRODUCTION_STOP_PRODUCT_LABELS.noPerformance +} + export type ProductionStopItemRow = { id: string demandId: string @@ -202,6 +306,7 @@ export type ProductionStopItemRow = { brand: string productTag: string productLabel: string + image: string postStopSkuStatus: string sales30dWithBig: string sales30dWithBigSku: string @@ -222,6 +327,23 @@ export type ProductionStopItemRow = { demandStatusNote: string } +export const PRODUCTION_STOP_ANALYTICS_GRANULARITIES = ['day', 'week', 'month'] as const +export type ProductionStopAnalyticsGranularity = + (typeof PRODUCTION_STOP_ANALYTICS_GRANULARITIES)[number] + +export type ProductionStopAnalytics = { + granularity: ProductionStopAnalyticsGranularity + from: string + to: string + total: number + series: Array<{ bucket: string; count: number }> + byDepartment: Array<{ key: string; count: number }> + byRequester: Array<{ key: string; count: number }> + byReasonCategory: Array<{ key: string; count: number }> + byDemandStatus: Array<{ status: ProductionStopDemandStatus; count: number }> + byConfirmationStatus: Array<{ status: ProductionStopConfirmationStatus; count: number }> +} + export type ProductionStopItemFilters = { keyword?: string confirmationStatus?: ProductionStopConfirmationStatus @@ -232,6 +354,20 @@ export type ProductionStopItemFilters = { requesterName?: string dateFrom?: string dateTo?: string + /** 通用字段「包含」筛选(品牌、产品标签、运营负责人等)。 */ + fieldFilters?: Partial> + /** 条码创建时间(按天匹配)。 */ + barcodeCreatedAt?: string + /** 条码创建时间区间(按天)。 */ + barcodeCreatedFrom?: string + barcodeCreatedTo?: string + /** 数值字段比较筛选(含大单日销等)。 */ + numericFilters?: Partial> + /** 多选值筛选(表头漏斗弹层勾选,含空白值空字符串)。 */ + multiFilters?: Partial> sortKey?: ProductionStopItemSortKey sortDirection?: 'asc' | 'desc' page: number @@ -323,3 +459,22 @@ export function parseBarcodeInput(value: string): string[] { } return result } + +/** + * 批量搜索词解析:支持换行、逗号、分号、顿号与空格分隔,去重后最多 100 个词。 + * 用于关键词搜索框一次粘贴多个条码/SPU/SKU。 + */ +export function parseProductionStopSearchTerms(value: string): string[] { + const seen = new Set() + const terms: string[] = [] + for (const raw of String(value ?? '').split(/[\s,,;;、]+/)) { + const term = raw.trim() + if (!term) continue + const key = term.toUpperCase() + if (seen.has(key)) continue + seen.add(key) + terms.push(term.slice(0, 120)) + if (terms.length >= 100) break + } + return terms +} diff --git a/furniture-3d-system/src/lib/production-stop-notifications.test.ts b/furniture-3d-system/src/lib/production-stop-notifications.test.ts new file mode 100644 index 00000000..f8b65de3 --- /dev/null +++ b/furniture-3d-system/src/lib/production-stop-notifications.test.ts @@ -0,0 +1,65 @@ +import { + buildProductionStopConfirmationPendingMessage, + buildProductionStopFlowPendingMessage, + buildProductionStopPendingCompletedMessage, + buildProductionStopRejectedMessage, +} from './production-stop-notifications' + +const linkUrl = 'https://cp.jiansun.vip/supply-chain/management/production-stop-demands' + +describe('停产需求通知中心消息口径', () => { + it('停产确认通知包含待办条数与执行要求', () => { + expect(buildProductionStopConfirmationPendingMessage(3)).toEqual({ + title: '是否同意停产确认', + content: [ + '**待办条数:** 3 条', + '', + '**执行需求:** 当天15点之前提交的需求,当天16点前完成确认,15点之后提交的次日16点前完成确认,超时未确认默认同意停产', + ].join('\n'), + }) + }) + + it('待走流程通知只同步待走流程条数', () => { + expect(buildProductionStopFlowPendingMessage(2)).toEqual({ + title: '待走停产流程', + content: '**待办条数:** 2 条', + }) + }) + + it('需求驳回通知包含供应商、条码与驳回原因', () => { + expect(buildProductionStopRejectedMessage({ + supplierName: '供应商A', + barcode: 'A1', + reason: '张三:库存仍有 300 件', + })).toEqual({ + title: '需求驳回同频', + content: [ + '**供应商:** 供应商A', + '**条码:** A1', + '**驳回原因:** 张三:库存仍有 300 件', + ].join('\n'), + }) + }) + + it('待办清零时生成完成消息', () => { + expect(buildProductionStopPendingCompletedMessage('CONFIRM_PENDING')).toEqual({ + title: '是否同意停产确认', + content: '**待办条数:** 0 条(已全部处理完成)', + }) + expect(buildProductionStopPendingCompletedMessage('FLOW_PENDING').title) + .toBe('待走停产流程') + }) + + it('正文追加可直接点击的处理链接(三类通知与完成消息都带)', () => { + const linkLine = `**处理链接:** [点击进入停产需求管理](${linkUrl})` + expect(buildProductionStopConfirmationPendingMessage(1, linkUrl).content).toContain(linkLine) + expect(buildProductionStopFlowPendingMessage(1, linkUrl).content).toContain(linkLine) + expect(buildProductionStopRejectedMessage({ + supplierName: '供应商A', + barcode: 'A1', + reason: '库存仍有 300 件', + }, linkUrl).content).toContain(linkLine) + expect(buildProductionStopPendingCompletedMessage('FLOW_PENDING', linkUrl).content) + .toContain(linkLine) + }) +}) diff --git a/furniture-3d-system/src/lib/production-stop-notifications.ts b/furniture-3d-system/src/lib/production-stop-notifications.ts new file mode 100644 index 00000000..508dacd3 --- /dev/null +++ b/furniture-3d-system/src/lib/production-stop-notifications.ts @@ -0,0 +1,70 @@ +// 停产需求管理 → 统一通知中心的三类消息口径。 +// 通知中心会在正文顶部自动加「【项目名称】」抬头,这里只写业务标题和正文。 + +export const PRODUCTION_STOP_NOTIFICATION_TITLES = { + // 通知中心会自动加「【项目名称】」前缀,这里只写业务标题,避免重复。 + confirmPending: '是否同意停产确认', + flowPending: '待走停产流程', + rejected: '需求驳回同频', +} as const + +export const PRODUCTION_STOP_CONFIRMATION_REQUIREMENT = + '当天15点之前提交的需求,当天16点前完成确认,15点之后提交的次日16点前完成确认,超时未确认默认同意停产' + +export type ProductionStopPendingNotificationKind = 'CONFIRM_PENDING' | 'FLOW_PENDING' + +/** 正文末尾追加可直接点击的处理链接(钉钉 Markdown 链接)。 */ +export function appendProductionStopActionLink(content: string, linkUrl?: string) { + const url = String(linkUrl ?? '').trim() + if (!url) return content + return `${content}\n\n**处理链接:** [点击进入停产需求管理](${url})` +} + +export function buildProductionStopConfirmationPendingMessage(pendingCount: number, linkUrl?: string) { + return { + title: PRODUCTION_STOP_NOTIFICATION_TITLES.confirmPending, + content: appendProductionStopActionLink([ + `**待办条数:** ${Math.max(0, pendingCount)} 条`, + '', + `**执行需求:** ${PRODUCTION_STOP_CONFIRMATION_REQUIREMENT}`, + ].join('\n'), linkUrl), + } +} + +export function buildProductionStopFlowPendingMessage(pendingCount: number, linkUrl?: string) { + return { + title: PRODUCTION_STOP_NOTIFICATION_TITLES.flowPending, + content: appendProductionStopActionLink( + `**待办条数:** ${Math.max(0, pendingCount)} 条`, + linkUrl, + ), + } +} + +export function buildProductionStopRejectedMessage(input: { + supplierName: string + barcode: string + reason: string +}, linkUrl?: string) { + return { + title: PRODUCTION_STOP_NOTIFICATION_TITLES.rejected, + content: appendProductionStopActionLink([ + `**供应商:** ${input.supplierName.trim() || '-'}`, + `**条码:** ${input.barcode.trim() || '-'}`, + `**驳回原因:** ${input.reason.trim() || '-'}`, + ].join('\n'), linkUrl), + } +} + +/** 待办清零时推送一次「已全部处理完成」,用于替代通知中心无法直接“勾选完成”的待办状态。 */ +export function buildProductionStopPendingCompletedMessage( + kind: ProductionStopPendingNotificationKind, + linkUrl?: string, +) { + return { + title: kind === 'CONFIRM_PENDING' + ? PRODUCTION_STOP_NOTIFICATION_TITLES.confirmPending + : PRODUCTION_STOP_NOTIFICATION_TITLES.flowPending, + content: appendProductionStopActionLink('**待办条数:** 0 条(已全部处理完成)', linkUrl), + } +} diff --git a/furniture-3d-system/src/server/integrations/notification-center/client.ts b/furniture-3d-system/src/server/integrations/notification-center/client.ts index 879cf563..b92b6872 100644 --- a/furniture-3d-system/src/server/integrations/notification-center/client.ts +++ b/furniture-3d-system/src/server/integrations/notification-center/client.ts @@ -123,7 +123,8 @@ export class NotificationCenterClient { send(input: NotificationCenterSendInput) { const payload: NotificationCenterSendInput = { idempotencyKey: input.idempotencyKey, - recipientUserId: input.recipientUserId, + ...(input.recipientUserId ? { recipientUserId: input.recipientUserId } : {}), + ...(input.recipientAlias ? { recipientAlias: input.recipientAlias } : {}), title: input.title, content: input.content, ...(input.actionTitle !== undefined ? { actionTitle: input.actionTitle } : {}), diff --git a/furniture-3d-system/src/server/integrations/notification-center/types.ts b/furniture-3d-system/src/server/integrations/notification-center/types.ts index 01020c19..a0bbe5aa 100644 --- a/furniture-3d-system/src/server/integrations/notification-center/types.ts +++ b/furniture-3d-system/src/server/integrations/notification-center/types.ts @@ -28,7 +28,10 @@ export type NotificationCenterDelivery = { export type NotificationCenterSendInput = { idempotencyKey: string - recipientUserId: string + /** 钉钉企业内部 userId(与 recipientAlias 二选一)。 */ + recipientUserId?: string + /** 由通知中心调用素材平台花名册精确解析的准确花名(与 recipientUserId 二选一)。 */ + recipientAlias?: string title: string content: string actionTitle?: string diff --git a/furniture-3d-system/src/server/repositories/production-stop-demands.repository.ts b/furniture-3d-system/src/server/repositories/production-stop-demands.repository.ts index dee40196..29f04b22 100644 --- a/furniture-3d-system/src/server/repositories/production-stop-demands.repository.ts +++ b/furniture-3d-system/src/server/repositories/production-stop-demands.repository.ts @@ -1,11 +1,19 @@ +import { Prisma } from '@prisma/client' import type { - Prisma, ProductionStopConfirmationApprover as PrismaConfirmationApprover, ProductionStopDemandConfirmationVote as PrismaConfirmationVote, ProductionStopFlowOwner as PrismaFlowOwner, } from '@prisma/client' import type { QueryResult, QueryResultRow } from 'pg' import { prisma } from '@/lib/prisma' +import { + PRODUCTION_STOP_ITEM_MULTI_FILTER_KEYS, + PRODUCTION_STOP_ITEM_NUMERIC_FILTER_KEYS, + PRODUCTION_STOP_ITEM_TEXT_FILTER_KEYS, + isProductionStopDemandTextField, + parseProductionStopSearchTerms, + isProductionStopItemTextFilterKey, +} from '@/lib/production-stop-demands' import type { ProductionStopConfirmationApproverOption, ProductionStopConfirmationDecision, @@ -21,6 +29,7 @@ import type { import { getSupplyChainArchiveReadPool } from '@/server/repositories/supply-chain-archive-read-pool' const ARCHIVE_TABLE = 'public."SupplyChainProductArchive"' +const ARCHIVE_PERFORMANCE_TABLE = 'public."SupplyChainProductArchiveSalesPerformance"' const DEMAND_NUMBER_LOCK_KEY = 2026093001 const ARCHIVE_CONNECTION_ERROR_CODES = new Set([ @@ -75,6 +84,14 @@ export type ProductionStopArchiveProfile = { productTag: string businessCreatedAt: Date | null opsDirector: string + /** 含大单近30日日销(条码维度):档案库该条码行的值。 */ + sales30dWithBig: string + /** SABC 打标值(S/A/B/C/D…):「产品标签」的主口径,未打标为空。 */ + sabcRating: string + /** 档案库同步的考核周期真实业绩:「产品标签」未打标兜底的判定依据,未匹配传空。 */ + sabcRealPerformance: string + /** 条码对应图片地址(档案库「图片」列)。 */ + image: string } export type ProductionStopDemandCreateInput = { @@ -99,10 +116,13 @@ export type ProductionStopDemandCreateInput = { brand: string productTag: string productLabel: string + image: string barcodeCreatedAt: Date | null postStopSkuStatus: string sales30dWithBig: string sales30dWithBigSku: string + sales30dWithBigValue: string | null + sales30dWithBigSkuValue: string | null opsDirector: string topSalesOpsDirector: string stopSuggestion: string @@ -151,6 +171,7 @@ const ITEM_SELECT = { brand: true, productTag: true, productLabel: true, + image: true, barcodeCreatedAt: true, postStopSkuStatus: true, sales30dWithBig: true, @@ -216,6 +237,7 @@ function toItemRow(item: ItemWithDemand): ProductionStopItemRow { brand: text(item.brand), productTag: text(item.productTag), productLabel: text(item.productLabel), + image: text(item.image), postStopSkuStatus: text(item.postStopSkuStatus), sales30dWithBig: text(item.sales30dWithBig), sales30dWithBigSku: text(item.sales30dWithBigSku), @@ -340,8 +362,8 @@ const ITEM_SORT_FIELDS: Partial ({ + OR: [ + { barcode: { contains: term, mode: 'insensitive' } }, + { spu: { contains: term, mode: 'insensitive' } }, + { sku: { contains: term, mode: 'insensitive' } }, + { productName: { contains: term, mode: 'insensitive' } }, + { supplierName: { contains: term, mode: 'insensitive' } }, + { purchaseOwner: { contains: term, mode: 'insensitive' } }, + { replaceBarcode: { contains: term, mode: 'insensitive' } }, + { demand: { is: { demandNo: { contains: term, mode: 'insensitive' } } } }, + { demand: { is: { requesterName: { contains: term, mode: 'insensitive' } } } }, + ], + })), }) } if (filters.confirmationStatus) { @@ -384,7 +408,13 @@ function buildWhere(filters: ProductionStopItemFilters): Prisma.ProductionStopDe } else if (filters.demandStatus) { conditions.push({ demand: { is: { demandStatus: filters.demandStatus } } }) } - if (filters.reasonCategory) conditions.push({ demand: { is: { reasonCategory: filters.reasonCategory } } }) + if (filters.reasonCategory) { + conditions.push({ + demand: { + is: { reasonCategory: { contains: filters.reasonCategory, mode: 'insensitive' } }, + }, + }) + } if (filters.purchaseOwner) { conditions.push({ purchaseOwner: { contains: filters.purchaseOwner, mode: 'insensitive' } }) } @@ -393,6 +423,60 @@ function buildWhere(filters: ProductionStopItemFilters): Prisma.ProductionStopDe demand: { is: { requesterName: { contains: filters.requesterName, mode: 'insensitive' } } }, }) } + for (const key of PRODUCTION_STOP_ITEM_TEXT_FILTER_KEYS) { + const value = filters.fieldFilters?.[key]?.trim() + if (!value) continue + if (isProductionStopDemandTextField(key)) { + conditions.push({ + demand: { is: { [key]: { contains: value, mode: 'insensitive' } } }, + } as Prisma.ProductionStopDemandItemWhereInput) + } else { + conditions.push({ + [key]: { contains: value, mode: 'insensitive' }, + } as Prisma.ProductionStopDemandItemWhereInput) + } + } + if (filters.barcodeCreatedAt) { + const start = new Date(`${filters.barcodeCreatedAt}T00:00:00+08:00`) + const end = new Date(`${filters.barcodeCreatedAt}T23:59:59.999+08:00`) + if (!Number.isNaN(start.getTime()) && !Number.isNaN(end.getTime())) { + conditions.push({ barcodeCreatedAt: { gte: start, lte: end } }) + } + } + if (filters.barcodeCreatedFrom || filters.barcodeCreatedTo) { + const range: { gte?: Date; lte?: Date } = {} + if (filters.barcodeCreatedFrom) { + range.gte = new Date(`${filters.barcodeCreatedFrom}T00:00:00+08:00`) + } + if (filters.barcodeCreatedTo) { + range.lte = new Date(`${filters.barcodeCreatedTo}T23:59:59.999+08:00`) + } + conditions.push({ barcodeCreatedAt: range }) + } + for (const key of PRODUCTION_STOP_ITEM_NUMERIC_FILTER_KEYS) { + const filter = filters.numericFilters?.[key] + if (!filter || !Number.isFinite(filter.value)) continue + const numericField = `${key}Value` as const + conditions.push({ + [numericField]: { [filter.op]: filter.value }, + } as Prisma.ProductionStopDemandItemWhereInput) + } + for (const key of PRODUCTION_STOP_ITEM_MULTI_FILTER_KEYS) { + const values = filters.multiFilters?.[key] + if (!values?.length) continue + const clean = values.map((value) => String(value)).slice(0, 100) + if ( + key === 'requesterName' + || key === 'reasonCategory' + || isProductionStopDemandTextField(key) + || key === 'confirmationStatus' + || key === 'demandStatus' + ) { + conditions.push({ demand: { is: { [key]: { in: clean } } } }) + } else { + conditions.push({ [key]: { in: clean } } as Prisma.ProductionStopDemandItemWhereInput) + } + } if (filters.dateFrom || filters.dateTo) { // 需求时间按北京时间区间换算成 UTC 再比较(存储口径为 UTC)。 const range: { gte?: Date; lte?: Date } = {} @@ -411,6 +495,23 @@ export interface ProductionStopDemandRepository { purchaseOwners: string[] requesterNames: string[] }> + getDemandAnalytics(input: { + from: Date + to: Date + granularity: 'day' | 'week' | 'month' + filters?: Omit< + ProductionStopItemFilters, + 'page' | 'pageSize' | 'sortKey' | 'sortDirection' | 'dateFrom' | 'dateTo' + > + }): Promise<{ + total: number + series: Array<{ bucket: string; count: number }> + byDepartment: Array<{ key: string; count: number }> + byRequester: Array<{ key: string; count: number }> + byReasonCategory: Array<{ key: string; count: number }> + byDemandStatus: Array<{ status: string; count: number }> + byConfirmationStatus: Array<{ status: string; count: number }> + }> findArchivedProfiles(barcodes: string[]): Promise> findExistingArchiveCodes(codes: string[]): Promise> findSuggestionsByBarcodes(barcodes: string[]): Promise> @@ -454,6 +555,8 @@ export interface ProductionStopDemandRepository { }>> deleteSuggestionConfig(id: string): Promise listSkuSalesBarcodes(skus: string[]): Promise> + sumSkuSales30dWithBig(skus: string[]): Promise> + listDistinctFilterValues(field: string, limit?: number): Promise listActiveProcessBarcodes(barcodes: string[]): Promise> nextDemandNo(now: Date): Promise createDemand(input: ProductionStopDemandCreateInput): Promise<{ id: string; demandNo: string }> @@ -469,6 +572,31 @@ export interface ProductionStopDemandRepository { }): Promise<{ id: string; demandNo: string; reasonCategory: string; reasonDetail: string } | null> findDemandById(demandId: string): Promise listOverduePendingDemands(now: Date, limit: number): Promise + listPendingConfirmationDemands(): Promise> + listPendingFlowDemands(): Promise> + findRejectionNotice(demandId: string): Promise<{ + id: string + demandNo: string + requesterId: string + requesterName: string + reasons: string + items: Array<{ barcode: string; supplierName: string }> + } | null> + getNotificationSync(kind: string, recipientKey: string): Promise<{ + pendingCount: number + snapshot: string + completedAt: Date | null + } | null> + findUserDingtalkIds(userIds: string[]): Promise> + upsertNotificationSync(input: { + kind: string + recipientKey: string + recipientName: string + pendingCount: number + snapshot: string + lastSentAt: Date | null + completedAt: Date | null + }): Promise listConfirmationVotes(demandId: string): Promise upsertConfirmationVote(input: { demandId: string @@ -585,6 +713,91 @@ class ProductionStopDemandRepositoryClass implements ProductionStopDemandReposit } } + /** 领导视图统计:时间趋势 + 部门/需求人/原因/状态分布。 */ + async getDemandAnalytics(input: { + from: Date + to: Date + granularity: 'day' | 'week' | 'month' + filters?: Omit< + ProductionStopItemFilters, + 'page' | 'pageSize' | 'sortKey' | 'sortDirection' | 'dateFrom' | 'dateTo' + > + }) { + const itemWhere = buildWhere({ + ...input.filters, + page: 1, + pageSize: 1, + }) + const where: Prisma.ProductionStopDemandWhereInput = { + submittedAt: { gte: input.from, lte: input.to }, + ...(Object.keys(itemWhere).length ? { items: { some: itemWhere } } : {}), + } + const [total, departmentRows, requesterRows, reasonRows, demandStatusRows, confirmationRows, series] = + await Promise.all([ + prisma.productionStopDemand.count({ where }), + prisma.productionStopDemand.groupBy({ + by: ['department'], + where, + _count: { _all: true }, + }), + prisma.productionStopDemand.groupBy({ + by: ['requesterName'], + where, + _count: { _all: true }, + }), + prisma.productionStopDemand.groupBy({ + by: ['reasonCategory'], + where, + _count: { _all: true }, + }), + prisma.productionStopDemand.groupBy({ + by: ['demandStatus'], + where, + _count: { _all: true }, + }), + prisma.productionStopDemand.groupBy({ + by: ['confirmationStatus'], + where, + _count: { _all: true }, + }), + prisma.$queryRaw>(Prisma.sql` + SELECT to_char( + date_trunc(${input.granularity}, "submittedAt" + interval '8 hours'), + 'YYYY-MM-DD' + ) AS "bucket", + COUNT(*)::int AS "count" + FROM "ProductionStopDemand" + WHERE "submittedAt" >= ${input.from} AND "submittedAt" <= ${input.to} + GROUP BY 1 + ORDER BY 1 + `), + ]) + + const toDistribution = (rows: Array>, key: string) => + rows + .map((row) => ({ + key: text(row[key] as string | null | undefined) || '未填写', + count: Number((row._count as { _all?: number } | undefined)?._all ?? 0), + })) + .sort((a, b) => b.count - a.count) + + return { + total, + series: series.map((row) => ({ bucket: text(row.bucket), count: Number(row.count) })), + byDepartment: toDistribution(departmentRows as Array>, 'department'), + byRequester: toDistribution(requesterRows as Array>, 'requesterName'), + byReasonCategory: toDistribution(reasonRows as Array>, 'reasonCategory'), + byDemandStatus: demandStatusRows.map((row) => ({ + status: text(row.demandStatus), + count: row._count._all, + })), + byConfirmationStatus: confirmationRows.map((row) => ({ + status: text(row.confirmationStatus), + count: row._count._all, + })), + } + } + async findArchivedProfiles(barcodes: string[]) { const result = new Map() const targets = [...new Set(barcodes.map((barcode) => barcode.trim().toUpperCase()).filter(Boolean))] @@ -602,13 +815,19 @@ class ProductionStopDemandRepositoryClass implements ProductionStopDemandReposit brand: string | null productTag: string | null opsDirector: string | null + sales30dWithBig: string | null + sabcRating: string | null + sabcRealPerformance: string | null + image: string | null businessCreatedAt: Date | null }>( - `SELECT "barcode", "categoryL1", "categoryL2", "spu", "sku", "productName", - "productStatus", "supplierName", "purchaseOwner", "brand", "productTag", "businessCreatedAt" - , "opsDirector" - FROM ${ARCHIVE_TABLE} - WHERE UPPER("barcode") = ANY($1::text[])`, + `SELECT a."barcode", a."categoryL1", a."categoryL2", a."spu", a."sku", a."productName", + a."productStatus", a."supplierName", a."purchaseOwner", a."brand", a."productTag", + a."businessCreatedAt", a."opsDirector", a."sales30dWithBig", a."sabcRating", a."image", + p."realPerformance"::text AS "sabcRealPerformance" + FROM ${ARCHIVE_TABLE} AS a + LEFT JOIN ${ARCHIVE_PERFORMANCE_TABLE} AS p ON p."barcode" = a."barcode" + WHERE UPPER(a."barcode") = ANY($1::text[])`, [targets], ) for (const row of rows.rows) { @@ -628,6 +847,10 @@ class ProductionStopDemandRepositoryClass implements ProductionStopDemandReposit productTag: text(row.productTag), businessCreatedAt: row.businessCreatedAt ?? null, opsDirector: text(row.opsDirector), + sales30dWithBig: text(row.sales30dWithBig), + sabcRating: text(row.sabcRating), + sabcRealPerformance: text(row.sabcRealPerformance), + image: text(row.image), }) } return result @@ -848,6 +1071,50 @@ class ProductionStopDemandRepositoryClass implements ProductionStopDemandReposit return result } + /** 表头漏斗筛选项:按字段取去重后的可选值(最多 500 个)。 */ + async listDistinctFilterValues(field: string, limit = 200) { + const safeLimit = Math.min(Math.max(1, Number(limit) || 200), 500) + const isDemandField = field === 'requesterName' + || field === 'reasonCategory' + || isProductionStopDemandTextField(field) + if (!isDemandField && !isProductionStopItemTextFilterKey(field)) return [] + const table = isDemandField ? 'ProductionStopDemand' : 'ProductionStopDemandItem' + const column = Prisma.raw(`"${field}"`) + const rows = await prisma.$queryRaw>(Prisma.sql` + SELECT DISTINCT ${column} AS "value" + FROM ${Prisma.raw(`"${table}"`)} + WHERE ${column} IS NOT NULL AND ${column} <> '' + ORDER BY "value" ASC + LIMIT ${safeLimit} + `) + return rows.map((row) => text(row.value)).filter(Boolean) + } + + /** + * 含大单近30日日销(SKU 维度):按货号汇总档案库中同 SKU 全部条码的「含大单近30日日销」。 + * 需求口径:条码维度取该条码行自身的值,SKU 维度取同 SKU 求和。 + */ + async sumSkuSales30dWithBig(skus: string[]) { + const result = new Map() + const targets = [...new Set(skus.map((sku) => sku.trim().toUpperCase()).filter(Boolean))] + if (!targets.length) return result + const rows = await runArchiveQuery<{ sku: string | null; total: string | null }>( + `SELECT UPPER("sku") AS sku, + SUM(CASE WHEN "sales30dWithBig" ~ '^-?[0-9]+(\\.[0-9]+)?$' THEN "sales30dWithBig"::numeric ELSE 0 END) AS total + FROM ${ARCHIVE_TABLE} + WHERE UPPER("sku") = ANY($1::text[]) + GROUP BY UPPER("sku")`, + [targets], + ) + for (const row of rows.rows) { + const key = text(row.sku).toUpperCase() + if (!key) continue + const total = Number(row.total ?? '0') + result.set(key, Number.isFinite(total) ? total.toFixed(2) : '0.00') + } + return result + } + /** 已提交且未被驳回的条码(用于「已在停产流程中」提醒,当前仅提示不拦截)。 */ async listActiveProcessBarcodes(barcodes: string[]) { const result = new Map() @@ -965,6 +1232,97 @@ class ProductionStopDemandRepositoryClass implements ProductionStopDemandReposit return rows.map(toConfirmationDemandRecord) } + /** 待确认需求(通知中心「是否同意停产确认」待办条数口径)。 */ + async listPendingConfirmationDemands() { + return prisma.productionStopDemand.findMany({ + where: { confirmationStatus: 'PENDING' }, + select: { id: true, demandNo: true }, + orderBy: { submittedAt: 'asc' }, + }) + } + + /** 已确认待走流程需求(通知中心「待走停产流程」待办条数口径)。 */ + async listPendingFlowDemands() { + return prisma.productionStopDemand.findMany({ + where: { demandStatus: 'CONFIRMED_WAIT_FLOW' }, + select: { id: true, demandNo: true }, + orderBy: { submittedAt: 'asc' }, + }) + } + + /** 需求驳回通知所需的供应商、条码与驳回原因。 */ + async findRejectionNotice(demandId: string) { + const row = await prisma.productionStopDemand.findUnique({ + where: { id: demandId }, + select: { + id: true, + demandNo: true, + requesterId: true, + requesterName: true, + confirmationReasons: true, + items: { select: { barcode: true, supplierName: true }, orderBy: { createdAt: 'asc' } }, + }, + }) + if (!row) return null + return { + id: row.id, + demandNo: row.demandNo, + requesterId: row.requesterId, + requesterName: row.requesterName, + reasons: row.confirmationReasons, + items: row.items.map((item) => ({ + barcode: text(item.barcode), + supplierName: text(item.supplierName), + })), + } + } + + async getNotificationSync(kind: string, recipientKey: string) { + const row = await prisma.productionStopNotificationSync.findUnique({ + where: { kind_recipientKey: { kind, recipientKey } }, + select: { pendingCount: true, snapshot: true, completedAt: true }, + }) + return row ?? null + } + + async upsertNotificationSync(input: { + kind: string + recipientKey: string + recipientName: string + pendingCount: number + snapshot: string + lastSentAt: Date | null + completedAt: Date | null + }) { + await prisma.productionStopNotificationSync.upsert({ + where: { kind_recipientKey: { kind: input.kind, recipientKey: input.recipientKey } }, + create: input, + update: { + recipientName: input.recipientName, + pendingCount: input.pendingCount, + snapshot: input.snapshot, + lastSentAt: input.lastSentAt, + completedAt: input.completedAt, + }, + }) + } + + /** 已绑定钉钉的账号 → dingtalkUserId(通知中心优先使用 userId 收件)。 */ + async findUserDingtalkIds(userIds: string[]) { + const targets = [...new Set(userIds.map((value) => value.trim()).filter(Boolean))] + const result = new Map() + if (!targets.length) return result + const rows = await prisma.user.findMany({ + where: { id: { in: targets } }, + select: { id: true, dingtalkUserId: true }, + }) + for (const row of rows) { + const userId = text(row.dingtalkUserId) + if (userId) result.set(row.id, userId) + } + return result + } + async listConfirmationVotes(demandId: string) { const rows = await prisma.productionStopDemandConfirmationVote.findMany({ where: { demandId }, diff --git a/furniture-3d-system/src/server/services/production-stop-demands.service.test.ts b/furniture-3d-system/src/server/services/production-stop-demands.service.test.ts index 8df66a2d..1ec3cbb2 100644 --- a/furniture-3d-system/src/server/services/production-stop-demands.service.test.ts +++ b/furniture-3d-system/src/server/services/production-stop-demands.service.test.ts @@ -6,6 +6,7 @@ import { } from '@/server/repositories/production-stop-demands.repository' import { dingtalkTodoService } from '@/server/services/dingtalk-todo.service' import { operationLogService } from '@/server/services/operation-log.service' +import { productionStopNotificationsService } from '@/server/services/production-stop-notifications.service' import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' import { productionStopDemandsService } from './production-stop-demands.service' @@ -17,6 +18,15 @@ jest.mock('@/server/services/dingtalk-todo.service', () => ({ dingtalkTodoService: { isEnabled: jest.fn(() => false), createTask: jest.fn() }, })) +jest.mock('@/server/services/production-stop-notifications.service', () => ({ + productionStopNotificationsService: { + isEnabled: jest.fn(() => false), + refreshAll: jest.fn(async () => ({ sent: 0, completed: 0, failed: 0 })), + refreshAllThrottled: jest.fn(async () => undefined), + notifyRejected: jest.fn(async () => false), + }, +})) + jest.mock('@/server/services/supply-chain-permission-settings.service', () => ({ supplyChainPermissionSettingsService: { getUserAccess: jest.fn(async () => ({ view: true, download: true, edit: true })), @@ -45,6 +55,7 @@ jest.mock('@/server/repositories/production-stop-demands.repository', () => { upsertSuggestionConfigs: jest.fn(), deleteSuggestionConfig: jest.fn(), listSkuSalesBarcodes: jest.fn(), + sumSkuSales30dWithBig: jest.fn(), listActiveProcessBarcodes: jest.fn(), nextDemandNo: jest.fn(), createDemand: jest.fn(), @@ -83,6 +94,12 @@ const todoService = dingtalkTodoService as unknown as { isEnabled: jest.Mock createTask: jest.Mock } +const notificationsService = productionStopNotificationsService as unknown as { + isEnabled: jest.Mock + refreshAll: jest.Mock + refreshAllThrottled: jest.Mock + notifyRejected: jest.Mock +} const permissionService = supplyChainPermissionSettingsService as unknown as { getUserAccess: jest.Mock } @@ -93,6 +110,10 @@ function archiveProfile(overrides: Partial<{ barcode: string sku: string productStatus: string + sales30dWithBig: string + sabcRating: string + sabcRealPerformance: string + image: string }> = {}) { return { barcode: 'A1', @@ -107,6 +128,10 @@ function archiveProfile(overrides: Partial<{ brand: '品牌1', productTag: '仓库-崔桥工厂仓', businessCreatedAt: new Date('2026-01-01T00:00:00.000Z'), + sales30dWithBig: '0.00', + sabcRating: '', + sabcRealPerformance: '', + image: '', ...overrides, } } @@ -196,6 +221,7 @@ function itemRowFixture(overrides: Partial = {}): Product brand: '', productTag: '', productLabel: '', + image: '', postStopSkuStatus: '', sales30dWithBig: '', sales30dWithBigSku: '', @@ -223,6 +249,7 @@ beforeEach(() => { repo.findOwnerDepartmentByName.mockResolvedValue('') repo.listActiveProcessBarcodes.mockResolvedValue(new Map()) repo.listSkuSalesBarcodes.mockResolvedValue(new Map()) + repo.sumSkuSales30dWithBig.mockResolvedValue(new Map()) repo.nextDemandNo.mockResolvedValue('TC20260930-001') repo.listConfirmationVotes.mockResolvedValue([]) repo.listActiveApprovers.mockResolvedValue([]) @@ -230,6 +257,9 @@ beforeEach(() => { repo.findApproverByUserId.mockResolvedValue(null) repo.findExistingArchiveCodes.mockResolvedValue(new Set()) repo.findSuggestionsByBarcodes.mockResolvedValue(new Map()) + repo.updateDemandConfirmation.mockResolvedValue(undefined) + notificationsService.isEnabled.mockReturnValue(false) + notificationsService.refreshAll.mockResolvedValue({ sent: 0, completed: 0, failed: 0 }) todoService.isEnabled.mockReturnValue(false) permissionService.getUserAccess.mockResolvedValue({ view: true, download: true, edit: true }) }) @@ -251,11 +281,16 @@ describe('停产需求:创建', () => { it('只写入销售条码,非销售条码返回异常,并在流程中条码给出提醒', async () => { repo.findReasonCategoryByName.mockResolvedValue({ id: 'c1', name: '滞销款', isActive: true }) repo.findArchivedProfiles.mockResolvedValue(new Map([ - ['A1', archiveProfile()], + ['A1', archiveProfile({ + sales30dWithBig: '5.00', + sabcRating: 'B', + image: 'https://static.jiansun.vip:3333/Resource/SkuImg/A1.jpg', + })], ['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.sumSkuSales30dWithBig.mockResolvedValue(new Map([['SKU1', '12.34']])) repo.findOwnerDepartmentByName.mockResolvedValue('品类一部') repo.nextDemandNo.mockResolvedValue('TC20260930-002') repo.createDemand.mockResolvedValue({ id: 'demand1', demandNo: 'TC20260930-002' }) @@ -284,9 +319,60 @@ describe('停产需求:创建', () => { productStatus: '销售', purchaseOwner: '采购1', postStopSkuStatus: '销售', + sales30dWithBig: '5.00', + sales30dWithBigSku: '12.34', + sales30dWithBigValue: '5.00', + sales30dWithBigSkuValue: '12.34', + productLabel: 'B', + image: 'https://static.jiansun.vip:3333/Resource/SkuImg/A1.jpg', }), ], })) + expect(repo.sumSkuSales30dWithBig).toHaveBeenCalledWith(['SKU1']) + }) + + it('产品标签按档案口径写入:SABC 标签优先,未打标兜底到业绩/无业绩', async () => { + repo.findReasonCategoryByName.mockResolvedValue({ id: 'c1', name: '滞销款', isActive: true }) + repo.findArchivedProfiles.mockResolvedValue(new Map([ + ['A1', archiveProfile({ sabcRating: 'A' })], + ['C3', archiveProfile({ + barcode: 'C3', + sku: 'SKU3', + sales30dWithBig: '1.00', + sabcRealPerformance: '66.6000', + })], + ['D4', archiveProfile({ barcode: 'D4', sku: 'SKU4' })], + ])) + repo.createDemand.mockResolvedValue({ id: 'demand1', demandNo: 'TC20260930-001' }) + + await productionStopDemandsService.createDemand( + { reasonCategory: '滞销款', barcodesText: 'A1\nC3\nD4' }, + user, + ) + + const labels = repo.createDemand.mock.calls.map(([input]) => input.items[0].productLabel) + expect(labels).toEqual(['A', '有业绩未打标', '无业绩未打标']) + }) + + it('启用通知中心后提单走汇总推送,不再调用钉钉待办', async () => { + notificationsService.isEnabled.mockReturnValue(true) + notificationsService.refreshAll.mockResolvedValue({ sent: 1, completed: 0, failed: 0 }) + todoService.isEnabled.mockReturnValue(true) + repo.findReasonCategoryByName.mockResolvedValue({ id: 'c1', name: '滞销款', isActive: true }) + repo.findArchivedProfiles.mockResolvedValue(new Map([['A1', archiveProfile()]])) + repo.createDemand.mockResolvedValue({ id: 'demand1', demandNo: 'TC20260930-001' }) + + await productionStopDemandsService.createDemand( + { reasonCategory: '滞销款', barcodesText: 'A1' }, + user, + ) + + expect(notificationsService.refreshAll).toHaveBeenCalled() + expect(todoService.createTask).not.toHaveBeenCalled() + expect(repo.updateDemandConfirmation).toHaveBeenCalledWith( + 'demand1', + expect.objectContaining({ pushMessage: '已通过通知中心推送 1 人' }), + ) }) it('全部条码都不是销售条码时不创建需求', async () => { diff --git a/furniture-3d-system/src/server/services/production-stop-demands.service.ts b/furniture-3d-system/src/server/services/production-stop-demands.service.ts index b30bd742..131b1238 100644 --- a/furniture-3d-system/src/server/services/production-stop-demands.service.ts +++ b/furniture-3d-system/src/server/services/production-stop-demands.service.ts @@ -2,6 +2,7 @@ import { OperationLogAction, OperationLogStatus } from '@prisma/client' import { AppError, ErrorCode } from '@/server/core/errors' import type { AuthUser } from '@/server/core/types' import { + PRODUCTION_STOP_ANALYTICS_GRANULARITIES, PRODUCTION_STOP_CONFIRMATION_DECISIONS, PRODUCTION_STOP_CONFIRMATION_LABELS, PRODUCTION_STOP_DEFAULT_REASON_CATEGORIES, @@ -16,11 +17,14 @@ import { PRODUCTION_STOP_REPLACE_TYPES, parseBarcodeInput, resolveProductionStopConfirmationDeadline, + resolveProductionStopProductLabel, type ProductionStopConfirmationApproverOption, type ProductionStopConfirmationDecision, type ProductionStopConfirmationInfo, type ProductionStopConfirmationStatus, type ProductionStopConfirmationVote, + type ProductionStopAnalytics, + type ProductionStopAnalyticsGranularity, type ProductionStopFlowOwnerOption, type ProductionStopItemFilters, type ProductionStopItemPage, @@ -29,6 +33,7 @@ import { } from '@/lib/production-stop-demands' import { operationLogService } from '@/server/services/operation-log.service' import { dingtalkTodoService } from '@/server/services/dingtalk-todo.service' +import { productionStopNotificationsService } from '@/server/services/production-stop-notifications.service' import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' import { productionStopFlowRepository } from '@/server/repositories/production-stop-flow.repository' import { @@ -99,6 +104,12 @@ function buildConfirmationRejectReasons(votes: ProductionStopConfirmationVote[]) .join('\n') } +/** 数值快照:仅接受标准十进制文本,用于数值比较筛选。 */ +function numericSnapshot(value: string) { + const normalized = String(value ?? '').trim() + return /^-?\d+(\.\d+)?$/.test(normalized) ? normalized : null +} + function normalizeReasonCategoryName(value: unknown) { const name = normalizeText(value) if (!name) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请填写停产原因分类名称') @@ -156,6 +167,10 @@ class ProductionStopDemandsService { async listPage(filters: ProductionStopItemFilters): Promise { await this.ensureReasonCategoryDefaults() + // 通知中心待办兜底同步(5 分钟节流):补发/收口不依赖生产定时任务。 + void productionStopNotificationsService.refreshAllThrottled().catch((error) => { + console.error('[production-stop-demands] notification sync failed', error) + }) const result = await productionStopDemandRepository.listItemsPage(filters) return { rows: result.rows, @@ -171,6 +186,50 @@ class ProductionStopDemandsService { } } + /** 领导视图:按日/周/月统计需求趋势及部门、需求人、状态分布。 */ + async getAnalytics(input: { + granularity?: unknown + from?: unknown + to?: unknown + filters?: Omit< + ProductionStopItemFilters, + 'page' | 'pageSize' | 'sortKey' | 'sortDirection' | 'dateFrom' | 'dateTo' + > + }): Promise { + const granularity = normalizeText(input.granularity) as ProductionStopAnalyticsGranularity + if (!(PRODUCTION_STOP_ANALYTICS_GRANULARITIES as readonly string[]).includes(granularity)) { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '统计维度不正确') + } + const from = normalizeText(input.from) + const to = normalizeText(input.to) + if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '统计日期范围不正确') + } + const fromDate = new Date(`${from}T00:00:00+08:00`) + const toDate = new Date(`${to}T23:59:59.999+08:00`) + if (Number.isNaN(fromDate.getTime()) || Number.isNaN(toDate.getTime()) || fromDate > toDate) { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '统计日期范围不正确') + } + const result = await productionStopDemandRepository.getDemandAnalytics({ + from: fromDate, + to: toDate, + granularity, + filters: input.filters, + }) + return { + granularity, + from, + to, + total: result.total, + series: result.series, + byDepartment: result.byDepartment, + byRequester: result.byRequester, + byReasonCategory: result.byReasonCategory, + byDemandStatus: result.byDemandStatus as ProductionStopAnalytics['byDemandStatus'], + byConfirmationStatus: result.byConfirmationStatus as ProductionStopAnalytics['byConfirmationStatus'], + } + } + async createDemand( input: { reasonCategory?: unknown; reasonDetail?: unknown; barcodesText?: unknown }, user: Pick, @@ -239,6 +298,10 @@ class ProductionStopDemandsService { const skuSalesBarcodes = await this.loadArchiveData(() => productionStopDemandRepository.listSkuSalesBarcodes(skuCodes), ) + // 含大单近30日日销-SKU 维度:同 SKU 全部条码求和;条码维度的值随档案逐条快照在下面写入。 + const skuSales30dWithBig = await this.loadArchiveData(() => + productionStopDemandRepository.sumSkuSales30dWithBig(skuCodes), + ) const suggestionConfigs = await productionStopDemandRepository.findSuggestionsByBarcodes( accepted.map((entry) => entry.profile.barcode || entry.barcode), ) @@ -251,6 +314,7 @@ class ProductionStopDemandsService { // 按条码拆单:一个条码一张需求单,各自独立确认;同一批用 batchNo 串联,便于批量确认与推送聚合。 for (const entry of accepted) { const demandNo = await productionStopDemandRepository.nextDemandNo(now) + const skuSales30dBig = skuSales30dWithBig.get(entry.profile.sku.trim().toUpperCase()) ?? '' const created = await productionStopDemandRepository.createDemand({ demandNo, requesterId: user.id, @@ -272,17 +336,25 @@ class ProductionStopDemandsService { purchaseOwner: entry.profile.purchaseOwner, brand: entry.profile.brand, productTag: entry.profile.productTag, - // 产品标签与含大单近30日日销:档案当前还没有对应数据列,先按空值快照, - // 待产品档案补齐后改为读取 "dataTag" / "sales30dWithBig" 即可自动带上。 - productLabel: '', + // 产品标签:SABC 标签优先,未打标按销售状态 × 考核周期真实业绩兜底(提单时快照)。 + productLabel: resolveProductionStopProductLabel({ + sabcRating: entry.profile.sabcRating, + productStatus: entry.profile.productStatus, + realPerformance: entry.profile.sabcRealPerformance, + }), + // 图片:按条码快照档案库「图片」地址,列表在条码前展示缩略图。 + image: entry.profile.image, barcodeCreatedAt: entry.profile.businessCreatedAt, postStopSkuStatus: resolvePostStopSkuStatus( entry.profile.barcode || entry.barcode, entry.profile.sku, skuSalesBarcodes, ), - sales30dWithBig: '', - sales30dWithBigSku: '', + // 含大单近30日日销:条码维度取档案该条码行的值,SKU 维度取同 SKU 全部条码求和(提单时快照)。 + sales30dWithBig: entry.profile.sales30dWithBig, + sales30dWithBigSku: skuSales30dBig, + sales30dWithBigValue: numericSnapshot(entry.profile.sales30dWithBig), + sales30dWithBigSkuValue: numericSnapshot(skuSales30dBig), // 以下两列业务口径待补充(需求文档标注“逻辑待补充”),先留空占位。 opsDirector: entry.profile.opsDirector, topSalesOpsDirector: '', @@ -311,9 +383,39 @@ class ProductionStopDemandsService { } } + /** 通知中心待办同步:失败只记录日志,不阻塞业务。 */ + private refreshStopNotifications() { + void productionStopNotificationsService.refreshAll().catch((error) => { + console.error('[production-stop-demands] notification sync failed', error) + }) + } + + /** 需求驳回通知需求人(供应商 + 条码 + 驳回原因)。 */ + private notifyRejectedDemand(demandId: string) { + void productionStopNotificationsService.notifyRejected(demandId).catch((error) => { + console.error('[production-stop-demands] rejection notification failed', error) + }) + } + /** 同批需求共用一个钉钉待办(避免按条码逐条推送刷屏)。 */ private async dispatchBatchPush(demandIds: string[], deadline: Date | null, batchNo: string) { if (!demandIds.length) return { pushed: 0, message: '没有可推送的需求' } + if (productionStopNotificationsService.isEnabled()) { + // 通知中心模式:按「待确认条数」汇总推送,避免按批次刷屏。 + const result = await productionStopNotificationsService.refreshAll() + const message = result.sent + ? `已通过通知中心推送 ${result.sent} 人` + : result.failed + ? `通知中心推送失败 ${result.failed} 人,可在明细里重新推送` + : '通知中心待办已是最新状态' + await Promise.all(demandIds.map((demandId) => + productionStopDemandRepository.updateDemandConfirmation(demandId, { + pushedAt: result.sent ? new Date() : null, + pushMessage: message, + }), + )) + return { pushed: result.sent, message } + } const activeApprovers = await productionStopDemandRepository.listActiveApprovers() const approvers = activeApprovers.filter((approver) => approver.dingtalkUnionId) let message = '' @@ -453,6 +555,7 @@ class ProductionStopDemandsService { }) marked.push(demand.demandNo) } + if (marked.length) this.refreshStopNotifications() return { marked, skipped } } @@ -544,6 +647,7 @@ class ProductionStopDemandsService { deletedDemands: result.deletedDemands, }, }) + this.refreshStopNotifications() return result } @@ -565,6 +669,8 @@ class ProductionStopDemandsService { reasons: buildConfirmationRejectReasons(rejected), }) await this.syncDemandStatusFromConfirmation(demandId, 'REJECTED') + this.notifyRejectedDemand(demandId) + this.refreshStopNotifications() return 'REJECTED' } const approvers = await productionStopDemandRepository.listActiveApprovers() @@ -576,6 +682,7 @@ class ProductionStopDemandsService { reasons: '', }) await this.syncDemandStatusFromConfirmation(demandId, 'AGREED') + this.refreshStopNotifications() return 'AGREED' } return 'PENDING' @@ -605,8 +712,10 @@ class ProductionStopDemandsService { reasons: rejected.length ? buildConfirmationRejectReasons(rejected) : '', }) await this.syncDemandStatusFromConfirmation(demand.id, rejected.length ? 'REJECTED' : 'AGREED') + if (rejected.length) this.notifyRejectedDemand(demand.id) finalized += 1 } + if (finalized) this.refreshStopNotifications() return finalized } @@ -627,7 +736,10 @@ class ProductionStopDemandsService { /** 需求驳回后二次修改并重新提交确认:清空本轮确认票,重新计算截止时间并推送。 */ async resubmitDemand(demandId: string, user: AuthUser): Promise { - const access = await supplyChainPermissionSettingsService.getUserAccess(user, 'productionStopDemands') + const access = await supplyChainPermissionSettingsService.getUserAccess( + user, + 'productionStopDemands', + ) if (!access.edit) { throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有该供应链功能的权限') } @@ -649,6 +761,7 @@ class ProductionStopDemandsService { status: 'PENDING_CONFIRMATION', note: '驳回后重新提交确认', }) + this.refreshStopNotifications() void this.dispatchConfirmationPush(demandId).catch((error) => { console.error('[production-stop-demands] resubmit push failed', error) }) @@ -689,6 +802,7 @@ class ProductionStopDemandsService { status: 'CONFIRMED_WAIT_FLOW', note: `流程负责人强制确认停产:${note}`, }) + this.refreshStopNotifications() return this.getConfirmationInfo(demandId, user) } @@ -720,6 +834,7 @@ class ProductionStopDemandsService { status: 'FLOW_IN_APPROVAL', note: note || '流程负责人已发起停产流程,等待审批结果(次日自动校验流程状态)', }) + this.refreshStopNotifications() return this.getConfirmationInfo(demandId, user) } @@ -769,6 +884,7 @@ class ProductionStopDemandsService { status: 'REJECTED', note: `PLM 流程状态为拒绝(${hit?.title ?? '条码恢复流程'}),自动识别为需求驳回`, }) + this.notifyRejectedDemand(demand.id) rejected += 1 continue } @@ -800,6 +916,7 @@ class ProductionStopDemandsService { } } + if (rejected || approved) this.refreshStopNotifications() return { checked: demands.length, rejected, approved, failed: false } } @@ -1095,6 +1212,19 @@ class ProductionStopDemandsService { if (!demand) { throw new AppError(ErrorCode.PRODUCTION_STOP_DEMAND_NOT_FOUND, '停产需求不存在') } + if (productionStopNotificationsService.isEnabled()) { + const result = await productionStopNotificationsService.refreshAll() + const message = result.sent + ? `已通过通知中心推送 ${result.sent} 人` + : result.failed + ? `通知中心推送失败 ${result.failed} 人` + : '通知中心待办已是最新状态' + await productionStopDemandRepository.updateDemandConfirmation(demandId, { + pushedAt: result.sent ? new Date() : null, + pushMessage: message, + }) + return { pushed: result.sent, message } + } const activeApprovers = await productionStopDemandRepository.listActiveApprovers() if (!activeApprovers.length) { const message = '还没有配置停产确认人,无法推送' diff --git a/furniture-3d-system/src/server/services/production-stop-notifications.service.test.ts b/furniture-3d-system/src/server/services/production-stop-notifications.service.test.ts new file mode 100644 index 00000000..17aa999e --- /dev/null +++ b/furniture-3d-system/src/server/services/production-stop-notifications.service.test.ts @@ -0,0 +1,179 @@ +import { ProductionStopNotificationsService } from './production-stop-notifications.service' + +const actionUrl = 'https://cp.jiansun.vip/supply-chain/management/production-stop-demands' +const wrappedActionUrl = `dingtalk://dingtalkclient/page/link?url=${encodeURIComponent(actionUrl)}&pc_slide=false` + +process.env.NEXT_PUBLIC_APP_URL = 'https://cp.jiansun.vip' + +type SyncRow = { + kind: string + recipientKey: string + recipientName: string + pendingCount: number + snapshot: string + lastSentAt: Date | null + completedAt: Date | null +} + +type DemandRef = { id: string; demandNo: string } + +function createHarness(initialPending: DemandRef[] = []) { + let pending = [...initialPending] + let flow: DemandRef[] = [] + const syncRows = new Map() + const sends: Array> = [] + const rejectionNotice: { + value: Awaited> + } = { value: null } + + const fakeRepository = { + listPendingConfirmationDemands: jest.fn(async () => pending), + listPendingFlowDemands: jest.fn(async () => flow), + listActiveApprovers: jest.fn(async () => [{ id: 'a1', userName: '张三' }]), + listActiveFlowOwners: jest.fn(async () => [{ id: 'f1', userName: '李四' }]), + getNotificationSync: jest.fn(async (kind: string, recipientKey: string) => + syncRows.get(`${kind}:${recipientKey}`) ?? null), + upsertNotificationSync: jest.fn(async (row: SyncRow) => { + syncRows.set(`${row.kind}:${row.recipientKey}`, row) + }), + findUserDingtalkIds: jest.fn(async (): Promise> => new Map()), + findRejectionNotice: jest.fn(async (): Promise<{ + id: string + demandNo: string + requesterId: string + requesterName: string + reasons: string + items: Array<{ barcode: string; supplierName: string }> + } | null> => rejectionNotice.value), + } + const client = { + send: jest.fn(async (input: Record) => { + sends.push(input) + return { id: `notification-${sends.length}`, status: 'pending' } + }), + } + const service = new ProductionStopNotificationsService({ + repository: fakeRepository as never, + createClient: () => client, + }) + return { + service, + client, + repository: fakeRepository, + sends, + syncRows, + setPending: (rows: DemandRef[]) => { pending = rows }, + setFlow: (rows: DemandRef[]) => { flow = rows }, + setRejectionNotice: (value: typeof rejectionNotice.value) => { rejectionNotice.value = value }, + } +} + +describe('停产需求通知中心编排', () => { + it('按待确认条数推送,快照未变化时不重复推送', async () => { + const harness = createHarness([ + { id: 'd1', demandNo: 'TC20261020-001' }, + { id: 'd2', demandNo: 'TC20261020-002' }, + ]) + + const first = await harness.service.refreshAll() + expect(first.sent).toBe(1) + expect(harness.sends[0]).toMatchObject({ + recipientAlias: '张三', + title: '是否同意停产确认', + }) + expect(String(harness.sends[0].content)).toContain('**待办条数:** 2 条') + expect(String(harness.sends[0].content)) + .toContain(`**处理链接:** [点击进入停产需求管理](${wrappedActionUrl})`) + expect(harness.sends[0].actionUrl).toBe(wrappedActionUrl) + expect(String(harness.sends[0].idempotencyKey)).toMatch(/^production_stop_confirm:a1:/) + + const second = await harness.service.refreshAll() + expect(second.sent).toBe(0) + expect(harness.sends).toHaveLength(1) + }) + + it('待办清零时推送一次「已全部处理完成」', async () => { + const harness = createHarness([{ id: 'd1', demandNo: 'TC20261020-001' }]) + await harness.service.refreshAll() + + harness.setPending([]) + const result = await harness.service.refreshAll() + + expect(result.completed).toBe(1) + expect(harness.sends).toHaveLength(2) + expect(String(harness.sends[1].content)).toContain('**待办条数:** 0 条(已全部处理完成)') + expect(String(harness.sends[1].idempotencyKey)).toContain(':done:') + }) + + it('待走流程待办只统计已确认待走流程并推送给流程负责人', async () => { + const harness = createHarness([]) + harness.setFlow([{ id: 'd3', demandNo: 'TC20261020-003' }]) + + const result = await harness.service.refreshAll() + + expect(result.sent).toBe(1) + expect(harness.sends[0]).toMatchObject({ + recipientAlias: '李四', + title: '待走停产流程', + content: [ + '**待办条数:** 1 条', + '', + `**处理链接:** [点击进入停产需求管理](${wrappedActionUrl})`, + ].join('\n'), + }) + }) + + it('需求驳回通知需求人,内容包含供应商、条码与驳回原因', async () => { + const harness = createHarness([]) + harness.setRejectionNotice({ + id: 'd1', + demandNo: 'TC20261020-001', + requesterId: 'u-requester', + requesterName: '王五', + reasons: '张三:库存仍有 300 件', + items: [{ barcode: 'A1', supplierName: '供应商A' }], + }) + + const sent = await harness.service.notifyRejected('d1') + + expect(sent).toBe(true) + expect(harness.sends[0]).toMatchObject({ + recipientAlias: '王五', + title: '需求驳回同频', + }) + expect(String(harness.sends[0].content)).toBe([ + '**供应商:** 供应商A', + '**条码:** A1', + '**驳回原因:** 张三:库存仍有 300 件', + '', + `**处理链接:** [点击进入停产需求管理](${wrappedActionUrl})`, + ].join('\n')) + expect(String(harness.sends[0].idempotencyKey)).toBe('production_stop_rejected:TC20261020-001:v1') + }) + + it('账号已绑定钉钉 userId 时优先按 userId 发送,不再依赖花名', async () => { + const harness = createHarness([{ id: 'd1', demandNo: 'TC20261020-001' }]) + harness.repository.listActiveApprovers.mockResolvedValue([ + { id: 'a1', userId: 'u1', userName: '张三' }, + ]) + harness.repository.findUserDingtalkIds.mockResolvedValue(new Map([['u1', 'ding-user-1']])) + + await harness.service.refreshAll() + + expect(harness.sends[0]).toMatchObject({ recipientUserId: 'ding-user-1' }) + expect(harness.sends[0].recipientAlias).toBeUndefined() + }) + + it('发送失败时不更新待办快照,下一次同步会重试', async () => { + const harness = createHarness([{ id: 'd1', demandNo: 'TC20261020-001' }]) + harness.client.send.mockRejectedValueOnce(new Error('network down')) + + const failed = await harness.service.refreshAll() + expect(failed.failed).toBe(1) + expect(harness.syncRows.size).toBe(0) + + const retried = await harness.service.refreshAll() + expect(retried.sent).toBe(1) + expect(harness.syncRows.size).toBe(1) + }) +}) diff --git a/furniture-3d-system/src/server/services/production-stop-notifications.service.ts b/furniture-3d-system/src/server/services/production-stop-notifications.service.ts new file mode 100644 index 00000000..90ac2f95 --- /dev/null +++ b/furniture-3d-system/src/server/services/production-stop-notifications.service.ts @@ -0,0 +1,273 @@ +// 停产需求管理 → 由壹点统一通知中心的推送编排。 +// - 停产确认待办 / 待走流程待办:按接收人维护「待办快照」,集合变化才推送新卡片; +// 待办清零时推送一次「已全部处理完成」(通知中心机器人消息本身不支持勾选完成)。 +// - 需求驳回:按需求单推送一次,内容为供应商 + 条码 + 驳回原因。 +import { createHash } from 'node:crypto' +import { buildDingTalkFullscreenUrl } from '@/lib/dingtalk-link' +import { + buildProductionStopConfirmationPendingMessage, + buildProductionStopFlowPendingMessage, + buildProductionStopPendingCompletedMessage, + buildProductionStopRejectedMessage, + type ProductionStopPendingNotificationKind, +} from '@/lib/production-stop-notifications' +import { + NotificationCenterClient, + NotificationCenterRequestError, +} from '@/server/integrations/notification-center/client' +import type { NotificationCenterSendInput } from '@/server/integrations/notification-center/types' +import { productionStopDemandRepository } from '@/server/repositories/production-stop-demands.repository' + +type NotificationSender = { + send(input: NotificationCenterSendInput): Promise +} + +type NotificationRepository = Pick< + typeof productionStopDemandRepository, + | 'listPendingConfirmationDemands' + | 'listPendingFlowDemands' + | 'listActiveApprovers' + | 'listActiveFlowOwners' + | 'getNotificationSync' + | 'upsertNotificationSync' + | 'findRejectionNotice' + | 'findUserDingtalkIds' +> + +const REFRESH_THROTTLE_MS = 5 * 60 * 1000 +let lastRefreshStartedAt = 0 + +function appBaseUrl() { + return (process.env.NEXT_PUBLIC_APP_URL || 'https://cp.jiansun.vip').replace(/\/+$/, '') +} + +/** + * 兼容两种变量名:接入文档使用 YYD_NOTIFICATION_PROJECT_*, + * 管理面板生成的配置使用 YYD_NOTIFICATION_CENTER_PROJECT_*。 + */ +function readNotificationCenterEnv() { + return { + baseUrl: process.env.YYD_NOTIFICATION_CENTER_URL?.trim() ?? '', + projectCode: process.env.YYD_NOTIFICATION_PROJECT_CODE?.trim() + || process.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE?.trim() + || '', + projectSecret: process.env.YYD_NOTIFICATION_PROJECT_SECRET?.trim() + || process.env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET?.trim() + || '', + } +} + +export function hasNotificationCenterConfig() { + const env = readNotificationCenterEnv() + return Boolean(env.baseUrl && env.projectCode && env.projectSecret) +} + +function fingerprint(demandIds: string[]) { + return createHash('sha256').update([...demandIds].sort().join('\n')).digest('hex').slice(0, 24) +} + +export class ProductionStopNotificationsService { + constructor(private readonly deps: { + repository?: NotificationRepository + createClient?: () => NotificationSender | null + } = {}) {} + + private get repository(): NotificationRepository { + return this.deps.repository ?? productionStopDemandRepository + } + + private createClient(): NotificationSender | null { + if (this.deps.createClient) return this.deps.createClient() + if (!hasNotificationCenterConfig()) return null + const env = readNotificationCenterEnv() + try { + return new NotificationCenterClient({ + baseUrl: env.baseUrl, + projectCode: env.projectCode, + projectSecret: env.projectSecret, + }) + } catch (error) { + console.error( + '[production-stop-notifications] 通知中心配置无效', + error instanceof Error ? error.message : String(error), + ) + return null + } + } + + isEnabled() { + return Boolean(this.createClient()) + } + + private actionUrl() { + return buildDingTalkFullscreenUrl( + `${appBaseUrl()}/supply-chain/management/production-stop-demands`, + ) + } + + private async sendTo( + client: NotificationSender, + recipient: { name: string; dingtalkUserId?: string }, + idempotencyKey: string, + message: { title: string; content: string }, + ) { + try { + await client.send({ + idempotencyKey, + ...(recipient.dingtalkUserId + ? { recipientUserId: recipient.dingtalkUserId } + : { recipientAlias: recipient.name }), + title: message.title, + content: message.content, + actionTitle: '查看停产需求', + actionUrl: this.actionUrl(), + }) + return true + } catch (error) { + console.error('[production-stop-notifications] 通知发送失败', { + idempotencyKey, + code: error instanceof NotificationCenterRequestError ? error.code : undefined, + message: error instanceof Error ? error.message : String(error), + }) + return false + } + } + + private async refreshKind(kind: ProductionStopPendingNotificationKind, client: NotificationSender) { + const result = { sent: 0, completed: 0, failed: 0 } + const demands = kind === 'CONFIRM_PENDING' + ? await this.repository.listPendingConfirmationDemands() + : await this.repository.listPendingFlowDemands() + const recipients = kind === 'CONFIRM_PENDING' + ? (await this.repository.listActiveApprovers()).map((row) => ({ + id: row.id, + userId: row.userId, + userName: row.userName, + })) + : (await this.repository.listActiveFlowOwners()).map((row) => ({ + id: row.id, + userId: row.userId, + userName: row.userName, + })) + const dingtalkIds = await this.repository.findUserDingtalkIds( + recipients.map((recipient) => recipient.userId), + ) + const pendingCount = demands.length + const snapshot = fingerprint(demands.map((demand) => demand.id)) + const keyPrefix = kind === 'CONFIRM_PENDING' ? 'production_stop_confirm' : 'production_stop_flow' + + for (const recipient of recipients) { + const recipientName = recipient.userName.trim() + if (!recipientName) continue + const recipientKey = `${kind}:${recipient.id}` + const state = await this.repository.getNotificationSync(kind, recipientKey) + + if (pendingCount > 0) { + if (state?.snapshot === snapshot) continue + const message = kind === 'CONFIRM_PENDING' + ? buildProductionStopConfirmationPendingMessage(pendingCount, this.actionUrl()) + : buildProductionStopFlowPendingMessage(pendingCount, this.actionUrl()) + const sent = await this.sendTo( + client, + { + name: recipientName, + dingtalkUserId: dingtalkIds.get(recipient.userId), + }, + `${keyPrefix}:${recipient.id}:${snapshot}:v1`, + message, + ) + if (!sent) { + result.failed += 1 + continue + } + await this.repository.upsertNotificationSync({ + kind, + recipientKey, + recipientName, + pendingCount, + snapshot, + lastSentAt: new Date(), + completedAt: null, + }) + result.sent += 1 + continue + } + + if (state && state.pendingCount > 0) { + const sent = await this.sendTo( + client, + { + name: recipientName, + dingtalkUserId: dingtalkIds.get(recipient.userId), + }, + `${keyPrefix}:${recipient.id}:done:${state.snapshot}:v1`, + buildProductionStopPendingCompletedMessage(kind, this.actionUrl()), + ) + if (!sent) { + result.failed += 1 + continue + } + await this.repository.upsertNotificationSync({ + kind, + recipientKey, + recipientName, + pendingCount: 0, + snapshot: state.snapshot, + lastSentAt: new Date(), + completedAt: new Date(), + }) + result.completed += 1 + } + } + return result + } + + /** 同步两类待办:仅在待办集合变化时推送新卡片,清零时推送一次完成消息。 */ + async refreshAll() { + const client = this.createClient() + if (!client) return { sent: 0, completed: 0, failed: 0 } + const [confirm, flow] = await Promise.all([ + this.refreshKind('CONFIRM_PENDING', client), + this.refreshKind('FLOW_PENDING', client), + ]) + return { + sent: confirm.sent + flow.sent, + completed: confirm.completed + flow.completed, + failed: confirm.failed + flow.failed, + } + } + + /** 列表访问时的兜底同步(5 分钟节流),保证没有定时任务的本地环境也能补发/收口。 */ + async refreshAllThrottled() { + if (!this.isEnabled()) return + const now = Date.now() + if (now - lastRefreshStartedAt < REFRESH_THROTTLE_MS) return + lastRefreshStartedAt = now + await this.refreshAll() + } + + /** 需求驳回:通知需求人(供应商 + 条码 + 驳回原因)。 */ + async notifyRejected(demandId: string) { + const client = this.createClient() + if (!client) return false + const notice = await this.repository.findRejectionNotice(demandId) + if (!notice || !notice.requesterName.trim() || !notice.items.length) return false + const message = buildProductionStopRejectedMessage({ + supplierName: notice.items.map((item) => item.supplierName).filter(Boolean).join('、'), + barcode: notice.items.map((item) => item.barcode).filter(Boolean).join('、'), + reason: notice.reasons, + }, this.actionUrl()) + const dingtalkIds = await this.repository.findUserDingtalkIds([notice.requesterId]) + return this.sendTo( + client, + { + name: notice.requesterName.trim(), + dingtalkUserId: dingtalkIds.get(notice.requesterId), + }, + `production_stop_rejected:${notice.demandNo}:v1`, + message, + ) + } +} + +export const productionStopNotificationsService = new ProductionStopNotificationsService()