From 78cfa85232ed253c43bbff66f227c004a7d833d1 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 27 Aug 2026 16:39:54 +0800 Subject: [PATCH] release: integrate approved product and notification updates Includes 13268ee5, 01a9d1e2, 48e496b0, bf85258d. Store account fix e48229ab is already integrated by parent d8141a63. --- furniture-3d-system/.env.example | 9 + .../migration.sql | 48 +++ .../migration.sql | 23 ++ furniture-3d-system/prisma/schema.prisma | 60 +++ .../run/route.test.ts | 104 +++++ .../product-development-todos/run/route.ts | 44 ++ .../app/api/notification-deliveries/route.ts | 65 +++ ...ntegratedDetailClient.factory-sku.test.tsx | 141 +++++++ .../useNonIntegratedFormActionControllers.ts | 4 + ...edWorkflowConfirmActionController.test.tsx | 28 +- .../workspaces/basic-info/SkuInfoRow.tsx | 2 +- .../workspaces/basic-info/skuBrandHelpers.ts | 7 +- .../basic-info/skuDerivationHelpers.ts | 6 +- .../basic-info/skuFieldUpdateHelpers.test.ts | 54 +++ .../basic-info/skuFieldUpdateHelpers.ts | 22 + .../basic-info/useSkuItemEditController.ts | 3 +- .../workspaces/preview/previewState.test.ts | 9 + .../useProductFactoryRowController.ts | 154 ++++--- ...ductDevelopmentOperationLogClient.test.tsx | 55 ++- .../ProductDevelopmentOperationLogClient.tsx | 109 ++++- .../src/app/intake/operation-logs/page.tsx | 2 +- .../nonIntegratedListState.test.ts | 58 +++ .../nonIntegratedListState.ts | 66 ++- .../NotificationLogClient.test.tsx | 36 ++ .../NotificationLogClient.tsx | 158 ++++++++ .../NotificationLogDetailDrawer.tsx | 68 ++++ .../NotificationLogFilters.tsx | 65 +++ .../NotificationLogPagination.tsx | 69 ++++ .../NotificationLogTable.tsx | 60 +++ .../notificationLogPresentation.tsx | 80 ++++ .../src/app/notification-logs/page.tsx | 9 + .../UserMenu.notification-log.test.tsx | 45 +++ .../basic-info-confirmation-validation.ts | 3 - .../notification-center/client.test.ts | 89 ++++ .../notification-center/client.ts | 153 +++++++ .../integrations/notification-center/types.ts | 36 ++ .../notification-delivery.repository.ts | 105 +++++ .../product-development-todo.repository.ts | 117 ++++++ .../notification-delivery.service.test.ts | 182 +++++++++ .../product-development-todo-rules.test.ts | 176 ++++++++ .../product-development-todo.service.test.ts | 220 ++++++++++ .../services/notification-delivery.service.ts | 380 ++++++++++++++++++ ...ct-development-todo-preparation.service.ts | 287 +++++++++++++ .../product-development-todo-rules.ts | 281 +++++++++++++ .../product-development-todo.service.ts | 150 +++++++ .../product-development-todo.types.ts | 53 +++ scripts/deploy-yydd-prod-from-git.ps1 | 30 +- 47 files changed, 3814 insertions(+), 111 deletions(-) create mode 100644 furniture-3d-system/prisma/migrations/20260826153000_add_notification_delivery_log/migration.sql create mode 100644 furniture-3d-system/prisma/migrations/20260827103000_add_product_development_todo_attempt/migration.sql create mode 100644 furniture-3d-system/src/app/api/internal/product-development-todos/run/route.test.ts create mode 100644 furniture-3d-system/src/app/api/internal/product-development-todos/run/route.ts create mode 100644 furniture-3d-system/src/app/api/notification-deliveries/route.ts create mode 100644 furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuFieldUpdateHelpers.test.ts create mode 100644 furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuFieldUpdateHelpers.ts create mode 100644 furniture-3d-system/src/app/intake/product-development/nonIntegratedListState.test.ts create mode 100644 furniture-3d-system/src/app/notification-logs/NotificationLogClient.test.tsx create mode 100644 furniture-3d-system/src/app/notification-logs/NotificationLogClient.tsx create mode 100644 furniture-3d-system/src/app/notification-logs/NotificationLogDetailDrawer.tsx create mode 100644 furniture-3d-system/src/app/notification-logs/NotificationLogFilters.tsx create mode 100644 furniture-3d-system/src/app/notification-logs/NotificationLogPagination.tsx create mode 100644 furniture-3d-system/src/app/notification-logs/NotificationLogTable.tsx create mode 100644 furniture-3d-system/src/app/notification-logs/notificationLogPresentation.tsx create mode 100644 furniture-3d-system/src/app/notification-logs/page.tsx create mode 100644 furniture-3d-system/src/components/ui/__tests__/UserMenu.notification-log.test.tsx create mode 100644 furniture-3d-system/src/server/integrations/notification-center/client.test.ts create mode 100644 furniture-3d-system/src/server/integrations/notification-center/client.ts create mode 100644 furniture-3d-system/src/server/integrations/notification-center/types.ts create mode 100644 furniture-3d-system/src/server/repositories/notification-delivery.repository.ts create mode 100644 furniture-3d-system/src/server/repositories/product-development-todo.repository.ts create mode 100644 furniture-3d-system/src/server/services/__tests__/notification-delivery.service.test.ts create mode 100644 furniture-3d-system/src/server/services/__tests__/product-development-todo-rules.test.ts create mode 100644 furniture-3d-system/src/server/services/__tests__/product-development-todo.service.test.ts create mode 100644 furniture-3d-system/src/server/services/notification-delivery.service.ts create mode 100644 furniture-3d-system/src/server/services/product-development-todo-preparation.service.ts create mode 100644 furniture-3d-system/src/server/services/product-development-todo-rules.ts create mode 100644 furniture-3d-system/src/server/services/product-development-todo.service.ts create mode 100644 furniture-3d-system/src/server/services/product-development-todo.types.ts diff --git a/furniture-3d-system/.env.example b/furniture-3d-system/.env.example index 6a90b391..a9204310 100644 --- a/furniture-3d-system/.env.example +++ b/furniture-3d-system/.env.example @@ -87,6 +87,15 @@ DINGTALK_TODO_ESCALATION_ENABLED=false DINGTALK_TODO_ESCALATION_DAYS=3 # DINGTALK_AGENT_ID= +# 由壹点通知中心(仅服务端使用,项目密钥禁止提交或暴露给浏览器) +YYD_NOTIFICATION_CENTER_URL=https://sc.jiansun.vip/notification-center +YYD_NOTIFICATION_PROJECT_CODE=furniture_platform +YYD_NOTIFICATION_PROJECT_SECRET= +YYD_NOTIFICATION_TIMEOUT_MS=10000 + +# 产品开发待办定时任务(仅服务端/部署 cron 使用,须与其他任务密钥分开) +PRODUCT_DEVELOPMENT_TODO_CRON_SECRET= + # 对象存储 # 必填,不能留空。当前生产桶使用 qiniu;只有明确切换适配器时才改为 s3/minio。 STORAGE_PROVIDER=qiniu diff --git a/furniture-3d-system/prisma/migrations/20260826153000_add_notification_delivery_log/migration.sql b/furniture-3d-system/prisma/migrations/20260826153000_add_notification_delivery_log/migration.sql new file mode 100644 index 00000000..46023500 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260826153000_add_notification_delivery_log/migration.sql @@ -0,0 +1,48 @@ +CREATE TABLE "NotificationDeliveryLog" ( + "id" TEXT NOT NULL, + "idempotencyKey" TEXT NOT NULL, + "payloadHash" TEXT NOT NULL, + "externalDeliveryId" TEXT, + "businessEvent" TEXT NOT NULL, + "businessRecordId" TEXT, + "source" TEXT NOT NULL DEFAULT 'SYSTEM', + "initiatedById" TEXT, + "initiatedByName" TEXT NOT NULL, + "recipientUserId" TEXT NOT NULL, + "recipientName" TEXT NOT NULL, + "recipientEmail" TEXT NOT NULL, + "recipientDingtalkUserId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "renderedTitle" TEXT, + "content" TEXT NOT NULL, + "actionTitle" TEXT NOT NULL, + "actionUrl" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "lastErrorCode" TEXT, + "lastErrorMessage" TEXT, + "sentAt" TIMESTAMP(3), + "readAt" TIMESTAMP(3), + "lastSyncedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "NotificationDeliveryLog_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "NotificationDeliveryLog_idempotencyKey_key" +ON "NotificationDeliveryLog"("idempotencyKey"); + +CREATE UNIQUE INDEX "NotificationDeliveryLog_externalDeliveryId_key" +ON "NotificationDeliveryLog"("externalDeliveryId"); + +CREATE INDEX "NotificationDeliveryLog_status_createdAt_idx" +ON "NotificationDeliveryLog"("status", "createdAt"); + +CREATE INDEX "NotificationDeliveryLog_recipientUserId_createdAt_idx" +ON "NotificationDeliveryLog"("recipientUserId", "createdAt"); + +CREATE INDEX "NotificationDeliveryLog_businessEvent_createdAt_idx" +ON "NotificationDeliveryLog"("businessEvent", "createdAt"); + +CREATE INDEX "NotificationDeliveryLog_createdAt_idx" +ON "NotificationDeliveryLog"("createdAt"); diff --git a/furniture-3d-system/prisma/migrations/20260827103000_add_product_development_todo_attempt/migration.sql b/furniture-3d-system/prisma/migrations/20260827103000_add_product_development_todo_attempt/migration.sql new file mode 100644 index 00000000..b06b0927 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260827103000_add_product_development_todo_attempt/migration.sql @@ -0,0 +1,23 @@ +CREATE TABLE "ProductDevelopmentTodoAttempt" ( + "id" TEXT NOT NULL, + "attemptKey" TEXT NOT NULL, + "ruleId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "recipientKey" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'claimed', + "lastErrorCode" TEXT, + "lastErrorMessage" TEXT, + "attemptedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProductDevelopmentTodoAttempt_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ProductDevelopmentTodoAttempt_attemptKey_key" +ON "ProductDevelopmentTodoAttempt"("attemptKey"); + +CREATE INDEX "ProductDevelopmentTodoAttempt_productId_ruleId_idx" +ON "ProductDevelopmentTodoAttempt"("productId", "ruleId"); + +CREATE INDEX "ProductDevelopmentTodoAttempt_recipientKey_attemptedAt_idx" +ON "ProductDevelopmentTodoAttempt"("recipientKey", "attemptedAt"); diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 369c6385..5f015bf5 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -3066,6 +3066,66 @@ model OperationLog { @@index([productId, createdAt]) } +// 家具系统发往统一通知中心的本地审计副本。 +// 不建立 User 外键,确保账号后续停用或删除后仍可追溯当时的收件人与内容。 +model NotificationDeliveryLog { + id String @id @default(cuid()) + + idempotencyKey String @unique + payloadHash String + externalDeliveryId String? @unique + + businessEvent String + businessRecordId String? + source String @default("SYSTEM") + initiatedById String? + initiatedByName String + + recipientUserId String + recipientName String + recipientEmail String + recipientDingtalkUserId String + + title String + renderedTitle String? + content String @db.Text + actionTitle String + actionUrl String @db.Text + + status String @default("pending") + lastErrorCode String? + lastErrorMessage String? @db.Text + sentAt DateTime? + readAt DateTime? + lastSyncedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status, createdAt]) + @@index([recipientUserId, createdAt]) + @@index([businessEvent, createdAt]) + @@index([createdAt]) +} + +// 产品开发提醒的一次性业务尝试标记。通知正文仍由 NotificationDeliveryLog 审计。 +model ProductDevelopmentTodoAttempt { + id String @id @default(cuid()) + + attemptKey String @unique + ruleId String + productId String + recipientKey String + status String @default("claimed") + lastErrorCode String? + lastErrorMessage String? @db.Text + attemptedAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([productId, ruleId]) + @@index([recipientKey, attemptedAt]) +} + model ReleaseNote { id String @id @default(cuid()) diff --git a/furniture-3d-system/src/app/api/internal/product-development-todos/run/route.test.ts b/furniture-3d-system/src/app/api/internal/product-development-todos/run/route.test.ts new file mode 100644 index 00000000..1f825b58 --- /dev/null +++ b/furniture-3d-system/src/app/api/internal/product-development-todos/run/route.test.ts @@ -0,0 +1,104 @@ +export {} + +const mockHandleRequest = jest.fn() +const mockJsonError = jest.fn() +const mockJsonSuccess = jest.fn() +const mockPreview = jest.fn() +const mockSend = jest.fn() +const mockCanViewOperationLogs = jest.fn() + +jest.mock('@/server/core/response', () => ({ + handleRequest: (...args: unknown[]) => mockHandleRequest(...args), + jsonError: (...args: unknown[]) => mockJsonError(...args), + jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args), +})) + +jest.mock('@/lib/permissions', () => ({ + canViewOperationLogs: (...args: unknown[]) => mockCanViewOperationLogs(...args), +})) + +jest.mock('@/server/services/product-development-todo.service', () => ({ + productDevelopmentTodoService: { + preview: (...args: unknown[]) => mockPreview(...args), + send: (...args: unknown[]) => mockSend(...args), + }, +})) + +const { POST } = require('./route') + +function request(input: { mode?: string; secret?: string } = {}) { + return { + nextUrl: { + searchParams: new URLSearchParams(input.mode ? { mode: input.mode } : {}), + }, + headers: { + get: (name: string) => name === 'x-product-development-todo-secret' + ? input.secret ?? null + : null, + }, + } +} + +describe('POST product development todos', () => { + beforeEach(() => { + jest.clearAllMocks() + process.env.PRODUCT_DEVELOPMENT_TODO_CRON_SECRET = 'cron-secret' + mockHandleRequest.mockImplementation( + async (_request: unknown, handler: (ctx: unknown) => unknown) => ( + handler({ requestId: 'request-1', user: null }) + ), + ) + mockJsonError.mockImplementation((code, message, status, requestId) => ({ + code, + message, + status, + requestId, + })) + mockJsonSuccess.mockImplementation((data, status, requestId) => ({ + data, + status, + requestId, + })) + mockPreview.mockResolvedValue({ mode: 'preview', groups: [] }) + mockSend.mockResolvedValue({ mode: 'send', attemptedCount: 1 }) + }) + + afterAll(() => { + delete process.env.PRODUCT_DEVELOPMENT_TODO_CRON_SECRET + }) + + it('allows an administrator to preview without the cron secret', async () => { + mockCanViewOperationLogs.mockReturnValue(true) + mockHandleRequest.mockImplementation( + async (_request: unknown, handler: (ctx: unknown) => unknown) => ( + handler({ requestId: 'request-1', user: { role: 'SUPER_ADMIN' } }) + ), + ) + + const response = await POST(request()) + + expect(mockPreview).toHaveBeenCalledTimes(1) + expect(response.data.mode).toBe('preview') + }) + + it('never sends from a signed-in session without the dedicated cron secret', async () => { + mockCanViewOperationLogs.mockReturnValue(true) + mockHandleRequest.mockImplementation( + async (_request: unknown, handler: (ctx: unknown) => unknown) => ( + handler({ requestId: 'request-1', user: { role: 'SUPER_ADMIN' } }) + ), + ) + + const response = await POST(request({ mode: 'send' })) + + expect(response.status).toBe(401) + expect(mockSend).not.toHaveBeenCalled() + }) + + it('runs the batch only with the dedicated secret', async () => { + const response = await POST(request({ mode: 'send', secret: 'cron-secret' })) + + expect(mockSend).toHaveBeenCalledTimes(1) + expect(response.data.mode).toBe('send') + }) +}) diff --git a/furniture-3d-system/src/app/api/internal/product-development-todos/run/route.ts b/furniture-3d-system/src/app/api/internal/product-development-todos/run/route.ts new file mode 100644 index 00000000..cc684e6d --- /dev/null +++ b/furniture-3d-system/src/app/api/internal/product-development-todos/run/route.ts @@ -0,0 +1,44 @@ +import { NextRequest } from 'next/server' +import { canViewOperationLogs } from '@/lib/permissions' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { productDevelopmentTodoService } from '@/server/services/product-development-todo.service' + +function hasCronSecret(request: NextRequest) { + const expected = process.env.PRODUCT_DEVELOPMENT_TODO_CRON_SECRET?.trim() + const received = request.headers.get('x-product-development-todo-secret')?.trim() + return Boolean(expected && received && expected === received) +} + +export async function POST(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const mode = request.nextUrl.searchParams.get('mode') === 'send' ? 'send' : 'preview' + const cronAuthorized = hasCronSecret(request) + + if (mode === 'send' && !cronAuthorized) { + return jsonError( + ErrorCode.AUTH_INVALID_TOKEN, + '产品开发待办执行密钥无效', + 401, + ctx.requestId, + ) + } + + if (mode === 'preview' && !cronAuthorized && (!ctx.user || !canViewOperationLogs(ctx.user))) { + return jsonError( + ErrorCode.AUTH_PERMISSION_DENIED, + '只有管理员可以预览产品开发待办', + 403, + ctx.requestId, + ) + } + + const result = mode === 'send' + ? await productDevelopmentTodoService.send() + : await productDevelopmentTodoService.preview({ + ignoreAttemptHistory: + request.nextUrl.searchParams.get('includeAttempted') === 'true', + }) + return jsonSuccess(result, 200, ctx.requestId) + }, { requireAuth: false }) +} diff --git a/furniture-3d-system/src/app/api/notification-deliveries/route.ts b/furniture-3d-system/src/app/api/notification-deliveries/route.ts new file mode 100644 index 00000000..579d8e4a --- /dev/null +++ b/furniture-3d-system/src/app/api/notification-deliveries/route.ts @@ -0,0 +1,65 @@ +import { NextRequest } from 'next/server' +import { canViewOperationLogs } from '@/lib/permissions' +import { NOTIFICATION_DELIVERY_STATUSES } from '@/server/integrations/notification-center/types' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccessWithPagination } from '@/server/core/response' +import { notificationDeliveryService } from '@/server/services/notification-delivery.service' + +function readDate(value: string | null, endOfDay = false) { + if (!value) return undefined + const date = new Date(value) + if (Number.isNaN(date.getTime())) return undefined + if (endOfDay && /^\d{4}-\d{2}-\d{2}$/.test(value)) { + date.setHours(23, 59, 59, 999) + } + return date +} + +export async function GET(request: NextRequest) { + return handleRequest(request, async (ctx) => { + if (!canViewOperationLogs(ctx.user)) { + return jsonError( + ErrorCode.AUTH_PERMISSION_DENIED, + '只有管理员可以查看通知记录', + 403, + ctx.requestId, + ) + } + + const params = request.nextUrl.searchParams + const page = Math.max(1, Number.parseInt(params.get('page') ?? '1', 10) || 1) + const pageSize = Math.min( + 200, + Math.max(1, Number.parseInt(params.get('pageSize') ?? '50', 10) || 50), + ) + const requestedStatus = params.get('status') + const status = requestedStatus && NOTIFICATION_DELIVERY_STATUSES.includes( + requestedStatus as (typeof NOTIFICATION_DELIVERY_STATUSES)[number], + ) ? requestedStatus : undefined + const result = await notificationDeliveryService.list({ + page, + pageSize, + status, + keyword: params.get('keyword') ?? undefined, + startDate: readDate(params.get('startDate')), + endDate: readDate(params.get('endDate'), true), + refresh: params.get('refresh') === 'true', + }) + + return jsonSuccessWithPagination( + { + records: result.records, + integrationConfigured: result.integrationConfigured, + syncWarning: result.syncWarning, + }, + { + page: result.page, + limit: result.pageSize, + total: result.total, + totalPages: result.totalPages, + hasMore: result.page < result.totalPages, + }, + ctx.requestId, + ) + }) +} diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.factory-sku.test.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.factory-sku.test.tsx index 5e978f60..6c8d8e96 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.factory-sku.test.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.factory-sku.test.tsx @@ -528,6 +528,147 @@ describe("NonIntegratedDetailClient factory and SKU editing", () => { }); }); + it("keeps basic information confirmed after a factory set count change", async () => { + const actor = userEvent.setup(); + const productId = "ZWJ0218-factory-preserves-basic-confirmation"; + const basicInfoApprovedAt = "2026-08-27T03:00:00.000Z"; + + saveNonIntegratedProduct( + createProductRecord({ + id: productId, + spu: "ZWJ0218", + skuBatchConfirmedAt: CONFIRMED_SKU_BATCH_AT, + documentWorkspaceConfirmations: { + ...createDefaultDocumentWorkspaceConfirmations(), + [NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY]: true, + }, + documentWorkspaceApprovedAt: { + [NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY]: basicInfoApprovedAt, + }, + skuItems: [ + createSkuItem({ + id: "sku-factory-preserves-basic-confirmation-1", + skuSequence: 1, + model: "ZWJ0218K01", + skuCode: "ZWJ021801", + setCount: "1", + barcode: "ZWJ021801N396V01", + manufacturer: "曹县嘉欢", + manufacturerCode: "N396", + installMethod: "整装", + source: "sku_workbook", + sourceTable: "sku_workbook", + contentLocked: true, + }), + ], + }), + ); + + render(); + await actor.click( + screen.getByTestId("document-workspace-trigger-产品定厂"), + ); + + const factoryWorkspace = screen.getByTestId("product-factory-workspace"); + const setCountInput = + within(factoryWorkspace).getByPlaceholderText("请输入套件量"); + fireEvent.change(setCountInput, { target: { value: "2" } }); + fireEvent.blur(setCountInput); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById(productId); + expect(savedProduct?.skuItems.map((item) => item.setCount)).toEqual([ + "2", + "2", + ]); + expect( + savedProduct?.documentWorkspaceConfirmations?.[ + NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY + ], + ).toBe(true); + expect( + savedProduct?.documentWorkspaceApprovedAt?.[ + NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY + ], + ).toBe(basicInfoApprovedAt); + }); + + }); + + it("keeps basic information confirmed after a factory install method change", async () => { + const actor = userEvent.setup(); + const productId = "ZWJ0218-factory-install-preserves-confirmation"; + const basicInfoApprovedAt = "2026-08-27T03:00:00.000Z"; + + saveNonIntegratedProduct( + createProductRecord({ + id: productId, + spu: "ZWJ0218", + skuBatchConfirmedAt: CONFIRMED_SKU_BATCH_AT, + documentWorkspaceConfirmations: { + ...createDefaultDocumentWorkspaceConfirmations(), + [NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY]: true, + }, + documentWorkspaceApprovedAt: { + [NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY]: basicInfoApprovedAt, + }, + skuItems: [ + createSkuItem({ + id: "sku-factory-install-preserves-confirmation-1", + skuSequence: 1, + model: "ZWJ0218K01", + skuCode: "ZWJ021801", + setCount: "1", + barcode: "ZWJ021801N396V01", + manufacturer: "曹县嘉欢", + manufacturerCode: "N396", + installMethod: "整装", + source: "sku_workbook", + sourceTable: "sku_workbook", + contentLocked: true, + }), + ], + }), + ); + + render(); + await actor.click( + screen.getByTestId("document-workspace-trigger-产品定厂"), + ); + + const factoryWorkspace = screen.getByTestId("product-factory-workspace"); + const installMethodSelect = within(factoryWorkspace).getByRole( + "combobox", + { name: "安装方式" }, + ); + await actor.click(installMethodSelect); + fireEvent.mouseDown(screen.getByRole("option", { name: "拆装" })); + await waitFor(() => expect(installMethodSelect).toHaveTextContent("拆装")); + + await waitFor(() => { + const savedProduct = getNonIntegratedProductById(productId); + expect(savedProduct?.skuItems.map((item) => item.installMethod)).toEqual([ + "拆装", + ]); + expect( + savedProduct?.factoryRows.map((row) => row.installMethod), + ).toEqual(["拆装"]); + expect( + savedProduct?.styleDetails.map((detail) => detail.installMethod), + ).toEqual(["拆装"]); + expect( + savedProduct?.documentWorkspaceConfirmations?.[ + NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY + ], + ).toBe(true); + expect( + savedProduct?.documentWorkspaceApprovedAt?.[ + NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY + ], + ).toBe(basicInfoApprovedAt); + }); + }); + it("fills the first warehouse selection by SPU and keeps later warehouse changes per SKU", async () => { const actor = userEvent.setup(); diff --git a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedFormActionControllers.ts b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedFormActionControllers.ts index b52c1a1a..e68adfc9 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedFormActionControllers.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedFormActionControllers.ts @@ -271,6 +271,7 @@ export function useNonIntegratedFormActionControllers({ productFactoryModelAutofillRef: workflowState.productFactoryModelAutofillRef, productFactoryReopenField: workflowState.productFactoryReopenField, productFactoryRows: workflowRows.productFactoryRows, + scheduleAutoSave, setDocumentWorkspaceApprovedAt: documentState.setDocumentWorkspaceApprovedAt, setDocumentWorkspaceConfirmations: documentState.setDocumentWorkspaceConfirmations, @@ -281,6 +282,9 @@ export function useNonIntegratedFormActionControllers({ setSaveMessageType: overlayState.setSaveMessageType, setSkuItems: skuState.setSkuItems, setStyleDetails: artifactState.setStyleDetails, + skuBatchChangeTypes: skuState.skuBatchChangeTypes, + skuBatchCount: skuState.skuBatchCount, + skuBatchFlowNos: skuState.skuBatchFlowNos, supplierCodeByName: directoryOptions.supplierCodeByName, workflowSkuItems: seriesDerived.workflowSkuItems, }); diff --git a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowConfirmActionController.test.tsx b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowConfirmActionController.test.tsx index d13c0703..b0a6c5a4 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowConfirmActionController.test.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/useNonIntegratedWorkflowConfirmActionController.test.tsx @@ -98,7 +98,7 @@ describe("useNonIntegratedWorkflowConfirmActionController", () => { ); }); - it("still blocks confirmation when the active batch itself is incomplete", async () => { + it("still blocks confirmation when the active batch itself is missing a required field", async () => { const params = createControllerParams({ derivedSkuItems: [ createSkuItem({ @@ -109,7 +109,7 @@ describe("useNonIntegratedWorkflowConfirmActionController", () => { }), createSkuItem({ id: "sku-batch-2", - installMethod: "", + material: "", }), ], }); @@ -126,7 +126,29 @@ describe("useNonIntegratedWorkflowConfirmActionController", () => { expect(params.persistEditedProduct).not.toHaveBeenCalled(); expect(params.setSaveMessage).toHaveBeenCalledWith( - "基础信息未填写完整,SKU XG001254 缺少:安装方式。请补全后再确认。", + "基础信息未填写完整,SKU XG001254 缺少:材质。请补全后再确认。", + ); + }); + + it("allows the active batch to confirm without an install method", async () => { + const params = createControllerParams({ + derivedSkuItems: [createSkuItem({ installMethod: "" })], + }); + jest.spyOn(window, "confirm").mockReturnValue(true); + + const { result } = renderHook(() => + useNonIntegratedWorkflowConfirmActionController(params), + ); + + await act(async () => { + await result.current.handleConfirmWorkflowNode( + NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, + ); + }); + + expect(params.persistEditedProduct).toHaveBeenCalledTimes(1); + expect(params.setSaveMessage).not.toHaveBeenCalledWith( + expect.stringContaining("安装方式"), ); }); }); diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/SkuInfoRow.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/SkuInfoRow.tsx index 488e1bb6..5fb1e861 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/SkuInfoRow.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/SkuInfoRow.tsx @@ -129,7 +129,7 @@ export function SkuInfoRow({ hasLegacySeriesComponentCodeText(derivedItem.drawingId)); const displayAttributeCode = hideLegacySeriesComponentFields ? "" - : item.attributeCode; + : derivedItem.attributeCode; const displayDrawingId = hideLegacySeriesComponentFields ? "" : derivedItem.drawingId; diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuBrandHelpers.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuBrandHelpers.ts index eea87360..f81290f6 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuBrandHelpers.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuBrandHelpers.ts @@ -3,7 +3,10 @@ import { buildNonIntegratedSkuId, type NonIntegratedSkuItem, } from "../../nonIntegratedStorage"; -import { DEFAULT_ASSET_SKU_SALES_STATUS } from "./skuFieldConfig"; +import { + DEFAULT_ASSET_SKU_ATTRIBUTE_CODE, + DEFAULT_ASSET_SKU_SALES_STATUS, +} from "./skuFieldConfig"; import { EMPTY_PREVIEW_NODE_OFFSET_MAP, EMPTY_PREVIEW_NODE_ROTATION_MAP, @@ -30,7 +33,7 @@ export function createEmptySkuItem(): NonIntegratedSkuItem { material: "", boardMaterialType: "", versionNo: "V01", - attributeCode: "", + attributeCode: DEFAULT_ASSET_SKU_ATTRIBUTE_CODE, drawingId: "", installMethod: "", executeStandard: "", diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuDerivationHelpers.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuDerivationHelpers.ts index 67341c2a..d5695ce9 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuDerivationHelpers.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuDerivationHelpers.ts @@ -28,6 +28,7 @@ import { import { isSeriesComponentAssetSkuItem } from "./seriesComponentAssetSkuHelpers"; import { resolveExecutionStandardOption } from "./detailValueHelpers"; import { + DEFAULT_ASSET_SKU_ATTRIBUTE_CODE, normalizeSkuSalesStatus, normalizeNationalStandardCompliance, normalizeSetCountValue, @@ -62,7 +63,9 @@ export function deriveSkuItem( }); const autoSpecName = buildAutoSpecName(item); const versionNo = normalizeSkuVersionNo(item.versionNo); - const normalizedItem = { ...item, versionNo }; + const attributeCode = + item.attributeCode.trim() || DEFAULT_ASSET_SKU_ATTRIBUTE_CODE; + const normalizedItem = { ...item, attributeCode, versionNo }; const importedContentLocked = isImportedSkuContentLocked(item); const skuWorkbookImported = isSkuWorkbookImportedItem(item); const shouldPreserveImportedContent = @@ -134,6 +137,7 @@ export function deriveSkuItem( minimumOrderQuantity: item.minimumOrderQuantity?.trim() ?? "", salesStatus: normalizeSkuSalesStatus(item.salesStatus), boardMaterialType: item.boardMaterialType?.trim() ?? "", + attributeCode, tagMaterial: formValues.tagMaterial.trim() || item.tagMaterial.trim(), }; } diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuFieldUpdateHelpers.test.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuFieldUpdateHelpers.test.ts new file mode 100644 index 00000000..8d9218c0 --- /dev/null +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuFieldUpdateHelpers.test.ts @@ -0,0 +1,54 @@ +import { createEmptySkuItem } from "./skuBrandHelpers"; +import { deriveSkuItem } from "./skuDerivationHelpers"; +import { applySkuFieldUpdate } from "./skuFieldUpdateHelpers"; +import { createFormValuesFromProduct } from "./productFormValueHelpers"; + +describe("SKU basic info field defaults", () => { + it("uses A as the default attribute code for a new SKU", () => { + expect(createEmptySkuItem().attributeCode).toBe("A"); + }); + + it("fills a blank existing attribute code before deriving the drawing ID", () => { + const item = { + ...createEmptySkuItem(), + attributeCode: "", + model: "XJ0101", + drawingId: "", + }; + const formValues = createFormValuesFromProduct(null); + + expect(deriveSkuItem(item, 0, "XJ0101", formValues)).toMatchObject({ + attributeCode: "A", + drawingId: "XJ0101AV01", + }); + }); + + it("sets national standard compliance from a selected board material type", () => { + const item = createEmptySkuItem(); + + expect( + applySkuFieldUpdate(item, "boardMaterialType", "ENF级") + .nationalStandardCompliant, + ).toBe("是"); + expect( + applySkuFieldUpdate(item, "boardMaterialType", "非ENF级") + .nationalStandardCompliant, + ).toBe("否"); + expect( + applySkuFieldUpdate(item, "boardMaterialType", "清货板材") + .nationalStandardCompliant, + ).toBe("否"); + }); + + it("allows the compliance value to be selected independently after autofill", () => { + const item = { + ...createEmptySkuItem(), + nationalStandardCompliant: "是", + }; + + expect( + applySkuFieldUpdate(item, "nationalStandardCompliant", "否") + .nationalStandardCompliant, + ).toBe("否"); + }); +}); diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuFieldUpdateHelpers.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuFieldUpdateHelpers.ts new file mode 100644 index 00000000..cbe25a08 --- /dev/null +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/skuFieldUpdateHelpers.ts @@ -0,0 +1,22 @@ +import type { NonIntegratedSkuItem } from "../../nonIntegratedStorage"; + +export function applySkuFieldUpdate( + item: NonIntegratedSkuItem, + key: keyof NonIntegratedSkuItem, + value: string | number, +): NonIntegratedSkuItem { + if (key !== "boardMaterialType") { + return { ...item, [key]: value }; + } + + const boardMaterialType = String(value); + if (!boardMaterialType) { + return { ...item, boardMaterialType }; + } + + return { + ...item, + boardMaterialType, + nationalStandardCompliant: boardMaterialType === "ENF级" ? "是" : "否", + }; +} diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/useSkuItemEditController.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/useSkuItemEditController.ts index cd9adc4a..9b5e021d 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/useSkuItemEditController.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/basic-info/useSkuItemEditController.ts @@ -16,6 +16,7 @@ import { import type { FormValues } from "./productFormValueHelpers"; import { buildAutoBarcode } from "./skuAutoHelpers"; import { deriveSkuItem } from "./skuDerivationHelpers"; +import { applySkuFieldUpdate } from "./skuFieldUpdateHelpers"; import { isImportedSkuContentLocked } from "./skuSourceHelpers"; type SaveMessageType = "success" | "error"; @@ -107,7 +108,7 @@ export function useSkuItemEditController({ }; } - return { ...item, [key]: value }; + return applySkuFieldUpdate(item, key, value); }); }); setSaveMessage(""); diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/preview/previewState.test.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/preview/previewState.test.ts index 66361f89..b8a4c752 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/preview/previewState.test.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/preview/previewState.test.ts @@ -86,4 +86,13 @@ describe("buildSkuContentRevisionSignature", () => { buildSignature(createSku()), ); }); + + it("keeps install method and set count revision-sensitive for direct basic information edits", () => { + expect(buildSignature(createSku({ installMethod: "整装" }))).not.toBe( + buildSignature(createSku()), + ); + expect(buildSignature(createSku({ setCount: "2" }))).not.toBe( + buildSignature(createSku()), + ); + }); }); diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/useProductFactoryRowController.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/useProductFactoryRowController.ts index 01dd678e..9420f086 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/useProductFactoryRowController.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/product-factory/useProductFactoryRowController.ts @@ -5,11 +5,12 @@ import type { } from "react"; import type { PersistEditedProductOverrides } from "../../nonIntegratedDetailTypes"; -import type { - NonIntegratedDocumentWorkspaceApprovalMap, - NonIntegratedDocumentWorkspaceConfirmationMap, - NonIntegratedFactoryRow, - NonIntegratedSkuItem, +import { + NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, + type NonIntegratedDocumentWorkspaceApprovalMap, + type NonIntegratedDocumentWorkspaceConfirmationMap, + type NonIntegratedFactoryRow, + type NonIntegratedSkuItem, } from "../../nonIntegratedStorage"; import { updateStyleDetailEntry, type StyleDetailForm } from "../style-assets/styleDetailState"; import { @@ -32,6 +33,7 @@ import { updateProductFactoryRowsForSku, } from "./productFactoryRowUpdateHelpers"; import { getProductFactoryTargetSkuIds } from "./productFactoryTargetSkuHelpers"; +import { buildSkuContentRevisionSignature } from "../preview/previewState"; type SaveMessageType = "success" | "error"; @@ -71,6 +73,7 @@ type UseProductFactoryRowControllerParams = { >; productFactoryReopenField: ProductFactoryEditableKey; productFactoryRows: NonIntegratedFactoryRow[]; + scheduleAutoSave: () => void; setDocumentWorkspaceApprovedAt: Dispatch< SetStateAction >; @@ -83,6 +86,9 @@ type UseProductFactoryRowControllerParams = { setSaveMessageType: Dispatch>; setSkuItems: Dispatch>; setStyleDetails: Dispatch>>; + skuBatchChangeTypes: Record; + skuBatchCount: number; + skuBatchFlowNos: Record; supplierCodeByName: ReadonlyMap; workflowSkuItems: NonIntegratedSkuItem[]; }; @@ -106,6 +112,7 @@ export function useProductFactoryRowController({ productFactoryModelAutofillRef, productFactoryReopenField, productFactoryRows, + scheduleAutoSave, setDocumentWorkspaceApprovedAt, setDocumentWorkspaceConfirmations, setFactoryRows, @@ -114,9 +121,33 @@ export function useProductFactoryRowController({ setSaveMessageType, setSkuItems, setStyleDetails, + skuBatchChangeTypes, + skuBatchCount, + skuBatchFlowNos, supplierCodeByName, workflowSkuItems, }: UseProductFactoryRowControllerParams) { + function setFactoryProjectedSkuItems( + update: (current: NonIntegratedSkuItem[]) => NonIntegratedSkuItem[], + ) { + setSkuItems((current) => { + const nextSkuItems = update(current); + if (documentWorkspaceRevisionSignatureRef.current) { + documentWorkspaceRevisionSignatureRef.current = { + ...documentWorkspaceRevisionSignatureRef.current, + [NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY]: + buildSkuContentRevisionSignature({ + skuItems: nextSkuItems, + skuBatchCount, + skuBatchFlowNos, + skuBatchChangeTypes, + }), + }; + } + return nextSkuItems; + }); + } + function updateProductFactoryRow( skuId: string, key: ProductFactoryEditableKey, @@ -135,81 +166,82 @@ export function useProductFactoryRowController({ ); const supplierName = supplier.name; - setSkuItems((current) => { - const targetSkuIds = getProductFactoryTargetSkuIds( - workflowSkuItems, - skuId, - key, - ); - return current.map((item, index) => + const targetSkuIds = getProductFactoryTargetSkuIds( + workflowSkuItems, + skuId, + key, + ); + setFactoryProjectedSkuItems((current) => + current.map((item, index) => targetSkuIds.has(item.id) ? applySkuSupplierName(item, index, supplierName) : item, - ); - }); + ), + ); } if (key === "setCount") { - setSkuItems((current) => { - const targetSkuIds = getProductFactoryTargetSkuIds( - workflowSkuItems, - skuId, - key, - ); - return current.map((item) => + const targetSkuIds = getProductFactoryTargetSkuIds( + workflowSkuItems, + skuId, + key, + ); + setFactoryProjectedSkuItems((current) => + current.map((item) => targetSkuIds.has(item.id) ? { ...item, setCount: value } : item, - ); - }); + ), + ); } if (key === "minimumOrderQuantity") { - setSkuItems((current) => { - const targetSkuIds = getProductFactoryTargetSkuIds( - workflowSkuItems, - skuId, - key, - productFactoryRows, - value, - productFactoryModelAutofillRef.current, - ); - return current.map((item) => + const targetSkuIds = getProductFactoryTargetSkuIds( + workflowSkuItems, + skuId, + key, + productFactoryRows, + value, + productFactoryModelAutofillRef.current, + ); + setFactoryProjectedSkuItems((current) => + current.map((item) => targetSkuIds.has(item.id) ? { ...item, minimumOrderQuantity: value } : item, - ); - }); + ), + ); } if (key === "installMethod") { - setSkuItems((current) => { - const targetSkuIds = getProductFactoryTargetSkuIds( - workflowSkuItems, - skuId, - key, - ); - const targetStyleKeys = new Set( - workflowSkuItems - .filter((item) => targetSkuIds.has(item.id)) - .map((item) => getStyleKeyFromModel(item.model)) - .filter(Boolean), - ); + const targetSkuIds = getProductFactoryTargetSkuIds( + workflowSkuItems, + skuId, + key, + ); + const targetStyleKeys = new Set( + workflowSkuItems + .filter((item) => targetSkuIds.has(item.id)) + .map((item) => getStyleKeyFromModel(item.model)) + .filter(Boolean), + ); - if (targetStyleKeys.size > 0) { - setStyleDetails((currentDetails) => { - let nextDetails = currentDetails; - targetStyleKeys.forEach((styleKey) => { - nextDetails = updateStyleDetailEntry(nextDetails, styleKey, { - installMethod: value, - }); + if (targetStyleKeys.size > 0) { + setStyleDetails((currentDetails) => { + let nextDetails = currentDetails; + targetStyleKeys.forEach((styleKey) => { + nextDetails = updateStyleDetailEntry(nextDetails, styleKey, { + installMethod: value, }); - return nextDetails; }); - } + return nextDetails; + }); + } - return current.map((item) => + setFactoryProjectedSkuItems((current) => + current.map((item) => targetSkuIds.has(item.id) ? { ...item, installMethod: value } : item, - ); - }); + ), + ); + scheduleAutoSave(); } setFactoryRows((current) => @@ -238,7 +270,7 @@ export function useProductFactoryRowController({ const supplierName = supplier.name; if (supplierName) { - setSkuItems((current) => + setFactoryProjectedSkuItems((current) => current.map((item, index) => item.id === skuId ? applySkuSupplierName(item, index, supplierName) @@ -390,7 +422,7 @@ export function useProductFactoryRowController({ ); if (currentFactoryRow?.confirmed) return; - setSkuItems((current) => + setFactoryProjectedSkuItems((current) => buildSkuItemsAfterSetCountCommit({ allowImportedContentLocked: true, current, diff --git a/furniture-3d-system/src/app/intake/operation-logs/ProductDevelopmentOperationLogClient.test.tsx b/furniture-3d-system/src/app/intake/operation-logs/ProductDevelopmentOperationLogClient.test.tsx index d0ad1fc6..b3ff11d6 100644 --- a/furniture-3d-system/src/app/intake/operation-logs/ProductDevelopmentOperationLogClient.test.tsx +++ b/furniture-3d-system/src/app/intake/operation-logs/ProductDevelopmentOperationLogClient.test.tsx @@ -1,7 +1,16 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { render, screen } from '@testing-library/react' import type { ReactNode } from 'react' +import userEvent from '@testing-library/user-event' import ProductDevelopmentOperationLogClient from './ProductDevelopmentOperationLogClient' +const replaceMock = jest.fn() + +jest.mock('next/navigation', () => ({ + usePathname: () => '/intake/operation-logs', + useRouter: () => ({ replace: replaceMock }), + useSearchParams: () => new URLSearchParams(), +})) + jest.mock('next/link', () => ({ __esModule: true, default: ({ children, href }: { children: ReactNode; href: string }) => ( @@ -9,26 +18,36 @@ jest.mock('next/link', () => ({ ), })) -describe('ProductDevelopmentOperationLogClient', () => { - it('shows the product development title and return link', async () => { - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - success: true, - data: [], - meta: { pagination: { page: 1, total: 0, totalPages: 1, hasMore: false } }, - }), - }) as jest.Mock +jest.mock('@/app/operation-logs/OperationLogClient', () => ({ + __esModule: true, + default: () =>
操作日志内容
, +})) +jest.mock('@/app/notification-logs/NotificationLogClient', () => ({ + __esModule: true, + default: () =>
通知记录内容
, +})) + +describe('ProductDevelopmentOperationLogClient', () => { + beforeEach(() => replaceMock.mockReset()) + + it('shows operation and notification logs as tabs on the same page', async () => { + const actor = userEvent.setup() render() - expect(screen.getByRole('heading', { name: '\u4ea7\u54c1\u5f00\u53d1\u64cd\u4f5c\u65e5\u5fd7' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: '产品开发日志' })).toBeInTheDocument() expect(screen.getByRole('link', { name: '\u8fd4\u56de\u4ea7\u54c1\u8d44\u6599' })).toHaveAttribute('href', '/intake') - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/api/operation-logs?'), - { cache: 'no-store' }, - ) - }) + expect(screen.getByRole('tab', { name: '操作日志' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('tab', { name: '通知记录' })).toHaveAttribute('aria-selected', 'false') + expect(screen.getByRole('tabpanel', { name: '操作日志' })).toBeVisible() + + await actor.click(screen.getByRole('tab', { name: '通知记录' })) + + expect(screen.getByRole('tab', { name: '通知记录' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('tabpanel', { name: '通知记录' })).toBeVisible() + expect(replaceMock).toHaveBeenCalledWith( + '/intake/operation-logs?tab=notifications', + { scroll: false }, + ) }) }) diff --git a/furniture-3d-system/src/app/intake/operation-logs/ProductDevelopmentOperationLogClient.tsx b/furniture-3d-system/src/app/intake/operation-logs/ProductDevelopmentOperationLogClient.tsx index 57f83ccf..668833bc 100644 --- a/furniture-3d-system/src/app/intake/operation-logs/ProductDevelopmentOperationLogClient.tsx +++ b/furniture-3d-system/src/app/intake/operation-logs/ProductDevelopmentOperationLogClient.tsx @@ -1,31 +1,110 @@ 'use client' import Link from 'next/link' -import { ExternalLink, FileWarning } from 'lucide-react' +import { useEffect, useState } from 'react' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { BellRing, ExternalLink, FileWarning } from 'lucide-react' import OperationLogClient from '@/app/operation-logs/OperationLogClient' +import NotificationLogClient from '@/app/notification-logs/NotificationLogClient' + +type LogTab = 'operations' | 'notifications' + +const tabs: Array<{ value: LogTab; label: string; icon: typeof FileWarning }> = [ + { value: 'operations', label: '操作日志', icon: FileWarning }, + { value: 'notifications', label: '通知记录', icon: BellRing }, +] export default function ProductDevelopmentOperationLogClient() { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + const requestedTab = searchParams.get('tab') + const [activeTab, setActiveTab] = useState( + requestedTab === 'notifications' ? 'notifications' : 'operations', + ) + + useEffect(() => { + const syncTabFromHistory = () => { + const tab = new URLSearchParams(window.location.search).get('tab') + setActiveTab(tab === 'notifications' ? 'notifications' : 'operations') + } + window.addEventListener('popstate', syncTabFromHistory) + return () => window.removeEventListener('popstate', syncTabFromHistory) + }, []) + + const selectTab = (tab: LogTab) => { + setActiveTab(tab) + const params = new URLSearchParams(searchParams.toString()) + params.set('tab', tab) + router.replace(`${pathname}?${params.toString()}`, { scroll: false }) + } + return ( <>
-
-
-
- - 产品开发 +
+
+
+
+ + 产品开发 +
+

产品开发日志

-

产品开发操作日志

+ + 返回产品资料 + + +
+
+ {tabs.map((tab) => { + const Icon = tab.icon + const selected = activeTab === tab.value + return ( + + ) + })}
- - 返回产品资料 - -
- + + ) } diff --git a/furniture-3d-system/src/app/intake/operation-logs/page.tsx b/furniture-3d-system/src/app/intake/operation-logs/page.tsx index 4fd24758..0d0d69ae 100644 --- a/furniture-3d-system/src/app/intake/operation-logs/page.tsx +++ b/furniture-3d-system/src/app/intake/operation-logs/page.tsx @@ -4,7 +4,7 @@ import { verifyToken } from '@/lib/jwt' import ProductDevelopmentOperationLogClient from './ProductDevelopmentOperationLogClient' export const metadata = { - title: 'Product Development Operation Logs | JianSun Product Development', + title: '产品开发日志 | 家具业务系统', } export default async function ProductDevelopmentOperationLogPage() { diff --git a/furniture-3d-system/src/app/intake/product-development/nonIntegratedListState.test.ts b/furniture-3d-system/src/app/intake/product-development/nonIntegratedListState.test.ts new file mode 100644 index 00000000..258f5bfb --- /dev/null +++ b/furniture-3d-system/src/app/intake/product-development/nonIntegratedListState.test.ts @@ -0,0 +1,58 @@ +import { + buildNonIntegratedListStateStorageKey, + createDefaultNonIntegratedListQuery, + migrateLegacyDefaultProductTypeState, + type PersistedNonIntegratedListState, +} from "./nonIntegratedListState"; + +describe("non-integrated product list defaults", () => { + it("defaults the product type filter to all", () => { + expect(createDefaultNonIntegratedListQuery("all").productTagFilter).toBe("all"); + }); + + it("migrates only the untouched legacy default state", () => { + const state: PersistedNonIntegratedListState = { + queryDraft: { + ...createDefaultNonIntegratedListQuery("new"), + productTagFilter: "finished", + }, + appliedQuery: { + ...createDefaultNonIntegratedListQuery("new"), + productTagFilter: "finished", + }, + currentPage: 1, + activeQuickScope: "all", + }; + + expect(migrateLegacyDefaultProductTypeState(state)).toEqual({ + ...state, + queryDraft: expect.objectContaining({ productTagFilter: "all" }), + appliedQuery: expect.objectContaining({ productTagFilter: "all" }), + }); + }); + + it("preserves a manually filtered legacy state", () => { + const state: PersistedNonIntegratedListState = { + queryDraft: { + ...createDefaultNonIntegratedListQuery("new"), + productTagFilter: "finished", + seriesName: "已选系列", + }, + appliedQuery: { + ...createDefaultNonIntegratedListQuery("new"), + productTagFilter: "finished", + seriesName: "已选系列", + }, + currentPage: 1, + activeQuickScope: "all", + }; + + expect(migrateLegacyDefaultProductTypeState(state)).toBe(state); + }); + + it("uses the existing product list storage key", () => { + expect(buildNonIntegratedListStateStorageKey("product", "new")).toBe( + "non-integrated-list-state-v2:product:new", + ); + }); +}); diff --git a/furniture-3d-system/src/app/intake/product-development/nonIntegratedListState.ts b/furniture-3d-system/src/app/intake/product-development/nonIntegratedListState.ts index 07df696e..488ab322 100644 --- a/furniture-3d-system/src/app/intake/product-development/nonIntegratedListState.ts +++ b/furniture-3d-system/src/app/intake/product-development/nonIntegratedListState.ts @@ -39,13 +39,15 @@ export type PersistedNonIntegratedListState = { const NON_INTEGRATED_LIST_STATE_STORAGE_PREFIX = "non-integrated-list-state-v2"; +const PRODUCT_TYPE_DEFAULT_MIGRATION_KEY = + "non-integrated-product-type-default-v1"; export function createDefaultNonIntegratedListQuery( productKind: NonIntegratedProductKindFilter, ): NonIntegratedListQuery { return { productKind, - productTagFilter: "finished", + productTagFilter: "all", seriesName: "", productCategory: "", createdDateFrom: "", @@ -66,6 +68,50 @@ export function createDefaultNonIntegratedListQuery( }; } +function isLegacyDefaultProductListState( + state: PersistedNonIntegratedListState, + productKind: NonIntegratedProductKindFilter, +) { + const defaultQuery = createDefaultNonIntegratedListQuery(productKind); + const legacyQuery = { ...defaultQuery, productTagFilter: "finished" }; + + if (state.currentPage !== undefined && normalizePersistedPage(state.currentPage) !== 1) { + return false; + } + if ( + state.activeQuickScope !== undefined && + normalizePersistedQuickScope(state.activeQuickScope) !== "all" + ) { + return false; + } + + return [state.queryDraft, state.appliedQuery].every((query) => { + if (!query) return true; + const keys = Object.keys(query); + if (keys.some((key) => !(key in legacyQuery))) return false; + return keys.every( + (key) => query[key as keyof NonIntegratedListQuery] === legacyQuery[key as keyof NonIntegratedListQuery], + ); + }); +} + +export function migrateLegacyDefaultProductTypeState( + state: PersistedNonIntegratedListState, + productKind: NonIntegratedProductKindFilter = "new", +): PersistedNonIntegratedListState { + if (!isLegacyDefaultProductListState(state, productKind)) return state; + + return { + ...state, + queryDraft: state.queryDraft + ? { ...state.queryDraft, productTagFilter: "all" } + : state.queryDraft, + appliedQuery: state.appliedQuery + ? { ...state.appliedQuery, productTagFilter: "all" } + : state.appliedQuery, + }; +} + export function buildNonIntegratedListStateStorageKey( productDevelopmentKind: ProductDevelopmentKind, fixedProductKind?: NonIntegratedProductKindFilter, @@ -123,9 +169,21 @@ export function readPersistedNonIntegratedListState( if (!rawValue) return null; const parsed = JSON.parse(rawValue); - return parsed && typeof parsed === "object" - ? (parsed as PersistedNonIntegratedListState) - : null; + if (!parsed || typeof parsed !== "object") return null; + + const state = parsed as PersistedNonIntegratedListState; + if ( + key !== buildNonIntegratedListStateStorageKey("product", "new") || + window.localStorage.getItem(PRODUCT_TYPE_DEFAULT_MIGRATION_KEY) === "1" || + !isLegacyDefaultProductListState(state, "new") + ) { + return state; + } + + const migrated = migrateLegacyDefaultProductTypeState(state); + window.localStorage.setItem(key, JSON.stringify(migrated)); + window.localStorage.setItem(PRODUCT_TYPE_DEFAULT_MIGRATION_KEY, "1"); + return migrated; } catch { return null; } diff --git a/furniture-3d-system/src/app/notification-logs/NotificationLogClient.test.tsx b/furniture-3d-system/src/app/notification-logs/NotificationLogClient.test.tsx new file mode 100644 index 00000000..63da2713 --- /dev/null +++ b/furniture-3d-system/src/app/notification-logs/NotificationLogClient.test.tsx @@ -0,0 +1,36 @@ +import { render, screen, waitFor } from '@testing-library/react' +import NotificationLogClient from './NotificationLogClient' + +describe('NotificationLogClient', () => { + afterEach(() => { + Reflect.deleteProperty(global, 'fetch') + }) + + it('renders the independent administrator notification log page', async () => { + Object.defineProperty(global, 'fetch', { + configurable: true, + writable: true, + value: jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + data: { + records: [], + integrationConfigured: true, + syncWarning: null, + }, + meta: { + pagination: { total: 0, totalPages: 1 }, + }, + }), + }), + }) + + render() + + expect(screen.getByRole('heading', { name: '通知记录' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /发送/ })).not.toBeInTheDocument() + await waitFor(() => expect(screen.getByText('暂无通知记录')).toBeInTheDocument()) + expect(screen.getByRole('navigation', { name: '通知记录分页' })).toBeInTheDocument() + }) +}) diff --git a/furniture-3d-system/src/app/notification-logs/NotificationLogClient.tsx b/furniture-3d-system/src/app/notification-logs/NotificationLogClient.tsx new file mode 100644 index 00000000..e6b848cc --- /dev/null +++ b/furniture-3d-system/src/app/notification-logs/NotificationLogClient.tsx @@ -0,0 +1,158 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { BellRing } from 'lucide-react' +import { NotificationLogDetailDrawer } from './NotificationLogDetailDrawer' +import { NotificationLogFilters } from './NotificationLogFilters' +import { NotificationLogPagination } from './NotificationLogPagination' +import { NotificationLogTable } from './NotificationLogTable' +import type { NotificationDeliveryRecord, NotificationStatus } from './notificationLogPresentation' + +type ApiPayload = { + success: boolean + data?: { + records: NotificationDeliveryRecord[] + integrationConfigured: boolean + syncWarning: string | null + } + meta?: { + pagination?: { total: number; totalPages: number } + } + error?: { message?: string } +} + +const ACTIVE_STATUSES = new Set(['pending', 'sending', 'retrying', 'sent', 'unread']) + +export default function NotificationLogClient({ embedded = false }: { embedded?: boolean }) { + const [records, setRecords] = useState([]) + const [selected, setSelected] = useState(null) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [error, setError] = useState(null) + const [configured, setConfigured] = useState(true) + const [syncWarning, setSyncWarning] = useState(null) + const [page, setPage] = useState(1) + const [pageInput, setPageInput] = useState('1') + const [pageSize, setPageSize] = useState(50) + const [total, setTotal] = useState(0) + const [totalPages, setTotalPages] = useState(1) + const [keyword, setKeyword] = useState('') + const [committedKeyword, setCommittedKeyword] = useState('') + const [status, setStatus] = useState('') + const [startDate, setStartDate] = useState('') + const [endDate, setEndDate] = useState('') + + useEffect(() => { + const timer = window.setTimeout(() => { + setCommittedKeyword(keyword.trim()) + setPage(1) + }, 300) + return () => window.clearTimeout(timer) + }, [keyword]) + + const query = useMemo(() => { + const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize), refresh: 'true' }) + if (committedKeyword) params.set('keyword', committedKeyword) + if (status) params.set('status', status) + if (startDate) params.set('startDate', startDate) + if (endDate) params.set('endDate', endDate) + return params.toString() + }, [committedKeyword, endDate, page, pageSize, startDate, status]) + + const load = useCallback(async (background = false) => { + if (background) { + setRefreshing(true) + } else { + setLoading(true) + } + setError(null) + try { + const response = await fetch(`/api/notification-deliveries?${query}`, { cache: 'no-store' }) + const payload = (await response.json()) as ApiPayload + if (!response.ok || !payload.success || !payload.data) { + throw new Error(payload.error?.message || '通知记录读取失败') + } + setRecords(payload.data.records) + setConfigured(payload.data.integrationConfigured) + setSyncWarning(payload.data.syncWarning) + setTotal(payload.meta?.pagination?.total ?? 0) + setTotalPages(payload.meta?.pagination?.totalPages ?? 1) + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : '通知记录读取失败') + } finally { + setLoading(false) + setRefreshing(false) + } + }, [query]) + + useEffect(() => { + void load() + }, [load]) + + const hasActiveDelivery = records.some((record) => ACTIVE_STATUSES.has(record.status)) + useEffect(() => { + if (!hasActiveDelivery) return + const timer = window.setInterval(() => void load(true), 10_000) + return () => window.clearInterval(timer) + }, [hasActiveDelivery, load]) + + useEffect(() => setPageInput(String(page)), [page]) + + const changeFilter = (change: () => void) => { + setPage(1) + change() + } + const commitPageInput = () => { + const nextPage = Math.min(totalPages, Math.max(1, Number(pageInput) || 1)) + setPage(nextPage) + setPageInput(String(nextPage)) + } + + return ( +
+ {!embedded ? ( +
+
+
管理员后台
+

通知记录

+
+

钉钉个人通知投递审计

+
+ ) : null} + {!configured ? ( +
通知服务参数尚未配置,当前只能查看已有记录。
+ ) : null} + {syncWarning ? ( +
{syncWarning}
+ ) : null} +
+ changeFilter(() => setStatus(value))} + onStartDateChange={(value) => changeFilter(() => setStartDate(value))} + onEndDateChange={(value) => changeFilter(() => setEndDate(value))} + onRefresh={() => void load(true)} + /> + +
+ { setPageSize(value); setPage(1) }} + /> + {selected ? setSelected(null)} /> : null} +
+ ) +} diff --git a/furniture-3d-system/src/app/notification-logs/NotificationLogDetailDrawer.tsx b/furniture-3d-system/src/app/notification-logs/NotificationLogDetailDrawer.tsx new file mode 100644 index 00000000..651e3032 --- /dev/null +++ b/furniture-3d-system/src/app/notification-logs/NotificationLogDetailDrawer.tsx @@ -0,0 +1,68 @@ +import { ExternalLink, X } from 'lucide-react' +import { NotificationStatusBadge, formatNotificationTime, type NotificationDeliveryRecord } from './notificationLogPresentation' + +export function NotificationLogDetailDrawer({ + record, + onClose, +}: { + record: NotificationDeliveryRecord + onClose: () => void +}) { + const fields = [ + ['创建时间', formatNotificationTime(record.createdAt)], + ['发送时间', formatNotificationTime(record.sentAt)], + ['已读时间', formatNotificationTime(record.readAt)], + ['收件人', `${record.recipientName} / ${record.recipientEmail}`], + ['钉钉用户', record.recipientDingtalkUserId], + ['触发来源', record.businessEvent], + ['操作人', record.source === 'SYSTEM' ? '系统任务' : record.initiatedByName], + ['业务记录', record.businessRecordId || '-'], + ['投递编号', record.externalDeliveryId || '-'], + ['错误码', record.lastErrorCode || '-'], + ] + + return ( +
+ +
+ ) +} diff --git a/furniture-3d-system/src/app/notification-logs/NotificationLogFilters.tsx b/furniture-3d-system/src/app/notification-logs/NotificationLogFilters.tsx new file mode 100644 index 00000000..84d4e80b --- /dev/null +++ b/furniture-3d-system/src/app/notification-logs/NotificationLogFilters.tsx @@ -0,0 +1,65 @@ +import { RefreshCw, Search } from 'lucide-react' +import { statusLabels, type NotificationStatus } from './notificationLogPresentation' + +type Props = { + keyword: string + status: NotificationStatus | '' + startDate: string + endDate: string + refreshing: boolean + onKeywordChange: (value: string) => void + onStatusChange: (value: NotificationStatus | '') => void + onStartDateChange: (value: string) => void + onEndDateChange: (value: string) => void + onRefresh: () => void +} + +export function NotificationLogFilters(props: Props) { + return ( +
+ + + props.onStartDateChange(event.target.value)} + aria-label="开始日期" + className="h-9 rounded-[5px] border border-zinc-300 bg-white px-3 text-sm text-zinc-800 outline-none" + /> + props.onEndDateChange(event.target.value)} + aria-label="结束日期" + className="h-9 rounded-[5px] border border-zinc-300 bg-white px-3 text-sm text-zinc-800 outline-none" + /> + +
+ ) +} diff --git a/furniture-3d-system/src/app/notification-logs/NotificationLogPagination.tsx b/furniture-3d-system/src/app/notification-logs/NotificationLogPagination.tsx new file mode 100644 index 00000000..e4d950bf --- /dev/null +++ b/furniture-3d-system/src/app/notification-logs/NotificationLogPagination.tsx @@ -0,0 +1,69 @@ +import { ArrowLeft, ArrowRight } from 'lucide-react' + +type Props = { + page: number + pageInput: string + pageSize: number + total: number + totalPages: number + loading: boolean + onPageChange: (page: number) => void + onPageInputChange: (value: string) => void + onCommitPageInput: () => void + onPageSizeChange: (pageSize: number) => void +} + +export function NotificationLogPagination(props: Props) { + return ( + + ) +} diff --git a/furniture-3d-system/src/app/notification-logs/NotificationLogTable.tsx b/furniture-3d-system/src/app/notification-logs/NotificationLogTable.tsx new file mode 100644 index 00000000..4dc1efeb --- /dev/null +++ b/furniture-3d-system/src/app/notification-logs/NotificationLogTable.tsx @@ -0,0 +1,60 @@ +import { NotificationStatusBadge, formatNotificationTime, type NotificationDeliveryRecord } from './notificationLogPresentation' + +type Props = { + records: NotificationDeliveryRecord[] + loading: boolean + error: string | null + onSelect: (record: NotificationDeliveryRecord) => void +} + +export function NotificationLogTable({ records, loading, error, onSelect }: Props) { + return ( +
+ + + + + + + + + + + + + {loading ? ( + + ) : error ? ( + + ) : records.length === 0 ? ( + + ) : records.map((record) => ( + onSelect(record)} + className="cursor-pointer bg-white transition hover:bg-zinc-50" + > + + + + + + + + ))} + +
创建时间状态收件人消息标题触发来源发送结果
正在读取通知记录
{error}
暂无通知记录
{formatNotificationTime(record.createdAt)} +
{record.recipientName}
+
{record.recipientEmail}
+
+

{record.title}

+

{record.content}

+
+
{record.businessEvent}
+
{record.source === 'SYSTEM' ? '系统任务' : record.initiatedByName}
+
+ {record.lastErrorMessage || (record.sentAt ? `发送于 ${formatNotificationTime(record.sentAt)}` : '已提交至通知中心')} +
+
+ ) +} diff --git a/furniture-3d-system/src/app/notification-logs/notificationLogPresentation.tsx b/furniture-3d-system/src/app/notification-logs/notificationLogPresentation.tsx new file mode 100644 index 00000000..6ee32697 --- /dev/null +++ b/furniture-3d-system/src/app/notification-logs/notificationLogPresentation.tsx @@ -0,0 +1,80 @@ +import { + AlertTriangle, + CheckCircle2, + Clock3, + Eye, + LoaderCircle, + MailCheck, +} from 'lucide-react' + +export type NotificationStatus = + | 'pending' + | 'sending' + | 'retrying' + | 'sent' + | 'unread' + | 'read' + | 'expired' + | 'failed' + +export type NotificationDeliveryRecord = { + id: string + externalDeliveryId: string | null + businessEvent: string + businessRecordId: string | null + source: string + initiatedByName: string + recipientUserId: string + recipientName: string + recipientEmail: string + recipientDingtalkUserId: string + title: string + content: string + actionTitle: string + actionUrl: string + status: NotificationStatus + lastErrorCode: string | null + lastErrorMessage: string | null + sentAt: string | null + readAt: string | null + lastSyncedAt: string | null + createdAt: string + updatedAt: string +} + +export const statusLabels: Record = { + pending: '等待发送', + sending: '发送中', + retrying: '等待重试', + sent: '已发送', + unread: '未读', + read: '已读', + expired: '已过期', + failed: '失败', +} + +export function formatNotificationTime(value: string | null) { + if (!value) return '-' + return new Date(value).toLocaleString('zh-CN', { hour12: false }) +} + +export function NotificationStatusBadge({ status }: { status: NotificationStatus }) { + const config = { + pending: { Icon: Clock3, classes: 'border-zinc-200 bg-zinc-50 text-zinc-600' }, + sending: { Icon: LoaderCircle, classes: 'border-sky-200 bg-sky-50 text-sky-700' }, + retrying: { Icon: Clock3, classes: 'border-amber-200 bg-amber-50 text-amber-700' }, + sent: { Icon: MailCheck, classes: 'border-emerald-200 bg-emerald-50 text-emerald-700' }, + unread: { Icon: Eye, classes: 'border-cyan-200 bg-cyan-50 text-cyan-700' }, + read: { Icon: CheckCircle2, classes: 'border-emerald-200 bg-emerald-50 text-emerald-700' }, + expired: { Icon: Clock3, classes: 'border-zinc-200 bg-zinc-100 text-zinc-500' }, + failed: { Icon: AlertTriangle, classes: 'border-red-200 bg-red-50 text-red-700' }, + }[status] + const Icon = config.Icon + + return ( + + + {statusLabels[status]} + + ) +} diff --git a/furniture-3d-system/src/app/notification-logs/page.tsx b/furniture-3d-system/src/app/notification-logs/page.tsx new file mode 100644 index 00000000..41437bd7 --- /dev/null +++ b/furniture-3d-system/src/app/notification-logs/page.tsx @@ -0,0 +1,9 @@ +import { redirect } from 'next/navigation' + +export const metadata = { + title: '通知记录 | 家具业务系统', +} + +export default async function NotificationLogsPage() { + redirect('/intake/operation-logs?tab=notifications') +} diff --git a/furniture-3d-system/src/components/ui/__tests__/UserMenu.notification-log.test.tsx b/furniture-3d-system/src/components/ui/__tests__/UserMenu.notification-log.test.tsx new file mode 100644 index 00000000..bf6eb147 --- /dev/null +++ b/furniture-3d-system/src/components/ui/__tests__/UserMenu.notification-log.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import UserMenu from '../UserMenu' + +const mockPush = jest.fn() + +jest.mock('next/navigation', () => ({ + usePathname: () => '/intake', + useRouter: () => ({ push: mockPush, refresh: jest.fn() }), +})) + +function user(role: string) { + return { + id: `${role.toLowerCase()}-1`, + name: role, + email: `${role.toLowerCase()}@example.com`, + role, + avatar: null, + department: null, + } +} + +describe('UserMenu operation log entry', () => { + beforeEach(() => mockPush.mockReset()) + + it('keeps one operation log entry for administrators', async () => { + const actor = userEvent.setup() + render() + + await actor.click(screen.getByRole('button', { name: /ADMIN/ })) + await actor.click(screen.getByRole('button', { name: '操作日志' })) + + expect(mockPush).toHaveBeenCalledWith('/intake/operation-logs') + expect(screen.queryByRole('button', { name: '通知记录' })).not.toBeInTheDocument() + }) + + it('hides operation logs from regular users', async () => { + const actor = userEvent.setup() + render() + + await actor.click(screen.getByRole('button', { name: /USER/ })) + + expect(screen.queryByRole('button', { name: '操作日志' })).not.toBeInTheDocument() + }) +}) diff --git a/furniture-3d-system/src/lib/open-product/basic-info-confirmation-validation.ts b/furniture-3d-system/src/lib/open-product/basic-info-confirmation-validation.ts index 74e4f426..9dbdd5c0 100644 --- a/furniture-3d-system/src/lib/open-product/basic-info-confirmation-validation.ts +++ b/furniture-3d-system/src/lib/open-product/basic-info-confirmation-validation.ts @@ -11,7 +11,6 @@ type BasicInfoRequiredSkuField = | "versionNo" | "attributeCode" | "drawingId" - | "installMethod" | "executeStandard" | "nationalStandardCompliant" | "batchNo"; @@ -30,7 +29,6 @@ type BasicInfoConfirmationSku = { versionNo: string; attributeCode: string; drawingId: string; - installMethod: string; executeStandard: string; nationalStandardCompliant: string; batchNo: number; @@ -52,7 +50,6 @@ export const BASIC_INFO_CONFIRMATION_REQUIRED_FIELDS: ReadonlyArray<{ { key: "versionNo", label: "版本号" }, { key: "attributeCode", label: "属性代码" }, { key: "drawingId", label: "图纸ID" }, - { key: "installMethod", label: "安装方式" }, { key: "executeStandard", label: "执行标准" }, { key: "nationalStandardCompliant", label: "符合国标" }, { key: "batchNo", label: "创建批次" }, diff --git a/furniture-3d-system/src/server/integrations/notification-center/client.test.ts b/furniture-3d-system/src/server/integrations/notification-center/client.test.ts new file mode 100644 index 00000000..36b4b588 --- /dev/null +++ b/furniture-3d-system/src/server/integrations/notification-center/client.test.ts @@ -0,0 +1,89 @@ +/** @jest-environment node */ + +import { createHash, createHmac } from 'node:crypto' +import { NotificationCenterClient, NotificationCenterRequestError } from './client' + +const PROJECT_SECRET = 's'.repeat(64) + +describe('NotificationCenterClient', () => { + it('signs the exact request body and sends only server credentials in headers', async () => { + const fetchImpl = jest.fn(async () => new Response(JSON.stringify({ + id: 'delivery-1', + projectCode: 'furniture_platform', + status: 'pending', + recipientUserId: 'ding-1', + title: '【家具业务系统】测试消息', + processQueryKey: null, + lastErrorCode: null, + lastErrorMessage: null, + sentAt: null, + readAt: null, + createdAt: '2026-08-26T07:00:00.000Z', + updatedAt: '2026-08-26T07:00:00.000Z', + }), { status: 202, headers: { 'content-type': 'application/json' } })) as jest.MockedFunction + const client = new NotificationCenterClient({ + baseUrl: 'https://sc.jiansun.vip/notification-center', + projectCode: 'furniture_platform', + projectSecret: PROJECT_SECRET, + fetchImpl, + now: () => 1_700_000_000_000, + createNonce: () => 'fixed-notification-nonce', + }) + const input = { + idempotencyKey: 'test:record-1:ding-1:v1', + recipientUserId: 'ding-1', + title: '测试消息', + content: '测试正文', + actionTitle: '打开产品开发', + actionUrl: 'https://cp.jiansun.vip/intake', + } + + await expect(client.send(input)).resolves.toEqual(expect.objectContaining({ id: 'delivery-1' })) + + const rawBody = JSON.stringify(input) + const canonical = [ + '1700000000', + 'fixed-notification-nonce', + 'POST', + '/v1/notifications', + createHash('sha256').update(rawBody).digest('hex'), + ].join('\n') + const expectedSignature = createHmac('sha256', PROJECT_SECRET).update(canonical).digest('hex') + expect(fetchImpl).toHaveBeenCalledWith( + 'https://sc.jiansun.vip/notification-center/v1/notifications', + expect.objectContaining({ + method: 'POST', + body: rawBody, + headers: expect.objectContaining({ + 'x-yyd-project-code': 'furniture_platform', + 'x-yyd-signature': expectedSignature, + }), + }), + ) + }) + + it('rejects insecure non-local notification center addresses', () => { + expect(() => new NotificationCenterClient({ + baseUrl: 'http://example.com/notification-center', + projectCode: 'furniture_platform', + projectSecret: PROJECT_SECRET, + })).toThrow('通知中心地址必须使用 HTTPS') + }) + + it('returns the notification center error code without exposing credentials', async () => { + const client = new NotificationCenterClient({ + baseUrl: 'https://sc.jiansun.vip/notification-center', + projectCode: 'furniture_platform', + projectSecret: PROJECT_SECRET, + fetchImpl: jest.fn(async () => new Response( + JSON.stringify({ error: 'SIGNATURE_INVALID' }), + { status: 401, headers: { 'content-type': 'application/json' } }, + )) as jest.MockedFunction, + }) + + await expect(client.getDelivery('delivery-1')).rejects.toMatchObject({ + status: 401, + code: 'SIGNATURE_INVALID', + }) + }) +}) diff --git a/furniture-3d-system/src/server/integrations/notification-center/client.ts b/furniture-3d-system/src/server/integrations/notification-center/client.ts new file mode 100644 index 00000000..9299dbe1 --- /dev/null +++ b/furniture-3d-system/src/server/integrations/notification-center/client.ts @@ -0,0 +1,153 @@ +import 'server-only' + +import { createHash, createHmac, randomBytes } from 'node:crypto' +import type { + NotificationCenterDelivery, + NotificationCenterSendInput, +} from './types' + +type NotificationCenterClientConfig = { + baseUrl: string + projectCode: string + projectSecret: string + timeoutMs?: number + fetchImpl?: typeof fetch + now?: () => number + createNonce?: () => string +} + +type NotificationCenterErrorPayload = { + error?: string + message?: string +} + +const sha256 = (value: string) => createHash('sha256').update(value).digest('hex') + +function normalizeBaseUrl(value: string) { + const url = new URL(value.trim()) + const isLocal = url.hostname === '127.0.0.1' || url.hostname === 'localhost' + if (url.protocol !== 'https:' && !(isLocal && url.protocol === 'http:')) { + throw new Error('通知中心地址必须使用 HTTPS') + } + if (url.username || url.password || url.search || url.hash) { + throw new Error('通知中心地址不能包含凭据、查询参数或片段') + } + return url.toString().replace(/\/$/, '') +} + +export class NotificationCenterRequestError extends Error { + constructor( + message: string, + readonly status?: number, + readonly code?: string, + ) { + super(message) + this.name = 'NotificationCenterRequestError' + } +} + +export class NotificationCenterClient { + private readonly baseUrl: string + private readonly projectCode: string + private readonly projectSecret: string + private readonly timeoutMs: number + private readonly fetchImpl: typeof fetch + private readonly now: () => number + private readonly createNonce: () => string + + constructor(config: NotificationCenterClientConfig) { + if (!/^[a-z0-9_-]{2,40}$/.test(config.projectCode)) { + throw new Error('通知中心项目代码格式不正确') + } + if (config.projectSecret.length < 24) { + throw new Error('通知中心项目密钥未配置或长度不足') + } + + this.baseUrl = normalizeBaseUrl(config.baseUrl) + this.projectCode = config.projectCode + this.projectSecret = config.projectSecret + this.timeoutMs = Math.min(60_000, Math.max(1_000, config.timeoutMs ?? 10_000)) + this.fetchImpl = config.fetchImpl ?? fetch + this.now = config.now ?? Date.now + this.createNonce = config.createNonce ?? (() => randomBytes(18).toString('base64url')) + } + + private createHeaders(method: string, path: string, rawBody: string) { + const timestamp = String(Math.floor(this.now() / 1000)) + const nonce = this.createNonce() + const canonical = [timestamp, nonce, method.toUpperCase(), path, sha256(rawBody)].join('\n') + const signature = createHmac('sha256', this.projectSecret) + .update(canonical) + .digest('hex') + + return { + 'content-type': 'application/json', + 'x-yyd-project-code': this.projectCode, + 'x-yyd-timestamp': timestamp, + 'x-yyd-nonce': nonce, + 'x-yyd-signature': signature, + } + } + + private async request(method: 'GET' | 'POST', path: string, body?: unknown): Promise { + const rawBody = body === undefined ? '' : JSON.stringify(body) + let response: Response + + try { + response = await this.fetchImpl(`${this.baseUrl}${path}`, { + method, + headers: this.createHeaders(method, path, rawBody), + body: rawBody || undefined, + cache: 'no-store', + signal: AbortSignal.timeout(this.timeoutMs), + }) + } catch (error) { + throw new NotificationCenterRequestError( + error instanceof Error ? error.message : '通知中心连接失败', + ) + } + + const payload = (await response.json().catch(() => null)) as T | NotificationCenterErrorPayload | null + if (!response.ok) { + const errorPayload = payload as NotificationCenterErrorPayload | null + throw new NotificationCenterRequestError( + errorPayload?.message || errorPayload?.error || `通知中心返回 HTTP ${response.status}`, + response.status, + errorPayload?.error, + ) + } + if (!payload) { + throw new NotificationCenterRequestError('通知中心返回了无效响应', response.status) + } + return payload as T + } + + send(input: NotificationCenterSendInput) { + return this.request('POST', '/v1/notifications', input) + } + + getDelivery(deliveryId: string) { + if (!deliveryId.trim()) throw new Error('通知投递编号不能为空') + return this.request( + 'GET', + `/v1/notifications/${encodeURIComponent(deliveryId.trim())}`, + ) + } +} + +export function isNotificationCenterConfigured(env: NodeJS.ProcessEnv = process.env) { + return Boolean( + env.YYD_NOTIFICATION_CENTER_URL?.trim() + && env.YYD_NOTIFICATION_PROJECT_CODE?.trim() + && env.YYD_NOTIFICATION_PROJECT_SECRET?.trim(), + ) +} + +export function createNotificationCenterClientFromEnv(env: NodeJS.ProcessEnv = process.env) { + return new NotificationCenterClient({ + baseUrl: env.YYD_NOTIFICATION_CENTER_URL?.trim() ?? '', + projectCode: env.YYD_NOTIFICATION_PROJECT_CODE?.trim() ?? '', + projectSecret: env.YYD_NOTIFICATION_PROJECT_SECRET?.trim() ?? '', + timeoutMs: Number(env.YYD_NOTIFICATION_TIMEOUT_MS) || 10_000, + }) +} diff --git a/furniture-3d-system/src/server/integrations/notification-center/types.ts b/furniture-3d-system/src/server/integrations/notification-center/types.ts new file mode 100644 index 00000000..7a57b206 --- /dev/null +++ b/furniture-3d-system/src/server/integrations/notification-center/types.ts @@ -0,0 +1,36 @@ +export const NOTIFICATION_DELIVERY_STATUSES = [ + 'pending', + 'sending', + 'retrying', + 'sent', + 'unread', + 'read', + 'expired', + 'failed', +] as const + +export type NotificationDeliveryStatus = (typeof NOTIFICATION_DELIVERY_STATUSES)[number] + +export type NotificationCenterDelivery = { + id: string + projectCode: string + status: NotificationDeliveryStatus + recipientUserId: string + title: string + processQueryKey: string | null + lastErrorCode: string | null + lastErrorMessage: string | null + sentAt: string | null + readAt: string | null + createdAt: string + updatedAt: string +} + +export type NotificationCenterSendInput = { + idempotencyKey: string + recipientUserId: string + title: string + content: string + actionTitle: string + actionUrl: string +} diff --git a/furniture-3d-system/src/server/repositories/notification-delivery.repository.ts b/furniture-3d-system/src/server/repositories/notification-delivery.repository.ts new file mode 100644 index 00000000..9d7065b1 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/notification-delivery.repository.ts @@ -0,0 +1,105 @@ +import 'server-only' + +import type { + BusinessAccountType, + NotificationDeliveryLog, + Prisma, + UserStatus, +} from '@prisma/client' +import { prisma } from '@/lib/prisma' + +export type NotificationRecipient = { + id: string + name: string + email: string + status: UserStatus + businessRoles: BusinessAccountType[] + dingtalkUserId: string | null +} + +export type NotificationDeliveryAuditRecipient = { + id: string + name: string + email: string + dingtalkUserId: string +} + +export type NotificationDeliveryFilter = { + keyword?: string + status?: string + startDate?: Date + endDate?: Date +} + +function buildWhere(filter: NotificationDeliveryFilter): Prisma.NotificationDeliveryLogWhereInput { + const where: Prisma.NotificationDeliveryLogWhereInput = {} + if (filter.status) where.status = filter.status + if (filter.startDate || filter.endDate) { + where.createdAt = { + ...(filter.startDate ? { gte: filter.startDate } : {}), + ...(filter.endDate ? { lte: filter.endDate } : {}), + } + } + + const keyword = filter.keyword?.trim() + if (keyword) { + where.OR = [ + { recipientName: { contains: keyword, mode: 'insensitive' } }, + { recipientEmail: { contains: keyword, mode: 'insensitive' } }, + { title: { contains: keyword, mode: 'insensitive' } }, + { renderedTitle: { contains: keyword, mode: 'insensitive' } }, + { content: { contains: keyword, mode: 'insensitive' } }, + { businessEvent: { contains: keyword, mode: 'insensitive' } }, + ] + } + return where +} + +class NotificationDeliveryRepository { + async findRecipient(userId: string): Promise { + return prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + name: true, + email: true, + status: true, + businessRoles: true, + dingtalkUserId: true, + }, + }) + } + + findByIdempotencyKey(idempotencyKey: string) { + return prisma.notificationDeliveryLog.findUnique({ where: { idempotencyKey } }) + } + + create(data: Prisma.NotificationDeliveryLogCreateInput) { + return prisma.notificationDeliveryLog.create({ data }) + } + + update(id: string, data: Prisma.NotificationDeliveryLogUpdateInput) { + return prisma.notificationDeliveryLog.update({ where: { id }, data }) + } + + async findMany(options: { + filter: NotificationDeliveryFilter + page: number + pageSize: number + }): Promise<{ items: NotificationDeliveryLog[]; total: number }> { + const where = buildWhere(options.filter) + const [items, total] = await Promise.all([ + prisma.notificationDeliveryLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (options.page - 1) * options.pageSize, + take: options.pageSize, + }), + prisma.notificationDeliveryLog.count({ where }), + ]) + return { items, total } + } +} + +export const notificationDeliveryRepository = new NotificationDeliveryRepository() +export { NotificationDeliveryRepository } diff --git a/furniture-3d-system/src/server/repositories/product-development-todo.repository.ts b/furniture-3d-system/src/server/repositories/product-development-todo.repository.ts new file mode 100644 index 00000000..98d427ea --- /dev/null +++ b/furniture-3d-system/src/server/repositories/product-development-todo.repository.ts @@ -0,0 +1,117 @@ +import 'server-only' + +import type { BusinessAccountType, Prisma, UserStatus } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import { + buildDevelopmentKindWhere, + buildStockProductWhere, +} from '@/server/repositories/intake-product-list-filters' + +export type ProductDevelopmentTodoUser = { + id: string + name: string + email: string + status: UserStatus + businessRoles: BusinessAccountType[] + dingtalkUserId: string | null +} + +export type ProductDevelopmentTodoSourceRecord = { + id: string + spu: string + designer: string + purchaser: string + createdAt: Date + payload: Prisma.JsonValue + profile: { + purchaseOwner: string + structureDesigner: string + } | null + pricingRows: Array<{ + costPrice: Prisma.Decimal | null + }> +} + +class ProductDevelopmentTodoRepository { + findEligibleProducts(): Promise { + const developmentKindWhere = buildDevelopmentKindWhere('product') + return prisma.intakeNonIntegratedProduct.findMany({ + where: { + deletedAt: null, + AND: [ + ...(developmentKindWhere ? [developmentKindWhere] : []), + { NOT: buildStockProductWhere() }, + ], + }, + select: { + id: true, + spu: true, + designer: true, + purchaser: true, + createdAt: true, + payload: true, + profile: { + select: { + purchaseOwner: true, + structureDesigner: true, + }, + }, + pricingRows: { + select: { + costPrice: true, + }, + }, + }, + orderBy: [ + { createdAt: 'asc' }, + { id: 'asc' }, + ], + }) + } + + findUsersByNames(names: string[]): Promise { + if (names.length === 0) return Promise.resolve([]) + return prisma.user.findMany({ + where: { name: { in: names } }, + select: { + id: true, + name: true, + email: true, + status: true, + businessRoles: true, + dingtalkUserId: true, + }, + }) + } + + findAttemptKeys(keys: string[]) { + if (keys.length === 0) return Promise.resolve([]) + return prisma.productDevelopmentTodoAttempt.findMany({ + where: { attemptKey: { in: keys } }, + select: { attemptKey: true }, + }) + } + + createAttempts(data: Prisma.ProductDevelopmentTodoAttemptCreateManyInput[]) { + if (data.length === 0) return Promise.resolve({ count: 0 }) + return prisma.productDevelopmentTodoAttempt.createMany({ + data, + skipDuplicates: true, + }) + } + + updateAttempts(keys: string[], data: { + status: string + lastErrorCode?: string | null + lastErrorMessage?: string | null + }) { + if (keys.length === 0) return Promise.resolve({ count: 0 }) + return prisma.productDevelopmentTodoAttempt.updateMany({ + where: { attemptKey: { in: keys } }, + data, + }) + } +} + +export const productDevelopmentTodoRepository = new ProductDevelopmentTodoRepository() +export { ProductDevelopmentTodoRepository } diff --git a/furniture-3d-system/src/server/services/__tests__/notification-delivery.service.test.ts b/furniture-3d-system/src/server/services/__tests__/notification-delivery.service.test.ts new file mode 100644 index 00000000..e3548df5 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/notification-delivery.service.test.ts @@ -0,0 +1,182 @@ +/** @jest-environment node */ + +const mockFindRecipient = jest.fn() +const mockFindByIdempotencyKey = jest.fn() +const mockCreate = jest.fn() +const mockUpdate = jest.fn() +const mockFindMany = jest.fn() +const mockSend = jest.fn() + +jest.mock('@/server/repositories/notification-delivery.repository', () => ({ + notificationDeliveryRepository: { + findRecipient: (...args: unknown[]) => mockFindRecipient(...args), + findByIdempotencyKey: (...args: unknown[]) => mockFindByIdempotencyKey(...args), + create: (...args: unknown[]) => mockCreate(...args), + update: (...args: unknown[]) => mockUpdate(...args), + findMany: (...args: unknown[]) => mockFindMany(...args), + }, +})) + +jest.mock('@/server/integrations/notification-center/client', () => ({ + createNotificationCenterClientFromEnv: () => ({ send: (...args: unknown[]) => mockSend(...args) }), + isNotificationCenterConfigured: () => true, + NotificationCenterRequestError: class NotificationCenterRequestError extends Error {}, +})) + +import { NotificationDeliveryError, NotificationDeliveryService } from '../notification-delivery.service' + +const now = new Date('2026-08-26T07:00:00.000Z') +const recipient = { + id: 'user-1', + name: '徐霞客', + email: 'xuxiake@example.com', + status: 'ACTIVE', + businessRoles: ['MANAGEMENT'], + dingtalkUserId: 'ding-user-1', +} + +const input = { + idempotencyKey: 'notification-test:record-1:user-1:v1', + businessEvent: 'notification_test', + businessRecordId: 'record-1', + source: 'USER' as const, + initiatedById: 'admin-1', + initiatedByName: '徐霞客', + recipientUserId: 'user-1', + title: '通知客户端测试', + content: '这是一条测试消息。', + actionTitle: '打开产品开发', + actionUrl: 'https://cp.jiansun.vip/intake', +} + +function storedRecord(overrides: Record = {}) { + return { + id: 'local-log-1', + idempotencyKey: input.idempotencyKey, + payloadHash: 'payload-hash', + externalDeliveryId: null, + businessEvent: input.businessEvent, + businessRecordId: input.businessRecordId, + source: input.source, + initiatedById: input.initiatedById, + initiatedByName: input.initiatedByName, + recipientUserId: recipient.id, + recipientName: recipient.name, + recipientEmail: recipient.email, + recipientDingtalkUserId: recipient.dingtalkUserId, + title: input.title, + renderedTitle: null, + content: input.content, + actionTitle: input.actionTitle, + actionUrl: input.actionUrl, + status: 'pending', + lastErrorCode: null, + lastErrorMessage: null, + sentAt: null, + readAt: null, + lastSyncedAt: null, + createdAt: now, + updatedAt: now, + ...overrides, + } +} + +describe('NotificationDeliveryService', () => { + beforeEach(() => { + jest.clearAllMocks() + mockFindRecipient.mockResolvedValue(recipient) + mockFindByIdempotencyKey.mockResolvedValue(null) + mockCreate.mockImplementation(async (data) => storedRecord({ ...data, payloadHash: data.payloadHash })) + mockSend.mockResolvedValue({ + id: 'delivery-1', + projectCode: 'furniture_platform', + status: 'pending', + recipientUserId: recipient.dingtalkUserId, + title: '【家具业务系统】通知客户端测试', + processQueryKey: null, + lastErrorCode: null, + lastErrorMessage: null, + sentAt: null, + readAt: null, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }) + mockUpdate.mockImplementation(async (_id, data) => storedRecord({ + ...data, + externalDeliveryId: 'delivery-1', + renderedTitle: '【家具业务系统】通知客户端测试', + status: 'pending', + })) + }) + + it('sends to the bound DingTalk user and stores the full audit payload', async () => { + const service = new NotificationDeliveryService() + const result = await service.sendToEmployee(input) + + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ + recipientUserId: 'user-1', + recipientName: '徐霞客', + recipientDingtalkUserId: 'ding-user-1', + title: input.title, + content: input.content, + actionUrl: input.actionUrl, + })) + expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ + recipientUserId: 'ding-user-1', + idempotencyKey: input.idempotencyKey, + })) + expect(result.externalDeliveryId).toBe('delivery-1') + }) + + it.each(['SUPPLIER', 'EXTERNAL_INTAKE'])('rejects %s accounts before creating a delivery', async (businessRole) => { + mockFindRecipient.mockResolvedValue({ ...recipient, businessRoles: [businessRole] }) + const service = new NotificationDeliveryService() + + await expect(service.sendToEmployee(input)).rejects.toMatchObject({ + code: 'RECIPIENT_EXTERNAL', + }) + expect(mockCreate).not.toHaveBeenCalled() + expect(mockSend).not.toHaveBeenCalled() + }) + + it('rejects a reused idempotency key when the payload changed', async () => { + mockFindByIdempotencyKey.mockResolvedValue(storedRecord({ payloadHash: 'different-hash' })) + const service = new NotificationDeliveryService() + + await expect(service.sendToEmployee(input)).rejects.toMatchObject({ + code: 'IDEMPOTENCY_CONFLICT', + }) + expect(mockSend).not.toHaveBeenCalled() + }) + + it('records an undeliverable recipient without calling notification center', async () => { + const service = new NotificationDeliveryService() + const result = await service.recordUndeliverable({ + idempotencyKey: 'product-todo:daily:2026-08-27:missing-purchaser', + businessEvent: 'product_development_daily_todo', + businessRecordId: '2026-08-27', + source: 'SYSTEM', + initiatedByName: '产品开发待办定时任务', + recipient: { + id: 'missing:purchaser', + name: '未配置采购', + email: '', + dingtalkUserId: '', + }, + title: '待办(1)', + content: 'XJ0100 · 产品资料已完成,需要打样 · 已用1天0小时', + actionTitle: '查看待办', + actionUrl: 'https://cp.jiansun.vip/intake/non-integrated/product-1?workspace=product-data', + errorCode: 'RECIPIENT_OWNER_MISSING', + errorMessage: 'XJ0100 的流程没有配置采购', + }) + + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ + status: 'failed', + recipientName: '未配置采购', + lastErrorCode: 'RECIPIENT_OWNER_MISSING', + })) + expect(mockSend).not.toHaveBeenCalled() + expect(result.status).toBe('failed') + }) +}) diff --git a/furniture-3d-system/src/server/services/__tests__/product-development-todo-rules.test.ts b/furniture-3d-system/src/server/services/__tests__/product-development-todo-rules.test.ts new file mode 100644 index 00000000..05e2cc18 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/product-development-todo-rules.test.ts @@ -0,0 +1,176 @@ +/** @jest-environment node */ + +import { + evaluateProductDevelopmentTodos, + formatProductDevelopmentTodoElapsed, + getBeijingDateKey, +} from '../product-development-todo-rules' +import type { ProductDevelopmentTodoProduct } from '../product-development-todo.types' + +const now = new Date('2026-08-27T02:00:00.000Z') +const day = 24 * 60 * 60 * 1_000 + +function product(overrides: Partial = {}): ProductDevelopmentTodoProduct { + return { + id: 'product-1', + spu: 'XJ0100', + createdAt: new Date(now.getTime() - 2 * day), + projectStatus: 'approved', + owners: { + appearanceDesigner: '徐霞客', + structureDesigner: '徐霞客', + purchaser: '徐霞客', + }, + confirmations: {}, + approvedAt: {}, + factoryRows: [], + sampleMeasurementRows: [], + pricingRows: [], + ...overrides, + } +} + +function approval(nodes: string[]) { + return { + confirmations: Object.fromEntries(nodes.map((node) => [node, true])), + approvedAt: Object.fromEntries(nodes.map((node, index) => [ + node, + new Date(now.getTime() - (nodes.length - index) * day).toISOString(), + ])), + } +} + +function completeFactoryRow() { + return { + confirmed: true, + skuId: 'sku-1', + model: 'A', + skuCode: 'XJ0100-A', + setCount: '1', + barcode: '10001', + specName: '规格', + size: '10x10', + color: '黑', + material: '木', + installMethod: '组装', + supplierName: '供应商', + supplierCode: 'S1', + warehouse: '总仓', + shippingMethod: '物流', + minimumOrderQuantity: '1', + taxIncludedUnitPrice: '100', + leadTime: '7', + assemblyMode: '拆装', + productionType: '自产', + installationService: '无', + supplierCategory: 'A', + purchaseOrderMethod: '采购单', + executeStandard: '国标', + } +} + +describe('evaluateProductDevelopmentTodos', () => { + it('keeps an earlier missing node and also activates a later node whose predecessor completed', () => { + const input = product({ + ...approval(['设计卖点']), + }) + + expect(evaluateProductDevelopmentTodos(input, now).map((item) => item.ruleId)).toEqual([ + 'opening_reference_unconfirmed', + 'product_information_unfinished', + ]) + }) + + it('requires a valid timestamp as well as the confirmation boolean', () => { + const input = product({ + confirmations: { 开品参考: true }, + approvedAt: { 开品参考: 'not-a-date' }, + }) + + expect(evaluateProductDevelopmentTodos(input, now)[0]?.ruleId) + .toBe('opening_reference_unconfirmed') + }) + + it('waits seven full days before activating the basic information reminder', () => { + const approvedAt = new Date(now.getTime() - 7 * day).toISOString() + const input = product({ + confirmations: { 产品模型: true }, + approvedAt: { 产品模型: approvedAt }, + }) + + expect(evaluateProductDevelopmentTodos(input, new Date(now.getTime() - 1)).map((item) => item.ruleId)) + .not.toContain('basic_information_unconfirmed') + expect(evaluateProductDevelopmentTodos(input, now).map((item) => item.ruleId)) + .toContain('basic_information_unconfirmed') + }) + + it('does not backfill the one-time sampling handoff after basic information completed', () => { + const input = product({ + ...approval(['产品模型', '基础信息']), + }) + + expect(evaluateProductDevelopmentTodos(input, now).map((item) => item.ruleId)) + .not.toContain('product_information_sampling_handoff') + }) + + it('does not accept product factory confirmation without complete confirmed rows', () => { + const input = product({ + ...approval(['基础信息', '产品定厂']), + factoryRows: [{ ...completeFactoryRow(), warehouse: '' }], + }) + + expect(evaluateProductDevelopmentTodos(input, now).map((item) => item.ruleId)) + .toContain('product_factory_unfinished') + }) + + it('routes pricing work to purchaser and Yiyi after sample measurement completes', () => { + const input = product({ + ...approval(['产品定厂', '样包测量']), + owners: { + appearanceDesigner: '外观', + structureDesigner: '结构', + purchaser: '徐霞客', + }, + factoryRows: [completeFactoryRow()], + sampleMeasurementRows: [{ + skuId: 'sku-1', + lengthCm: '10', + widthCm: '20', + heightCm: '30', + weightKg: '4', + }], + }) + + const pricing = evaluateProductDevelopmentTodos(input, now) + .find((item) => item.ruleId === 'product_pricing_unfinished') + expect(pricing?.recipientNames).toEqual(['徐霞客', '依依']) + }) + + it.each(['宁珂', '松柏'])('adds both merchandisers for purchaser %s', (purchaser) => { + const input = product({ + ...approval(['基础信息']), + owners: { + appearanceDesigner: '外观', + structureDesigner: '结构', + purchaser, + }, + }) + + const factory = evaluateProductDevelopmentTodos(input, now) + .find((item) => item.ruleId === 'product_factory_unfinished') + expect(factory?.recipientNames).toEqual([purchaser, '雁兰', '槐米']) + }) +}) + +describe('product development todo presentation helpers', () => { + it('formats elapsed time without unstable spacing', () => { + expect(formatProductDevelopmentTodoElapsed( + new Date(now.getTime() - (2 * day + 2 * 60 * 60 * 1_000)), + now, + )).toBe('2天2小时') + }) + + it('uses the Beijing calendar date for idempotency', () => { + expect(getBeijingDateKey(new Date('2026-08-26T16:01:00.000Z'))).toBe('2026-08-27') + }) +}) diff --git a/furniture-3d-system/src/server/services/__tests__/product-development-todo.service.test.ts b/furniture-3d-system/src/server/services/__tests__/product-development-todo.service.test.ts new file mode 100644 index 00000000..ab04f903 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/product-development-todo.service.test.ts @@ -0,0 +1,220 @@ +/** @jest-environment node */ + +const mockFindEligibleProducts = jest.fn() +const mockFindUsersByNames = jest.fn() +const mockFindAttemptKeys = jest.fn() +const mockCreateAttempts = jest.fn() +const mockUpdateAttempts = jest.fn() +const mockSendToEmployee = jest.fn() +const mockRecordUndeliverable = jest.fn() + +jest.mock('@/server/repositories/product-development-todo.repository', () => ({ + productDevelopmentTodoRepository: { + findEligibleProducts: (...args: unknown[]) => mockFindEligibleProducts(...args), + findUsersByNames: (...args: unknown[]) => mockFindUsersByNames(...args), + findAttemptKeys: (...args: unknown[]) => mockFindAttemptKeys(...args), + createAttempts: (...args: unknown[]) => mockCreateAttempts(...args), + updateAttempts: (...args: unknown[]) => mockUpdateAttempts(...args), + }, +})) + +jest.mock('@/server/services/notification-delivery.service', () => ({ + notificationDeliveryService: { + sendToEmployee: (...args: unknown[]) => mockSendToEmployee(...args), + recordUndeliverable: (...args: unknown[]) => mockRecordUndeliverable(...args), + }, + NotificationDeliveryError: class NotificationDeliveryError extends Error { + constructor(message: string, readonly code: string) { + super(message) + } + }, +})) + +import { ProductDevelopmentTodoService } from '../product-development-todo.service' + +const now = new Date('2026-08-27T02:00:00.000Z') + +function record(payload: Record) { + return { + id: 'product-1', + spu: 'XJ0100', + designer: '徐霞客', + purchaser: '徐霞客', + createdAt: new Date('2026-08-25T02:00:00.000Z'), + payload, + profile: { + purchaseOwner: '徐霞客', + structureDesigner: '徐霞客', + }, + pricingRows: [], + } +} + +const user = { + id: 'user-1', + name: '徐霞客', + email: 'xuxiake@example.com', + status: 'ACTIVE', + businessRoles: ['MANAGEMENT'], + dingtalkUserId: 'ding-user-1', +} + +describe('ProductDevelopmentTodoService', () => { + beforeEach(() => { + jest.clearAllMocks() + mockFindEligibleProducts.mockResolvedValue([]) + mockFindUsersByNames.mockResolvedValue([user]) + mockFindAttemptKeys.mockResolvedValue([]) + mockCreateAttempts.mockResolvedValue({ count: 0 }) + mockUpdateAttempts.mockResolvedValue({ count: 0 }) + mockSendToEmployee.mockResolvedValue({ status: 'pending' }) + mockRecordUndeliverable.mockResolvedValue({ status: 'failed' }) + }) + + it('aggregates abnormal earlier and active later nodes into one recipient message', async () => { + mockFindEligibleProducts.mockResolvedValue([ + record({ + projectStatus: 'approved', + openingReference: { + designer: '徐霞客', + structureDesigner: '徐霞客', + }, + documentWorkspaceConfirmations: { 设计卖点: true }, + documentWorkspaceApprovedAt: { 设计卖点: '2026-08-26T02:00:00.000Z' }, + }), + ]) + + const result = await new ProductDevelopmentTodoService().preview({ now }) + + expect(result.groups).toHaveLength(1) + expect(result.groups[0]).toMatchObject({ + recipientName: '徐霞客', + itemCount: 2, + deliverable: true, + }) + expect(result.groups[0]?.content).toContain('XJ0100 · 开品参考未确认') + expect(result.groups[0]?.content).toContain('XJ0100 · 产品资料未完成') + expect(result.groups[0]?.items.map((item) => item.actionUrl)).toEqual([ + expect.stringContaining('workspace=opening-reference'), + expect.stringContaining('workspace=product-data'), + ]) + }) + + it('shows a visible preview issue when the purchaser is missing', async () => { + mockFindEligibleProducts.mockResolvedValue([ + { + ...record({ + projectStatus: 'approved', + openingReference: { designer: '外观', structureDesigner: '结构' }, + documentWorkspaceConfirmations: { 开品参考: true, 设计卖点: true, 产品模型: true }, + documentWorkspaceApprovedAt: { + 开品参考: '2026-08-24T02:00:00.000Z', + 设计卖点: '2026-08-25T02:00:00.000Z', + 产品模型: '2026-08-26T02:00:00.000Z', + }, + }), + purchaser: '', + profile: { purchaseOwner: '', structureDesigner: '结构' }, + }, + ]) + mockFindUsersByNames.mockResolvedValue([]) + + const result = await new ProductDevelopmentTodoService().preview({ now }) + const issue = result.groups.find((group) => group.recipientName === '未配置采购') + + expect(issue).toMatchObject({ + deliverable: false, + issue: 'XJ0100 的流程没有配置采购', + }) + }) + + it('aggregates all SPUs into the missing purchaser issue', async () => { + mockFindEligibleProducts.mockResolvedValue([ + { + ...record({ + projectStatus: 'approved', + documentWorkspaceConfirmations: { 基础信息: true }, + documentWorkspaceApprovedAt: { 基础信息: '2026-08-26T02:00:00.000Z' }, + }), + purchaser: '', + profile: { purchaseOwner: '', structureDesigner: '结构' }, + }, + { + ...record({ + projectStatus: 'approved', + documentWorkspaceConfirmations: { 基础信息: true }, + documentWorkspaceApprovedAt: { 基础信息: '2026-08-26T02:00:00.000Z' }, + }), + id: 'product-2', + spu: 'XJ0101', + purchaser: '', + profile: { purchaseOwner: '', structureDesigner: '结构' }, + }, + ]) + mockFindUsersByNames.mockResolvedValue([]) + + const result = await new ProductDevelopmentTodoService().preview({ now }) + const issue = result.groups.find((group) => group.recipientName === '未配置采购') + + expect(issue?.issue).toBe('2 个流程没有配置采购:XJ0100、XJ0101') + }) + + it('claims one-time handoffs before sending and records a failed undeliverable group', async () => { + mockFindEligibleProducts.mockResolvedValue([ + { + ...record({ + projectStatus: 'approved', + openingReference: { designer: '外观', structureDesigner: '结构' }, + documentWorkspaceConfirmations: { 开品参考: true, 设计卖点: true, 产品模型: true }, + documentWorkspaceApprovedAt: { + 开品参考: '2026-08-24T02:00:00.000Z', + 设计卖点: '2026-08-25T02:00:00.000Z', + 产品模型: '2026-08-26T02:00:00.000Z', + }, + }), + purchaser: '', + profile: { purchaseOwner: '', structureDesigner: '结构' }, + }, + ]) + mockFindUsersByNames.mockResolvedValue([]) + + const result = await new ProductDevelopmentTodoService().send({ now }) + + expect(mockCreateAttempts).toHaveBeenCalledWith([ + expect.objectContaining({ + ruleId: 'product_information_sampling_handoff', + productId: 'product-1', + }), + ]) + expect(mockRecordUndeliverable).toHaveBeenCalledWith(expect.objectContaining({ + errorCode: 'RECIPIENT_OWNER_MISSING', + title: '待办(1)', + })) + expect(mockSendToEmployee).not.toHaveBeenCalled() + expect(result.failedCount).toBe(1) + }) + + it('does not retry a one-time handoff that already has an attempt', async () => { + mockFindEligibleProducts.mockResolvedValue([ + record({ + projectStatus: 'approved', + openingReference: { designer: '徐霞客', structureDesigner: '徐霞客' }, + documentWorkspaceConfirmations: { 开品参考: true, 设计卖点: true, 产品模型: true }, + documentWorkspaceApprovedAt: { + 开品参考: '2026-08-24T02:00:00.000Z', + 设计卖点: '2026-08-25T02:00:00.000Z', + 产品模型: '2026-08-26T02:00:00.000Z', + }, + }), + ]) + mockFindAttemptKeys.mockImplementation(async (keys: string[]) => ( + keys.map((attemptKey) => ({ attemptKey })) + )) + + const result = await new ProductDevelopmentTodoService().send({ now }) + + expect(result.recipientCount).toBe(0) + expect(mockCreateAttempts).toHaveBeenCalledWith([]) + expect(mockSendToEmployee).not.toHaveBeenCalled() + }) +}) diff --git a/furniture-3d-system/src/server/services/notification-delivery.service.ts b/furniture-3d-system/src/server/services/notification-delivery.service.ts new file mode 100644 index 00000000..14519a8d --- /dev/null +++ b/furniture-3d-system/src/server/services/notification-delivery.service.ts @@ -0,0 +1,380 @@ +import 'server-only' + +import { createHash } from 'node:crypto' +import type { NotificationDeliveryLog } from '@prisma/client' +import { + createNotificationCenterClientFromEnv, + isNotificationCenterConfigured, + NotificationCenterRequestError, +} from '@/server/integrations/notification-center/client' +import { + NOTIFICATION_DELIVERY_STATUSES, + type NotificationCenterDelivery, + type NotificationDeliveryStatus, +} from '@/server/integrations/notification-center/types' +import { + notificationDeliveryRepository, + type NotificationDeliveryAuditRecipient, + type NotificationDeliveryFilter, + type NotificationRecipient, +} from '@/server/repositories/notification-delivery.repository' + +const EXTERNAL_ACCOUNT_ROLES = new Set(['SUPPLIER', 'EXTERNAL_INTAKE']) +const ACTIVE_DELIVERY_STATUSES = new Set([ + 'pending', + 'sending', + 'retrying', + 'sent', + 'unread', +]) + +export type SendEmployeeNotificationInput = { + idempotencyKey: string + businessEvent: string + businessRecordId?: string + source?: 'USER' | 'SYSTEM' + initiatedById?: string + initiatedByName?: string + recipientUserId: string + title: string + content: string + actionTitle: string + actionUrl: string + retryExisting?: boolean +} + +export type RecordUndeliverableNotificationInput = Omit< + SendEmployeeNotificationInput, + 'recipientUserId' | 'retryExisting' +> & { + recipient: NotificationDeliveryAuditRecipient + errorCode: string + errorMessage: string +} + +export type NotificationDeliveryDto = { + id: string + externalDeliveryId: string | null + businessEvent: string + businessRecordId: string | null + source: string + initiatedByName: string + recipientUserId: string + recipientName: string + recipientEmail: string + recipientDingtalkUserId: string + title: string + content: string + actionTitle: string + actionUrl: string + status: string + lastErrorCode: string | null + lastErrorMessage: string | null + sentAt: string | null + readAt: string | null + lastSyncedAt: string | null + createdAt: string + updatedAt: string +} + +export class NotificationDeliveryError extends Error { + constructor(message: string, readonly code: string) { + super(message) + this.name = 'NotificationDeliveryError' + } +} + +function requiredText(value: string, label: string, maxLength: number) { + const normalized = value.trim() + if (!normalized) throw new NotificationDeliveryError(`${label}不能为空`, 'INVALID_INPUT') + if (normalized.length > maxLength) { + throw new NotificationDeliveryError(`${label}不能超过 ${maxLength} 个字符`, 'INVALID_INPUT') + } + return normalized +} + +function validateActionUrl(value: string) { + let url: URL + try { + url = new URL(value) + } catch { + throw new NotificationDeliveryError('通知跳转地址格式不正确', 'ACTION_URL_INVALID') + } + if ( + url.protocol !== 'https:' + || url.hostname.toLowerCase() !== 'cp.jiansun.vip' + || url.username + || url.password + || url.port + ) { + throw new NotificationDeliveryError( + '通知跳转地址必须是 cp.jiansun.vip 的标准 HTTPS 地址', + 'ACTION_URL_INVALID', + ) + } + return url.toString() +} + +function assertInternalRecipient(recipient: NotificationRecipient) { + if (recipient.status !== 'ACTIVE') { + throw new NotificationDeliveryError('收件人账号不是在职启用状态', 'RECIPIENT_INACTIVE') + } + if (recipient.businessRoles.some((role) => EXTERNAL_ACCOUNT_ROLES.has(role))) { + throw new NotificationDeliveryError('供应商和外部录入人员不能接收系统通知', 'RECIPIENT_EXTERNAL') + } + if (!recipient.dingtalkUserId?.trim()) { + throw new NotificationDeliveryError('收件人尚未绑定钉钉账号', 'RECIPIENT_DINGTALK_UNBOUND') + } +} + +function createPayloadHash(input: { + recipientDingtalkUserId: string + title: string + content: string + actionTitle: string + actionUrl: string +}) { + return createHash('sha256').update(JSON.stringify(input)).digest('hex') +} + +function parseDate(value: string | null) { + if (!value) return null + const date = new Date(value) + return Number.isNaN(date.getTime()) ? null : date +} + +function normalizeStatus(status: string): NotificationDeliveryStatus { + return NOTIFICATION_DELIVERY_STATUSES.includes(status as NotificationDeliveryStatus) + ? status as NotificationDeliveryStatus + : 'failed' +} + +function maskDingtalkUserId(value: string) { + if (value.length <= 6) return '***' + return `${value.slice(0, 3)}***${value.slice(-3)}` +} + +function toDto(record: NotificationDeliveryLog): NotificationDeliveryDto { + return { + id: record.id, + externalDeliveryId: record.externalDeliveryId, + businessEvent: record.businessEvent, + businessRecordId: record.businessRecordId, + source: record.source, + initiatedByName: record.initiatedByName, + recipientUserId: record.recipientUserId, + recipientName: record.recipientName, + recipientEmail: record.recipientEmail, + recipientDingtalkUserId: maskDingtalkUserId(record.recipientDingtalkUserId), + title: record.renderedTitle || record.title, + content: record.content, + actionTitle: record.actionTitle, + actionUrl: record.actionUrl, + status: record.status, + lastErrorCode: record.lastErrorCode, + lastErrorMessage: record.lastErrorMessage, + sentAt: record.sentAt?.toISOString() ?? null, + readAt: record.readAt?.toISOString() ?? null, + lastSyncedAt: record.lastSyncedAt?.toISOString() ?? null, + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + } +} + +class NotificationDeliveryService { + async sendToEmployee(input: SendEmployeeNotificationInput) { + const recipient = await notificationDeliveryRepository.findRecipient(input.recipientUserId) + if (!recipient) { + throw new NotificationDeliveryError('收件人账号不存在', 'RECIPIENT_NOT_FOUND') + } + assertInternalRecipient(recipient) + + const idempotencyKey = requiredText(input.idempotencyKey, '幂等键', 160) + const businessEvent = requiredText(input.businessEvent, '业务事件', 100) + const title = requiredText(input.title, '通知标题', 100) + const content = requiredText(input.content, '通知正文', 4_600) + const actionTitle = requiredText(input.actionTitle, '按钮文字', 20) + const actionUrl = validateActionUrl(input.actionUrl) + if (/^【[^】]+】/.test(title)) { + throw new NotificationDeliveryError('通知标题不要重复添加项目名称前缀', 'INVALID_INPUT') + } + + const recipientDingtalkUserId = recipient.dingtalkUserId!.trim() + const payloadHash = createPayloadHash({ + recipientDingtalkUserId, + title, + content, + actionTitle, + actionUrl, + }) + let record = await notificationDeliveryRepository.findByIdempotencyKey(idempotencyKey) + if (record && input.retryExisting === false) return toDto(record) + if (record && record.payloadHash !== payloadHash) { + throw new NotificationDeliveryError( + '同一幂等键对应了不同通知内容', + 'IDEMPOTENCY_CONFLICT', + ) + } + if (record?.externalDeliveryId) return toDto(record) + + if (!record) { + record = await notificationDeliveryRepository.create({ + idempotencyKey, + payloadHash, + businessEvent, + businessRecordId: input.businessRecordId?.trim() || null, + source: input.source ?? 'SYSTEM', + initiatedById: input.initiatedById?.trim() || null, + initiatedByName: input.initiatedByName?.trim() || '系统任务', + recipientUserId: recipient.id, + recipientName: recipient.name, + recipientEmail: recipient.email, + recipientDingtalkUserId, + title, + content, + actionTitle, + actionUrl, + }) + } + + try { + const delivery = await createNotificationCenterClientFromEnv().send({ + idempotencyKey, + recipientUserId: recipientDingtalkUserId, + title, + content, + actionTitle, + actionUrl, + }) + const updated = await this.applyDeliveryState(record.id, delivery) + return toDto(updated) + } catch (error) { + const requestError = error instanceof NotificationCenterRequestError ? error : null + await notificationDeliveryRepository.update(record.id, { + status: 'failed', + lastErrorCode: requestError?.code || 'NOTIFICATION_CENTER_REQUEST_FAILED', + lastErrorMessage: error instanceof Error ? error.message : '通知中心调用失败', + lastSyncedAt: new Date(), + }) + throw new NotificationDeliveryError( + '通知中心调用失败,请在通知记录中查看原因', + requestError?.code || 'NOTIFICATION_CENTER_REQUEST_FAILED', + ) + } + } + + async recordUndeliverable(input: RecordUndeliverableNotificationInput) { + const idempotencyKey = requiredText(input.idempotencyKey, '幂等键', 160) + const existing = await notificationDeliveryRepository.findByIdempotencyKey(idempotencyKey) + if (existing) return toDto(existing) + + const businessEvent = requiredText(input.businessEvent, '业务事件', 100) + const title = requiredText(input.title, '通知标题', 100) + const content = requiredText(input.content, '通知正文', 4_600) + const actionTitle = requiredText(input.actionTitle, '按钮文字', 20) + const actionUrl = validateActionUrl(input.actionUrl) + const recipient = input.recipient + const payloadHash = createPayloadHash({ + recipientDingtalkUserId: recipient.dingtalkUserId, + title, + content, + actionTitle, + actionUrl, + }) + const record = await notificationDeliveryRepository.create({ + idempotencyKey, + payloadHash, + businessEvent, + businessRecordId: input.businessRecordId?.trim() || null, + source: input.source ?? 'SYSTEM', + initiatedById: input.initiatedById?.trim() || null, + initiatedByName: input.initiatedByName?.trim() || '系统任务', + recipientUserId: recipient.id, + recipientName: recipient.name, + recipientEmail: recipient.email, + recipientDingtalkUserId: recipient.dingtalkUserId, + title, + content, + actionTitle, + actionUrl, + status: 'failed', + lastErrorCode: requiredText(input.errorCode, '失败代码', 100), + lastErrorMessage: requiredText(input.errorMessage, '失败原因', 2_000), + lastSyncedAt: new Date(), + }) + return toDto(record) + } + + private applyDeliveryState(recordId: string, delivery: NotificationCenterDelivery) { + return notificationDeliveryRepository.update(recordId, { + externalDeliveryId: delivery.id, + renderedTitle: delivery.title, + status: normalizeStatus(delivery.status), + lastErrorCode: delivery.lastErrorCode, + lastErrorMessage: delivery.lastErrorMessage, + sentAt: parseDate(delivery.sentAt), + readAt: parseDate(delivery.readAt), + lastSyncedAt: new Date(), + }) + } + + private async refreshRecords(records: NotificationDeliveryLog[]) { + if (!isNotificationCenterConfigured()) return { failed: 0, configured: false } + const candidates = records.filter((record) => ( + record.externalDeliveryId + && ACTIVE_DELIVERY_STATUSES.has(record.status as NotificationDeliveryStatus) + && (!record.lastSyncedAt || Date.now() - record.lastSyncedAt.getTime() >= 5_000) + )) + if (candidates.length === 0) return { failed: 0, configured: true } + + const client = createNotificationCenterClientFromEnv() + let failed = 0 + for (let index = 0; index < candidates.length; index += 5) { + const batch = candidates.slice(index, index + 5) + await Promise.all(batch.map(async (record) => { + try { + const delivery = await client.getDelivery(record.externalDeliveryId!) + await this.applyDeliveryState(record.id, delivery) + } catch { + failed += 1 + } + })) + } + return { failed, configured: true } + } + + async list(options: NotificationDeliveryFilter & { + page?: number + pageSize?: number + refresh?: boolean + }) { + const page = Math.max(1, options.page ?? 1) + const pageSize = Math.min(200, Math.max(1, options.pageSize ?? 50)) + let result = await notificationDeliveryRepository.findMany({ + page, + pageSize, + filter: options, + }) + let syncResult = { failed: 0, configured: isNotificationCenterConfigured() } + + if (options.refresh) { + syncResult = await this.refreshRecords(result.items) + if (result.items.length > 0) { + result = await notificationDeliveryRepository.findMany({ page, pageSize, filter: options }) + } + } + + return { + records: result.items.map(toDto), + total: result.total, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(result.total / pageSize)), + integrationConfigured: syncResult.configured, + syncWarning: syncResult.failed > 0 ? `${syncResult.failed} 条投递状态刷新失败` : null, + } + } +} + +export const notificationDeliveryService = new NotificationDeliveryService() +export { NotificationDeliveryService } diff --git a/furniture-3d-system/src/server/services/product-development-todo-preparation.service.ts b/furniture-3d-system/src/server/services/product-development-todo-preparation.service.ts new file mode 100644 index 00000000..5ee6412a --- /dev/null +++ b/furniture-3d-system/src/server/services/product-development-todo-preparation.service.ts @@ -0,0 +1,287 @@ +import 'server-only' + +import { createHash } from 'node:crypto' +import type { BusinessAccountType } from '@prisma/client' +import { + productDevelopmentTodoRepository, + type ProductDevelopmentTodoSourceRecord, + type ProductDevelopmentTodoUser, +} from '@/server/repositories/product-development-todo.repository' +import { + evaluateProductDevelopmentTodos, + formatProductDevelopmentTodoElapsed, + getBeijingDateKey, +} from '@/server/services/product-development-todo-rules' +import type { + ProductDevelopmentTodoCandidate, + ProductDevelopmentTodoProduct, +} from '@/server/services/product-development-todo.types' + +const EXTERNAL_ACCOUNT_ROLES = new Set(['SUPPLIER', 'EXTERNAL_INTAKE']) +const PRODUCT_DETAIL_ORIGIN = 'https://cp.jiansun.vip' + +export type PreparedTodoItem = ProductDevelopmentTodoCandidate & { + actionUrl: string + elapsed: string + onceAttemptKey: string | null +} + +export type RecipientResolution = { + key: string + name: string + user: ProductDevelopmentTodoUser | null + errorCode: string | null + errorMessage: string | null +} + +export type PreparedRecipientGroup = { + recipient: RecipientResolution + items: PreparedTodoItem[] + title: string + content: string + actionTitle: string + actionUrl: string + idempotencyKey: string + onceAttemptKeys: string[] +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {} +} + +function asArray(value: unknown) { + return Array.isArray(value) ? value : [] +} + +function text(value: unknown) { + return typeof value === 'string' ? value.trim() : '' +} + +function toRuleProduct(record: ProductDevelopmentTodoSourceRecord): ProductDevelopmentTodoProduct { + const payload = asRecord(record.payload) + const openingReference = asRecord(payload.openingReference) + const structuredPricingRows = record.pricingRows.map((row) => ({ + costPrice: row.costPrice?.toString() ?? '', + })) + + return { + id: record.id, + spu: record.spu, + createdAt: record.createdAt, + projectStatus: text(payload.projectStatus) || 'approved', + owners: { + appearanceDesigner: text(openingReference.designer) || record.designer.trim(), + structureDesigner: + record.profile?.structureDesigner.trim() + || text(openingReference.structureDesigner), + purchaser: + record.profile?.purchaseOwner.trim() + || text(payload.purchaseOwner) + || record.purchaser.trim(), + }, + confirmations: asRecord(payload.documentWorkspaceConfirmations), + approvedAt: asRecord(payload.documentWorkspaceApprovedAt), + factoryRows: asArray(payload.factoryRows), + sampleMeasurementRows: asArray(payload.sampleMeasurementRows), + pricingRows: structuredPricingRows.length > 0 + ? structuredPricingRows + : asArray(payload.pricingRows), + } +} + +function buildActionUrl(item: ProductDevelopmentTodoCandidate) { + const path = `/intake/non-integrated/${encodeURIComponent(item.productId)}` + const query = new URLSearchParams({ workspace: item.workspace, mode: 'edit' }) + return `${PRODUCT_DETAIL_ORIGIN}${path}?${query.toString()}` +} + +function recipientKey(value: string) { + return createHash('sha256').update(value).digest('hex').slice(0, 24) +} + +function buildOnceAttemptKey(item: ProductDevelopmentTodoCandidate, recipient: string) { + return `product-todo:once:${item.ruleId}:${item.productId}:${recipientKey(recipient)}` +} + +function classifyUser(name: string, user: ProductDevelopmentTodoUser | undefined): RecipientResolution { + if (!user) { + return { + key: `name:${name}`, + name, + user: null, + errorCode: 'RECIPIENT_NOT_FOUND', + errorMessage: `流程负责人“${name}”没有对应的系统账号`, + } + } + if (user.status !== 'ACTIVE') { + return { + key: `user:${user.id}`, + name, + user, + errorCode: 'RECIPIENT_INACTIVE', + errorMessage: `流程负责人“${name}”的系统账号不是在职启用状态`, + } + } + if (user.businessRoles.some((role) => EXTERNAL_ACCOUNT_ROLES.has(role))) { + return { + key: `user:${user.id}`, + name, + user, + errorCode: 'RECIPIENT_EXTERNAL', + errorMessage: `流程负责人“${name}”是供应商或外部录入账号,不能接收系统通知`, + } + } + if (!user.dingtalkUserId?.trim()) { + return { + key: `user:${user.id}`, + name, + user, + errorCode: 'RECIPIENT_DINGTALK_UNBOUND', + errorMessage: `流程负责人“${name}”尚未绑定钉钉账号`, + } + } + return { + key: `user:${user.id}`, + name, + user, + errorCode: null, + errorMessage: null, + } +} + +function missingOwnerResolution(item: ProductDevelopmentTodoCandidate): RecipientResolution { + const roleLabel = item.recipientRole === 'appearanceDesigner' + ? '外观设计师' + : item.recipientRole === 'structureDesigner' + ? '结构设计师' + : item.recipientRole === 'purchaser' + ? '采购' + : '固定收件人' + return { + key: `missing:${item.recipientRole}`, + name: `未配置${roleLabel}`, + user: null, + errorCode: 'RECIPIENT_OWNER_MISSING', + errorMessage: `${item.spu} 的流程没有配置${roleLabel}`, + } +} + +function buildMessage(recipientName: string, items: PreparedTodoItem[]) { + const title = `待办(${items.length})` + const lines = items.map((item) => ( + `${item.spu} · ${item.nodeLabel} · 已用${item.elapsed} · [查看 >](${item.actionUrl})` + )) + return { + title, + content: `${recipientName},今日有 ${items.length} 项待处理。\n\n${lines.join('\n')}`, + } +} + +export function toProductDevelopmentTodoPreviewGroup( + group: PreparedRecipientGroup, +) { + return { + recipientName: group.recipient.name, + deliverable: !group.recipient.errorCode, + issue: group.recipient.errorMessage, + itemCount: group.items.length, + title: group.title, + content: group.content, + actionUrl: group.actionUrl, + items: group.items.map((item) => ({ + productId: item.productId, + spu: item.spu, + nodeLabel: item.nodeLabel, + elapsed: item.elapsed, + actionUrl: item.actionUrl, + })), + } +} + +export async function prepareProductDevelopmentTodos(input: { + now: Date + ignoreAttemptHistory?: boolean +}) { + const records = await productDevelopmentTodoRepository.findEligibleProducts() + const candidates = records.flatMap((record) => ( + evaluateProductDevelopmentTodos(toRuleProduct(record), input.now) + )) + const ownerNames = Array.from(new Set(candidates.flatMap((item) => item.recipientNames))) + const users = await productDevelopmentTodoRepository.findUsersByNames(ownerNames) + const userByName = new Map(users.map((user) => [user.name, user])) + const expanded = candidates.flatMap((item) => { + if (item.recipientNames.length === 0) { + return [{ item, recipient: missingOwnerResolution(item) }] + } + return item.recipientNames.map((name) => ({ + item, + recipient: classifyUser(name, userByName.get(name)), + })) + }) + const withAttemptKeys = expanded.map(({ item, recipient }) => ({ + item, + recipient, + onceAttemptKey: item.deliveryMode === 'once' + ? buildOnceAttemptKey(item, recipient.key) + : null, + })) + const attemptKeys = withAttemptKeys.flatMap((entry) => ( + entry.onceAttemptKey ? [entry.onceAttemptKey] : [] + )) + const existingAttemptKeys = input.ignoreAttemptHistory + ? new Set() + : new Set((await productDevelopmentTodoRepository.findAttemptKeys(attemptKeys)) + .map((entry) => entry.attemptKey)) + const preparedItems = withAttemptKeys + .filter((entry) => !entry.onceAttemptKey || !existingAttemptKeys.has(entry.onceAttemptKey)) + .map(({ item, recipient, onceAttemptKey }) => ({ + recipient, + item: { + ...item, + actionUrl: buildActionUrl(item), + elapsed: formatProductDevelopmentTodoElapsed(item.activatedAt, input.now), + onceAttemptKey, + } satisfies PreparedTodoItem, + })) + const groupsByRecipient = new Map() + preparedItems.forEach(({ recipient, item }) => { + const current = groupsByRecipient.get(recipient.key) ?? { recipient, items: [] } + current.items.push(item) + groupsByRecipient.set(recipient.key, current) + }) + + const dateKey = getBeijingDateKey(input.now) + const groups = Array.from(groupsByRecipient.values()).map(({ recipient, items }) => { + items.sort((left, right) => ( + left.activatedAt.getTime() - right.activatedAt.getTime() + || left.spu.localeCompare(right.spu, 'zh-CN') + || left.ruleId.localeCompare(right.ruleId) + )) + const resolvedRecipient = recipient.errorCode === 'RECIPIENT_OWNER_MISSING' + ? { + ...recipient, + errorMessage: items.length === 1 + ? recipient.errorMessage + : `${items.length} 个流程没有配置${recipient.name.replace(/^未配置/, '')}:${Array.from(new Set(items.map((item) => item.spu))).join('、')}`, + } + : recipient + return { + recipient: resolvedRecipient, + items, + ...buildMessage(recipient.name, items), + actionTitle: '查看待办', + actionUrl: items[0]!.actionUrl, + idempotencyKey: `product-todo:daily:${dateKey}:${recipientKey(recipient.key)}`, + onceAttemptKeys: items.flatMap((item) => ( + item.onceAttemptKey ? [item.onceAttemptKey] : [] + )), + } satisfies PreparedRecipientGroup + }) + groups.sort((left, right) => left.recipient.name.localeCompare(right.recipient.name, 'zh-CN')) + return { records, candidates, groups } +} diff --git a/furniture-3d-system/src/server/services/product-development-todo-rules.ts b/furniture-3d-system/src/server/services/product-development-todo-rules.ts new file mode 100644 index 00000000..60be5bf0 --- /dev/null +++ b/furniture-3d-system/src/server/services/product-development-todo-rules.ts @@ -0,0 +1,281 @@ +import type { + ProductDevelopmentTodoCandidate, + ProductDevelopmentTodoProduct, +} from '@/server/services/product-development-todo.types' + +export const PRODUCT_DEVELOPMENT_TODO_EVENT = 'product_development_daily_todo' + +const DAY_MS = 24 * 60 * 60 * 1_000 +const PRODUCT_INFORMATION_WAIT_MS = 7 * DAY_MS + +type NodeKey = + | '开品参考' + | '设计卖点' + | '产品模型' + | '基础信息' + | '产品定厂' + | '样包测量' + | '产品核价' + +const PURCHASER_MERCHANDISER_NAMES = new Set(['宁珂', '松柏']) +const MERCHANDISER_NAMES = ['雁兰', '槐米'] + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {} +} + +function text(value: unknown) { + return typeof value === 'string' ? value.trim() : '' +} + +function parseDate(value: unknown): Date | null { + const normalized = text(value) + if (!normalized) return null + const parsed = new Date(normalized) + return Number.isNaN(parsed.getTime()) ? null : parsed +} + +function splitNames(value: string) { + return Array.from(new Set( + value + .split(/[、,,;;/]/) + .map((entry) => entry.trim()) + .filter(Boolean), + )) +} + +function nodeApproved(product: ProductDevelopmentTodoProduct, node: NodeKey) { + return product.confirmations[node] === true && parseDate(product.approvedAt[node]) !== null +} + +function approvedAt(product: ProductDevelopmentTodoProduct, node: NodeKey) { + return parseDate(product.approvedAt[node]) +} + +const FACTORY_REQUIRED_KEYS = [ + 'model', + 'skuCode', + 'setCount', + 'barcode', + 'specName', + 'material', + 'installMethod', + 'supplierName', + 'warehouse', + 'shippingMethod', + 'minimumOrderQuantity', + 'leadTime', + 'productionType', + 'installationService', + 'supplierCategory', + 'purchaseOrderMethod', + 'executeStandard', +] as const + +function factoryContentComplete(rows: unknown[]) { + return rows.length > 0 && rows.every((entry) => { + const row = asRecord(entry) + return row.confirmed === true && FACTORY_REQUIRED_KEYS.every((key) => text(row[key])) + }) +} + +function sampleMeasurementContentComplete(rows: unknown[]) { + return rows.length > 0 && rows.every((entry) => { + const row = asRecord(entry) + return ['lengthCm', 'widthCm', 'heightCm', 'weightKg'] + .every((key) => text(row[key])) + }) +} + +function pricingContentComplete(rows: unknown[]) { + return rows.length > 0 && rows.every((entry) => text(asRecord(entry).costPrice)) +} + +function productFactoryApproved(product: ProductDevelopmentTodoProduct) { + return nodeApproved(product, '产品定厂') && factoryContentComplete(product.factoryRows) +} + +function sampleMeasurementApproved(product: ProductDevelopmentTodoProduct) { + return nodeApproved(product, '样包测量') + && productFactoryApproved(product) + && sampleMeasurementContentComplete(product.sampleMeasurementRows) +} + +function productPricingApproved(product: ProductDevelopmentTodoProduct) { + return nodeApproved(product, '产品核价') && pricingContentComplete(product.pricingRows) +} + +function purchaserRecipients(purchaser: string, extraNames: string[] = []) { + const purchaserNames = splitNames(purchaser) + const needsMerchandisers = purchaserNames.some((name) => ( + PURCHASER_MERCHANDISER_NAMES.has(name) + )) + return Array.from(new Set([ + ...purchaserNames, + ...(needsMerchandisers ? MERCHANDISER_NAMES : []), + ...extraNames, + ])) +} + +function candidate(input: Omit & { + product: ProductDevelopmentTodoProduct +}): ProductDevelopmentTodoCandidate { + return { + ruleId: input.ruleId, + productId: input.product.id, + spu: input.product.spu, + nodeLabel: input.nodeLabel, + workspace: input.workspace, + recipientRole: input.recipientRole, + recipientNames: input.recipientNames, + activatedAt: input.activatedAt, + deliveryMode: input.deliveryMode, + } +} + +export function evaluateProductDevelopmentTodos( + product: ProductDevelopmentTodoProduct, + now: Date, +): ProductDevelopmentTodoCandidate[] { + if (product.projectStatus === 'rejected') return [] + + const items: ProductDevelopmentTodoCandidate[] = [] + const openingApprovedAt = approvedAt(product, '开品参考') + const sellingPointApprovedAt = approvedAt(product, '设计卖点') + const productInformationApprovedAt = approvedAt(product, '产品模型') + const basicInformationApprovedAt = approvedAt(product, '基础信息') + const productFactoryApprovedAt = approvedAt(product, '产品定厂') + const sampleMeasurementApprovedAt = approvedAt(product, '样包测量') + + if (!nodeApproved(product, '开品参考')) { + items.push(candidate({ + product, + ruleId: 'opening_reference_unconfirmed', + nodeLabel: '开品参考未确认', + workspace: 'opening-reference', + recipientRole: 'appearanceDesigner', + recipientNames: splitNames(product.owners.appearanceDesigner), + activatedAt: product.createdAt, + deliveryMode: 'daily', + })) + } + + if (nodeApproved(product, '开品参考') && !nodeApproved(product, '设计卖点')) { + items.push(candidate({ + product, + ruleId: 'selling_point_unconfirmed', + nodeLabel: '设计卖点未确认', + workspace: 'design-selling-point', + recipientRole: 'appearanceDesigner', + recipientNames: splitNames(product.owners.appearanceDesigner), + activatedAt: openingApprovedAt!, + deliveryMode: 'daily', + })) + } + + if (nodeApproved(product, '设计卖点') && !nodeApproved(product, '产品模型')) { + items.push(candidate({ + product, + ruleId: 'product_information_unfinished', + nodeLabel: '产品资料未完成', + workspace: 'product-data', + recipientRole: 'structureDesigner', + recipientNames: splitNames(product.owners.structureDesigner), + activatedAt: sellingPointApprovedAt!, + deliveryMode: 'daily', + })) + } + + if (nodeApproved(product, '产品模型') && !nodeApproved(product, '基础信息')) { + items.push(candidate({ + product, + ruleId: 'product_information_sampling_handoff', + nodeLabel: '产品资料已完成,需要打样', + workspace: 'product-data', + recipientRole: 'purchaser', + recipientNames: purchaserRecipients(product.owners.purchaser), + activatedAt: productInformationApprovedAt!, + deliveryMode: 'once', + })) + } + + if ( + nodeApproved(product, '产品模型') + && !nodeApproved(product, '基础信息') + && now.getTime() - productInformationApprovedAt!.getTime() >= PRODUCT_INFORMATION_WAIT_MS + ) { + items.push(candidate({ + product, + ruleId: 'basic_information_unconfirmed', + nodeLabel: '基础信息未确认', + workspace: 'basic-info', + recipientRole: 'structureDesigner', + recipientNames: splitNames(product.owners.structureDesigner), + activatedAt: productInformationApprovedAt!, + deliveryMode: 'daily', + })) + } + + if (nodeApproved(product, '基础信息') && !productFactoryApproved(product)) { + items.push(candidate({ + product, + ruleId: 'product_factory_unfinished', + nodeLabel: '产品定厂未完成', + workspace: 'product-factory', + recipientRole: 'purchaser', + recipientNames: purchaserRecipients(product.owners.purchaser), + activatedAt: basicInformationApprovedAt!, + deliveryMode: 'daily', + })) + } + + if (productFactoryApproved(product) && !sampleMeasurementApproved(product)) { + items.push(candidate({ + product, + ruleId: 'sample_measurement_unfinished', + nodeLabel: '样包测量未完成', + workspace: 'sample-measurement', + recipientRole: 'purchaser', + recipientNames: purchaserRecipients(product.owners.purchaser), + activatedAt: productFactoryApprovedAt!, + deliveryMode: 'daily', + })) + } + + if (sampleMeasurementApproved(product) && !productPricingApproved(product)) { + items.push(candidate({ + product, + ruleId: 'product_pricing_unfinished', + nodeLabel: '产品核价未完成', + workspace: 'product-pricing', + recipientRole: 'purchaser', + recipientNames: purchaserRecipients(product.owners.purchaser, ['依依']), + activatedAt: sampleMeasurementApprovedAt!, + deliveryMode: 'daily', + })) + } + + return items +} + +export function formatProductDevelopmentTodoElapsed(activatedAt: Date, now: Date) { + const totalMinutes = Math.max(0, Math.floor((now.getTime() - activatedAt.getTime()) / 60_000)) + const days = Math.floor(totalMinutes / (24 * 60)) + const hours = Math.floor((totalMinutes % (24 * 60)) / 60) + const minutes = totalMinutes % 60 + + if (days > 0) return `${days}天${hours}小时` + if (hours > 0) return `${hours}小时${minutes}分钟` + return `${minutes}分钟` +} + +export function getBeijingDateKey(now: Date) { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(now) +} diff --git a/furniture-3d-system/src/server/services/product-development-todo.service.ts b/furniture-3d-system/src/server/services/product-development-todo.service.ts new file mode 100644 index 00000000..6da5bfa3 --- /dev/null +++ b/furniture-3d-system/src/server/services/product-development-todo.service.ts @@ -0,0 +1,150 @@ +import 'server-only' + +import { productDevelopmentTodoRepository } from '@/server/repositories/product-development-todo.repository' +import { + notificationDeliveryService, + NotificationDeliveryError, +} from '@/server/services/notification-delivery.service' +import { + prepareProductDevelopmentTodos, + toProductDevelopmentTodoPreviewGroup, +} from '@/server/services/product-development-todo-preparation.service' +import { + getBeijingDateKey, + PRODUCT_DEVELOPMENT_TODO_EVENT, +} from '@/server/services/product-development-todo-rules' + +class ProductDevelopmentTodoService { + async preview(input: { now?: Date; ignoreAttemptHistory?: boolean } = {}) { + const now = input.now ?? new Date() + const prepared = await prepareProductDevelopmentTodos({ + now, + ignoreAttemptHistory: input.ignoreAttemptHistory, + }) + return { + mode: 'preview' as const, + now: now.toISOString(), + productCount: prepared.records.length, + candidateCount: prepared.candidates.length, + recipientCount: prepared.groups.length, + deliverableRecipientCount: prepared.groups.filter((group) => !group.recipient.errorCode).length, + issueCount: prepared.groups.filter((group) => group.recipient.errorCode).length, + groups: prepared.groups.map(toProductDevelopmentTodoPreviewGroup), + } + } + + async send(input: { now?: Date } = {}) { + const now = input.now ?? new Date() + const prepared = await prepareProductDevelopmentTodos({ now }) + const onceAttempts = prepared.groups.flatMap((group) => ( + group.items.flatMap((item) => item.onceAttemptKey ? [{ + attemptKey: item.onceAttemptKey, + ruleId: item.ruleId, + productId: item.productId, + recipientKey: group.recipient.key, + attemptedAt: now, + }] : []) + )) + await productDevelopmentTodoRepository.createAttempts(onceAttempts) + + const results: Array<{ + recipientName: string + itemCount: number + status: 'attempted' | 'failed' + errorCode: string | null + }> = [] + + for (const group of prepared.groups) { + try { + if (group.recipient.errorCode) { + await notificationDeliveryService.recordUndeliverable({ + idempotencyKey: group.idempotencyKey, + businessEvent: PRODUCT_DEVELOPMENT_TODO_EVENT, + businessRecordId: getBeijingDateKey(now), + source: 'SYSTEM', + initiatedByName: '产品开发待办定时任务', + recipient: { + id: group.recipient.user?.id ?? group.recipient.key, + name: group.recipient.name, + email: group.recipient.user?.email ?? '', + dingtalkUserId: group.recipient.user?.dingtalkUserId?.trim() ?? '', + }, + title: group.title, + content: group.content, + actionTitle: group.actionTitle, + actionUrl: group.actionUrl, + errorCode: group.recipient.errorCode, + errorMessage: group.recipient.errorMessage ?? '收件人不可投递', + }) + await productDevelopmentTodoRepository.updateAttempts(group.onceAttemptKeys, { + status: 'failed', + lastErrorCode: group.recipient.errorCode, + lastErrorMessage: group.recipient.errorMessage, + }) + results.push({ + recipientName: group.recipient.name, + itemCount: group.items.length, + status: 'failed', + errorCode: group.recipient.errorCode, + }) + continue + } + + await notificationDeliveryService.sendToEmployee({ + idempotencyKey: group.idempotencyKey, + businessEvent: PRODUCT_DEVELOPMENT_TODO_EVENT, + businessRecordId: getBeijingDateKey(now), + source: 'SYSTEM', + initiatedByName: '产品开发待办定时任务', + recipientUserId: group.recipient.user!.id, + title: group.title, + content: group.content, + actionTitle: group.actionTitle, + actionUrl: group.actionUrl, + retryExisting: false, + }) + await productDevelopmentTodoRepository.updateAttempts(group.onceAttemptKeys, { + status: 'attempted', + lastErrorCode: null, + lastErrorMessage: null, + }) + results.push({ + recipientName: group.recipient.name, + itemCount: group.items.length, + status: 'attempted', + errorCode: null, + }) + } catch (error) { + const errorCode = error instanceof NotificationDeliveryError + ? error.code + : 'PRODUCT_TODO_DELIVERY_FAILED' + const errorMessage = error instanceof Error ? error.message : '产品开发待办发送失败' + await productDevelopmentTodoRepository.updateAttempts(group.onceAttemptKeys, { + status: 'failed', + lastErrorCode: errorCode, + lastErrorMessage: errorMessage, + }) + results.push({ + recipientName: group.recipient.name, + itemCount: group.items.length, + status: 'failed', + errorCode, + }) + } + } + + return { + mode: 'send' as const, + now: now.toISOString(), + productCount: prepared.records.length, + candidateCount: prepared.candidates.length, + recipientCount: prepared.groups.length, + attemptedCount: results.filter((result) => result.status === 'attempted').length, + failedCount: results.filter((result) => result.status === 'failed').length, + results, + } + } +} + +export const productDevelopmentTodoService = new ProductDevelopmentTodoService() +export { ProductDevelopmentTodoService } diff --git a/furniture-3d-system/src/server/services/product-development-todo.types.ts b/furniture-3d-system/src/server/services/product-development-todo.types.ts new file mode 100644 index 00000000..700699a8 --- /dev/null +++ b/furniture-3d-system/src/server/services/product-development-todo.types.ts @@ -0,0 +1,53 @@ +export type ProductDevelopmentTodoWorkspace = + | 'opening-reference' + | 'design-selling-point' + | 'product-data' + | 'basic-info' + | 'product-factory' + | 'sample-measurement' + | 'product-pricing' + +export type ProductDevelopmentTodoRecipientRole = + | 'appearanceDesigner' + | 'structureDesigner' + | 'purchaser' + | 'fixed' + +export type ProductDevelopmentTodoRuleId = + | 'opening_reference_unconfirmed' + | 'selling_point_unconfirmed' + | 'product_information_unfinished' + | 'product_information_sampling_handoff' + | 'basic_information_unconfirmed' + | 'product_factory_unfinished' + | 'sample_measurement_unfinished' + | 'product_pricing_unfinished' + +export type ProductDevelopmentTodoProduct = { + id: string + spu: string + createdAt: Date + projectStatus: string + owners: { + appearanceDesigner: string + structureDesigner: string + purchaser: string + } + confirmations: Record + approvedAt: Record + factoryRows: unknown[] + sampleMeasurementRows: unknown[] + pricingRows: unknown[] +} + +export type ProductDevelopmentTodoCandidate = { + ruleId: ProductDevelopmentTodoRuleId + productId: string + spu: string + nodeLabel: string + workspace: ProductDevelopmentTodoWorkspace + recipientRole: ProductDevelopmentTodoRecipientRole + recipientNames: string[] + activatedAt: Date + deliveryMode: 'daily' | 'once' +} diff --git a/scripts/deploy-yydd-prod-from-git.ps1 b/scripts/deploy-yydd-prod-from-git.ps1 index 32f31779..2f68725c 100644 --- a/scripts/deploy-yydd-prod-from-git.ps1 +++ b/scripts/deploy-yydd-prod-from-git.ps1 @@ -333,6 +333,23 @@ ensure_bom_costing_worker_secret() { chmod 600 "$SHARED/.env" || true } +ensure_product_development_todo_secret() { + if grep -qE '^PRODUCT_DEVELOPMENT_TODO_CRON_SECRET=.+$' "$SHARED/.env"; then + return 0 + fi + + local secret + secret="$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('hex'))")" + if [ -z "$secret" ]; then + echo "Unable to generate product development todo cron secret" >&2 + exit 1 + fi + + umask 077 + printf '\nPRODUCT_DEVELOPMENT_TODO_CRON_SECRET=%s\n' "$secret" >> "$SHARED/.env" + chmod 600 "$SHARED/.env" || true +} + ensure_image_search_model() { local model_dir="$SHARED/models/dinov2-small" local source_base="${IMAGE_SEARCH_MODEL_SOURCE_BASE_URL:-https://www.modelscope.cn/models/Xenova/dinov2-small/resolve/master}" @@ -388,12 +405,16 @@ install_virtual_inventory_push_cron() { local cron_file local cron_line local fixed_cron_line + local todo_cron_line local lock_file="/tmp/furniture-3d-system-virtual-inventory-push.lock" local fixed_lock_file="/tmp/furniture-3d-system-fixed-virtual-inventory-push.lock" + local todo_lock_file="/tmp/furniture-3d-system-product-development-todos.lock" local endpoint="http://127.0.0.1:$APP_PORT/api/intake/non-integrated/jushuitan-virtual-inventory-push" local fixed_endpoint="http://127.0.0.1:$APP_PORT/api/intake/non-integrated/jushuitan-fixed-virtual-inventory-push" + local todo_endpoint="http://127.0.0.1:$APP_PORT/api/internal/product-development-todos/run?mode=send" local log_file="$SHARED/logs/jushuitan-virtual-inventory-push.log" local fixed_log_file="$SHARED/logs/jushuitan-fixed-virtual-inventory-push.log" + local todo_log_file="$SHARED/logs/product-development-todos.log" mkdir -p "$SHARED/logs" cron_file="$(mktemp)" @@ -402,12 +423,18 @@ install_virtual_inventory_push_cron() { sed '\|/api/intake/non-integrated/jushuitan-virtual-inventory-push|d' | sed '\|furniture-3d-system-virtual-inventory-push|d' | sed '\|/api/intake/non-integrated/jushuitan-fixed-virtual-inventory-push|d' | - sed '\|furniture-3d-system-fixed-virtual-inventory-push|d' > "$cron_file" + sed '\|furniture-3d-system-fixed-virtual-inventory-push|d' | + sed '\|/api/internal/product-development-todos/run|d' | + sed '\|furniture-3d-system-product-development-todos|d' | + sed '\|^CRON_TZ=Asia/Shanghai$|d' > "$cron_file" cron_line="* * * * * /usr/bin/flock -n \"$lock_file\" /bin/bash -lc 'set -euo pipefail; set -a; source \"$SHARED/.env\"; set +a; : \"\${JST_VIRTUAL_INVENTORY_CRON_SECRET:?JST_VIRTUAL_INVENTORY_CRON_SECRET is required}\"; curl --fail --silent --show-error --max-time 55 --request POST \"$endpoint\" --header \"x-jst-virtual-inventory-secret: \${JST_VIRTUAL_INVENTORY_CRON_SECRET}\"' >> \"$log_file\" 2>&1 # furniture-3d-system-virtual-inventory-push" fixed_cron_line="0 20 * * * /usr/bin/flock -n \"$fixed_lock_file\" /bin/bash -lc 'set -euo pipefail; set -a; source \"$SHARED/.env\"; set +a; : \"\${JST_VIRTUAL_INVENTORY_CRON_SECRET:?JST_VIRTUAL_INVENTORY_CRON_SECRET is required}\"; curl --fail --silent --show-error --max-time 1800 --request POST \"$fixed_endpoint\" --header \"x-jst-virtual-inventory-secret: \${JST_VIRTUAL_INVENTORY_CRON_SECRET}\"' >> \"$fixed_log_file\" 2>&1 # furniture-3d-system-fixed-virtual-inventory-push" + todo_cron_line="0 10 * * * /usr/bin/flock -n \"$todo_lock_file\" /bin/bash -lc 'set -euo pipefail; set -a; source \"$SHARED/.env\"; set +a; : \"\${PRODUCT_DEVELOPMENT_TODO_CRON_SECRET:?PRODUCT_DEVELOPMENT_TODO_CRON_SECRET is required}\"; curl --fail --silent --show-error --max-time 300 --request POST \"$todo_endpoint\" --header \"x-product-development-todo-secret: \${PRODUCT_DEVELOPMENT_TODO_CRON_SECRET}\"' >> \"$todo_log_file\" 2>&1 # furniture-3d-system-product-development-todos" printf '%s\n' "$cron_line" >> "$cron_file" printf '%s\n' "$fixed_cron_line" >> "$cron_file" + printf '%s\n' 'CRON_TZ=Asia/Shanghai' >> "$cron_file" + printf '%s\n' "$todo_cron_line" >> "$cron_file" crontab "$cron_file" rm -f -- "$cron_file" } @@ -520,6 +547,7 @@ ensure_shared_non_integrated_assets() { mkdir -p "$BASE" "$RELEASES" "$SHARED" copy_legacy_env_if_needed ensure_bom_costing_worker_secret +ensure_product_development_todo_secret ensure_repo fetch_target_commit "$(resolve_fetch_ref)"