release: integrate approved product and notification updates
Includes 13268ee5, 01a9d1e2, 48e496b0, bf85258d. Store account fix e48229ab is already integrated by parent d8141a63.
This commit is contained in:
parent
d8141a63a9
commit
78cfa85232
@ -87,6 +87,15 @@ DINGTALK_TODO_ESCALATION_ENABLED=false
|
|||||||
DINGTALK_TODO_ESCALATION_DAYS=3
|
DINGTALK_TODO_ESCALATION_DAYS=3
|
||||||
# DINGTALK_AGENT_ID=
|
# 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。
|
# 必填,不能留空。当前生产桶使用 qiniu;只有明确切换适配器时才改为 s3/minio。
|
||||||
STORAGE_PROVIDER=qiniu
|
STORAGE_PROVIDER=qiniu
|
||||||
|
|||||||
@ -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");
|
||||||
@ -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");
|
||||||
@ -3066,6 +3066,66 @@ model OperationLog {
|
|||||||
@@index([productId, createdAt])
|
@@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 {
|
model ReleaseNote {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
|||||||
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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 })
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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(<NonIntegratedDetailClient mode="edit" productId={productId} />);
|
||||||
|
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(<NonIntegratedDetailClient mode="edit" productId={productId} />);
|
||||||
|
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 () => {
|
it("fills the first warehouse selection by SPU and keeps later warehouse changes per SKU", async () => {
|
||||||
const actor = userEvent.setup();
|
const actor = userEvent.setup();
|
||||||
|
|
||||||
|
|||||||
@ -271,6 +271,7 @@ export function useNonIntegratedFormActionControllers({
|
|||||||
productFactoryModelAutofillRef: workflowState.productFactoryModelAutofillRef,
|
productFactoryModelAutofillRef: workflowState.productFactoryModelAutofillRef,
|
||||||
productFactoryReopenField: workflowState.productFactoryReopenField,
|
productFactoryReopenField: workflowState.productFactoryReopenField,
|
||||||
productFactoryRows: workflowRows.productFactoryRows,
|
productFactoryRows: workflowRows.productFactoryRows,
|
||||||
|
scheduleAutoSave,
|
||||||
setDocumentWorkspaceApprovedAt: documentState.setDocumentWorkspaceApprovedAt,
|
setDocumentWorkspaceApprovedAt: documentState.setDocumentWorkspaceApprovedAt,
|
||||||
setDocumentWorkspaceConfirmations:
|
setDocumentWorkspaceConfirmations:
|
||||||
documentState.setDocumentWorkspaceConfirmations,
|
documentState.setDocumentWorkspaceConfirmations,
|
||||||
@ -281,6 +282,9 @@ export function useNonIntegratedFormActionControllers({
|
|||||||
setSaveMessageType: overlayState.setSaveMessageType,
|
setSaveMessageType: overlayState.setSaveMessageType,
|
||||||
setSkuItems: skuState.setSkuItems,
|
setSkuItems: skuState.setSkuItems,
|
||||||
setStyleDetails: artifactState.setStyleDetails,
|
setStyleDetails: artifactState.setStyleDetails,
|
||||||
|
skuBatchChangeTypes: skuState.skuBatchChangeTypes,
|
||||||
|
skuBatchCount: skuState.skuBatchCount,
|
||||||
|
skuBatchFlowNos: skuState.skuBatchFlowNos,
|
||||||
supplierCodeByName: directoryOptions.supplierCodeByName,
|
supplierCodeByName: directoryOptions.supplierCodeByName,
|
||||||
workflowSkuItems: seriesDerived.workflowSkuItems,
|
workflowSkuItems: seriesDerived.workflowSkuItems,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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({
|
const params = createControllerParams({
|
||||||
derivedSkuItems: [
|
derivedSkuItems: [
|
||||||
createSkuItem({
|
createSkuItem({
|
||||||
@ -109,7 +109,7 @@ describe("useNonIntegratedWorkflowConfirmActionController", () => {
|
|||||||
}),
|
}),
|
||||||
createSkuItem({
|
createSkuItem({
|
||||||
id: "sku-batch-2",
|
id: "sku-batch-2",
|
||||||
installMethod: "",
|
material: "",
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@ -126,7 +126,29 @@ describe("useNonIntegratedWorkflowConfirmActionController", () => {
|
|||||||
|
|
||||||
expect(params.persistEditedProduct).not.toHaveBeenCalled();
|
expect(params.persistEditedProduct).not.toHaveBeenCalled();
|
||||||
expect(params.setSaveMessage).toHaveBeenCalledWith(
|
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("安装方式"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -129,7 +129,7 @@ export function SkuInfoRow({
|
|||||||
hasLegacySeriesComponentCodeText(derivedItem.drawingId));
|
hasLegacySeriesComponentCodeText(derivedItem.drawingId));
|
||||||
const displayAttributeCode = hideLegacySeriesComponentFields
|
const displayAttributeCode = hideLegacySeriesComponentFields
|
||||||
? ""
|
? ""
|
||||||
: item.attributeCode;
|
: derivedItem.attributeCode;
|
||||||
const displayDrawingId = hideLegacySeriesComponentFields
|
const displayDrawingId = hideLegacySeriesComponentFields
|
||||||
? ""
|
? ""
|
||||||
: derivedItem.drawingId;
|
: derivedItem.drawingId;
|
||||||
|
|||||||
@ -3,7 +3,10 @@ import {
|
|||||||
buildNonIntegratedSkuId,
|
buildNonIntegratedSkuId,
|
||||||
type NonIntegratedSkuItem,
|
type NonIntegratedSkuItem,
|
||||||
} from "../../nonIntegratedStorage";
|
} from "../../nonIntegratedStorage";
|
||||||
import { DEFAULT_ASSET_SKU_SALES_STATUS } from "./skuFieldConfig";
|
import {
|
||||||
|
DEFAULT_ASSET_SKU_ATTRIBUTE_CODE,
|
||||||
|
DEFAULT_ASSET_SKU_SALES_STATUS,
|
||||||
|
} from "./skuFieldConfig";
|
||||||
import {
|
import {
|
||||||
EMPTY_PREVIEW_NODE_OFFSET_MAP,
|
EMPTY_PREVIEW_NODE_OFFSET_MAP,
|
||||||
EMPTY_PREVIEW_NODE_ROTATION_MAP,
|
EMPTY_PREVIEW_NODE_ROTATION_MAP,
|
||||||
@ -30,7 +33,7 @@ export function createEmptySkuItem(): NonIntegratedSkuItem {
|
|||||||
material: "",
|
material: "",
|
||||||
boardMaterialType: "",
|
boardMaterialType: "",
|
||||||
versionNo: "V01",
|
versionNo: "V01",
|
||||||
attributeCode: "",
|
attributeCode: DEFAULT_ASSET_SKU_ATTRIBUTE_CODE,
|
||||||
drawingId: "",
|
drawingId: "",
|
||||||
installMethod: "",
|
installMethod: "",
|
||||||
executeStandard: "",
|
executeStandard: "",
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import {
|
|||||||
import { isSeriesComponentAssetSkuItem } from "./seriesComponentAssetSkuHelpers";
|
import { isSeriesComponentAssetSkuItem } from "./seriesComponentAssetSkuHelpers";
|
||||||
import { resolveExecutionStandardOption } from "./detailValueHelpers";
|
import { resolveExecutionStandardOption } from "./detailValueHelpers";
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_ASSET_SKU_ATTRIBUTE_CODE,
|
||||||
normalizeSkuSalesStatus,
|
normalizeSkuSalesStatus,
|
||||||
normalizeNationalStandardCompliance,
|
normalizeNationalStandardCompliance,
|
||||||
normalizeSetCountValue,
|
normalizeSetCountValue,
|
||||||
@ -62,7 +63,9 @@ export function deriveSkuItem(
|
|||||||
});
|
});
|
||||||
const autoSpecName = buildAutoSpecName(item);
|
const autoSpecName = buildAutoSpecName(item);
|
||||||
const versionNo = normalizeSkuVersionNo(item.versionNo);
|
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 importedContentLocked = isImportedSkuContentLocked(item);
|
||||||
const skuWorkbookImported = isSkuWorkbookImportedItem(item);
|
const skuWorkbookImported = isSkuWorkbookImportedItem(item);
|
||||||
const shouldPreserveImportedContent =
|
const shouldPreserveImportedContent =
|
||||||
@ -134,6 +137,7 @@ export function deriveSkuItem(
|
|||||||
minimumOrderQuantity: item.minimumOrderQuantity?.trim() ?? "",
|
minimumOrderQuantity: item.minimumOrderQuantity?.trim() ?? "",
|
||||||
salesStatus: normalizeSkuSalesStatus(item.salesStatus),
|
salesStatus: normalizeSkuSalesStatus(item.salesStatus),
|
||||||
boardMaterialType: item.boardMaterialType?.trim() ?? "",
|
boardMaterialType: item.boardMaterialType?.trim() ?? "",
|
||||||
|
attributeCode,
|
||||||
tagMaterial: formValues.tagMaterial.trim() || item.tagMaterial.trim(),
|
tagMaterial: formValues.tagMaterial.trim() || item.tagMaterial.trim(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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("否");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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级" ? "是" : "否",
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -16,6 +16,7 @@ import {
|
|||||||
import type { FormValues } from "./productFormValueHelpers";
|
import type { FormValues } from "./productFormValueHelpers";
|
||||||
import { buildAutoBarcode } from "./skuAutoHelpers";
|
import { buildAutoBarcode } from "./skuAutoHelpers";
|
||||||
import { deriveSkuItem } from "./skuDerivationHelpers";
|
import { deriveSkuItem } from "./skuDerivationHelpers";
|
||||||
|
import { applySkuFieldUpdate } from "./skuFieldUpdateHelpers";
|
||||||
import { isImportedSkuContentLocked } from "./skuSourceHelpers";
|
import { isImportedSkuContentLocked } from "./skuSourceHelpers";
|
||||||
|
|
||||||
type SaveMessageType = "success" | "error";
|
type SaveMessageType = "success" | "error";
|
||||||
@ -107,7 +108,7 @@ export function useSkuItemEditController({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...item, [key]: value };
|
return applySkuFieldUpdate(item, key, value);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
setSaveMessage("");
|
setSaveMessage("");
|
||||||
|
|||||||
@ -86,4 +86,13 @@ describe("buildSkuContentRevisionSignature", () => {
|
|||||||
buildSignature(createSku()),
|
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()),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,11 +5,12 @@ import type {
|
|||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import type { PersistEditedProductOverrides } from "../../nonIntegratedDetailTypes";
|
import type { PersistEditedProductOverrides } from "../../nonIntegratedDetailTypes";
|
||||||
import type {
|
import {
|
||||||
NonIntegratedDocumentWorkspaceApprovalMap,
|
NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY,
|
||||||
NonIntegratedDocumentWorkspaceConfirmationMap,
|
type NonIntegratedDocumentWorkspaceApprovalMap,
|
||||||
NonIntegratedFactoryRow,
|
type NonIntegratedDocumentWorkspaceConfirmationMap,
|
||||||
NonIntegratedSkuItem,
|
type NonIntegratedFactoryRow,
|
||||||
|
type NonIntegratedSkuItem,
|
||||||
} from "../../nonIntegratedStorage";
|
} from "../../nonIntegratedStorage";
|
||||||
import { updateStyleDetailEntry, type StyleDetailForm } from "../style-assets/styleDetailState";
|
import { updateStyleDetailEntry, type StyleDetailForm } from "../style-assets/styleDetailState";
|
||||||
import {
|
import {
|
||||||
@ -32,6 +33,7 @@ import {
|
|||||||
updateProductFactoryRowsForSku,
|
updateProductFactoryRowsForSku,
|
||||||
} from "./productFactoryRowUpdateHelpers";
|
} from "./productFactoryRowUpdateHelpers";
|
||||||
import { getProductFactoryTargetSkuIds } from "./productFactoryTargetSkuHelpers";
|
import { getProductFactoryTargetSkuIds } from "./productFactoryTargetSkuHelpers";
|
||||||
|
import { buildSkuContentRevisionSignature } from "../preview/previewState";
|
||||||
|
|
||||||
type SaveMessageType = "success" | "error";
|
type SaveMessageType = "success" | "error";
|
||||||
|
|
||||||
@ -71,6 +73,7 @@ type UseProductFactoryRowControllerParams = {
|
|||||||
>;
|
>;
|
||||||
productFactoryReopenField: ProductFactoryEditableKey;
|
productFactoryReopenField: ProductFactoryEditableKey;
|
||||||
productFactoryRows: NonIntegratedFactoryRow[];
|
productFactoryRows: NonIntegratedFactoryRow[];
|
||||||
|
scheduleAutoSave: () => void;
|
||||||
setDocumentWorkspaceApprovedAt: Dispatch<
|
setDocumentWorkspaceApprovedAt: Dispatch<
|
||||||
SetStateAction<NonIntegratedDocumentWorkspaceApprovalMap>
|
SetStateAction<NonIntegratedDocumentWorkspaceApprovalMap>
|
||||||
>;
|
>;
|
||||||
@ -83,6 +86,9 @@ type UseProductFactoryRowControllerParams = {
|
|||||||
setSaveMessageType: Dispatch<SetStateAction<SaveMessageType>>;
|
setSaveMessageType: Dispatch<SetStateAction<SaveMessageType>>;
|
||||||
setSkuItems: Dispatch<SetStateAction<NonIntegratedSkuItem[]>>;
|
setSkuItems: Dispatch<SetStateAction<NonIntegratedSkuItem[]>>;
|
||||||
setStyleDetails: Dispatch<SetStateAction<Record<string, StyleDetailForm>>>;
|
setStyleDetails: Dispatch<SetStateAction<Record<string, StyleDetailForm>>>;
|
||||||
|
skuBatchChangeTypes: Record<string, string>;
|
||||||
|
skuBatchCount: number;
|
||||||
|
skuBatchFlowNos: Record<string, string>;
|
||||||
supplierCodeByName: ReadonlyMap<string, string>;
|
supplierCodeByName: ReadonlyMap<string, string>;
|
||||||
workflowSkuItems: NonIntegratedSkuItem[];
|
workflowSkuItems: NonIntegratedSkuItem[];
|
||||||
};
|
};
|
||||||
@ -106,6 +112,7 @@ export function useProductFactoryRowController({
|
|||||||
productFactoryModelAutofillRef,
|
productFactoryModelAutofillRef,
|
||||||
productFactoryReopenField,
|
productFactoryReopenField,
|
||||||
productFactoryRows,
|
productFactoryRows,
|
||||||
|
scheduleAutoSave,
|
||||||
setDocumentWorkspaceApprovedAt,
|
setDocumentWorkspaceApprovedAt,
|
||||||
setDocumentWorkspaceConfirmations,
|
setDocumentWorkspaceConfirmations,
|
||||||
setFactoryRows,
|
setFactoryRows,
|
||||||
@ -114,9 +121,33 @@ export function useProductFactoryRowController({
|
|||||||
setSaveMessageType,
|
setSaveMessageType,
|
||||||
setSkuItems,
|
setSkuItems,
|
||||||
setStyleDetails,
|
setStyleDetails,
|
||||||
|
skuBatchChangeTypes,
|
||||||
|
skuBatchCount,
|
||||||
|
skuBatchFlowNos,
|
||||||
supplierCodeByName,
|
supplierCodeByName,
|
||||||
workflowSkuItems,
|
workflowSkuItems,
|
||||||
}: UseProductFactoryRowControllerParams) {
|
}: 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(
|
function updateProductFactoryRow(
|
||||||
skuId: string,
|
skuId: string,
|
||||||
key: ProductFactoryEditableKey,
|
key: ProductFactoryEditableKey,
|
||||||
@ -135,81 +166,82 @@ export function useProductFactoryRowController({
|
|||||||
);
|
);
|
||||||
const supplierName = supplier.name;
|
const supplierName = supplier.name;
|
||||||
|
|
||||||
setSkuItems((current) => {
|
const targetSkuIds = getProductFactoryTargetSkuIds(
|
||||||
const targetSkuIds = getProductFactoryTargetSkuIds(
|
workflowSkuItems,
|
||||||
workflowSkuItems,
|
skuId,
|
||||||
skuId,
|
key,
|
||||||
key,
|
);
|
||||||
);
|
setFactoryProjectedSkuItems((current) =>
|
||||||
return current.map((item, index) =>
|
current.map((item, index) =>
|
||||||
targetSkuIds.has(item.id)
|
targetSkuIds.has(item.id)
|
||||||
? applySkuSupplierName(item, index, supplierName)
|
? applySkuSupplierName(item, index, supplierName)
|
||||||
: item,
|
: item,
|
||||||
);
|
),
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === "setCount") {
|
if (key === "setCount") {
|
||||||
setSkuItems((current) => {
|
const targetSkuIds = getProductFactoryTargetSkuIds(
|
||||||
const targetSkuIds = getProductFactoryTargetSkuIds(
|
workflowSkuItems,
|
||||||
workflowSkuItems,
|
skuId,
|
||||||
skuId,
|
key,
|
||||||
key,
|
);
|
||||||
);
|
setFactoryProjectedSkuItems((current) =>
|
||||||
return current.map((item) =>
|
current.map((item) =>
|
||||||
targetSkuIds.has(item.id) ? { ...item, setCount: value } : item,
|
targetSkuIds.has(item.id) ? { ...item, setCount: value } : item,
|
||||||
);
|
),
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === "minimumOrderQuantity") {
|
if (key === "minimumOrderQuantity") {
|
||||||
setSkuItems((current) => {
|
const targetSkuIds = getProductFactoryTargetSkuIds(
|
||||||
const targetSkuIds = getProductFactoryTargetSkuIds(
|
workflowSkuItems,
|
||||||
workflowSkuItems,
|
skuId,
|
||||||
skuId,
|
key,
|
||||||
key,
|
productFactoryRows,
|
||||||
productFactoryRows,
|
value,
|
||||||
value,
|
productFactoryModelAutofillRef.current,
|
||||||
productFactoryModelAutofillRef.current,
|
);
|
||||||
);
|
setFactoryProjectedSkuItems((current) =>
|
||||||
return current.map((item) =>
|
current.map((item) =>
|
||||||
targetSkuIds.has(item.id)
|
targetSkuIds.has(item.id)
|
||||||
? { ...item, minimumOrderQuantity: value }
|
? { ...item, minimumOrderQuantity: value }
|
||||||
: item,
|
: item,
|
||||||
);
|
),
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === "installMethod") {
|
if (key === "installMethod") {
|
||||||
setSkuItems((current) => {
|
const targetSkuIds = getProductFactoryTargetSkuIds(
|
||||||
const targetSkuIds = getProductFactoryTargetSkuIds(
|
workflowSkuItems,
|
||||||
workflowSkuItems,
|
skuId,
|
||||||
skuId,
|
key,
|
||||||
key,
|
);
|
||||||
);
|
const targetStyleKeys = new Set(
|
||||||
const targetStyleKeys = new Set(
|
workflowSkuItems
|
||||||
workflowSkuItems
|
.filter((item) => targetSkuIds.has(item.id))
|
||||||
.filter((item) => targetSkuIds.has(item.id))
|
.map((item) => getStyleKeyFromModel(item.model))
|
||||||
.map((item) => getStyleKeyFromModel(item.model))
|
.filter(Boolean),
|
||||||
.filter(Boolean),
|
);
|
||||||
);
|
|
||||||
|
|
||||||
if (targetStyleKeys.size > 0) {
|
if (targetStyleKeys.size > 0) {
|
||||||
setStyleDetails((currentDetails) => {
|
setStyleDetails((currentDetails) => {
|
||||||
let nextDetails = currentDetails;
|
let nextDetails = currentDetails;
|
||||||
targetStyleKeys.forEach((styleKey) => {
|
targetStyleKeys.forEach((styleKey) => {
|
||||||
nextDetails = updateStyleDetailEntry(nextDetails, styleKey, {
|
nextDetails = updateStyleDetailEntry(nextDetails, styleKey, {
|
||||||
installMethod: value,
|
installMethod: value,
|
||||||
});
|
|
||||||
});
|
});
|
||||||
return nextDetails;
|
|
||||||
});
|
});
|
||||||
}
|
return nextDetails;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return current.map((item) =>
|
setFactoryProjectedSkuItems((current) =>
|
||||||
|
current.map((item) =>
|
||||||
targetSkuIds.has(item.id) ? { ...item, installMethod: value } : item,
|
targetSkuIds.has(item.id) ? { ...item, installMethod: value } : item,
|
||||||
);
|
),
|
||||||
});
|
);
|
||||||
|
scheduleAutoSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
setFactoryRows((current) =>
|
setFactoryRows((current) =>
|
||||||
@ -238,7 +270,7 @@ export function useProductFactoryRowController({
|
|||||||
const supplierName = supplier.name;
|
const supplierName = supplier.name;
|
||||||
|
|
||||||
if (supplierName) {
|
if (supplierName) {
|
||||||
setSkuItems((current) =>
|
setFactoryProjectedSkuItems((current) =>
|
||||||
current.map((item, index) =>
|
current.map((item, index) =>
|
||||||
item.id === skuId
|
item.id === skuId
|
||||||
? applySkuSupplierName(item, index, supplierName)
|
? applySkuSupplierName(item, index, supplierName)
|
||||||
@ -390,7 +422,7 @@ export function useProductFactoryRowController({
|
|||||||
);
|
);
|
||||||
if (currentFactoryRow?.confirmed) return;
|
if (currentFactoryRow?.confirmed) return;
|
||||||
|
|
||||||
setSkuItems((current) =>
|
setFactoryProjectedSkuItems((current) =>
|
||||||
buildSkuItemsAfterSetCountCommit({
|
buildSkuItemsAfterSetCountCommit({
|
||||||
allowImportedContentLocked: true,
|
allowImportedContentLocked: true,
|
||||||
current,
|
current,
|
||||||
|
|||||||
@ -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 type { ReactNode } from 'react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
import ProductDevelopmentOperationLogClient from './ProductDevelopmentOperationLogClient'
|
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', () => ({
|
jest.mock('next/link', () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: ({ children, href }: { children: ReactNode; href: string }) => (
|
default: ({ children, href }: { children: ReactNode; href: string }) => (
|
||||||
@ -9,26 +18,36 @@ jest.mock('next/link', () => ({
|
|||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe('ProductDevelopmentOperationLogClient', () => {
|
jest.mock('@/app/operation-logs/OperationLogClient', () => ({
|
||||||
it('shows the product development title and return link', async () => {
|
__esModule: true,
|
||||||
global.fetch = jest.fn().mockResolvedValue({
|
default: () => <div>操作日志内容</div>,
|
||||||
ok: true,
|
}))
|
||||||
json: async () => ({
|
|
||||||
success: true,
|
|
||||||
data: [],
|
|
||||||
meta: { pagination: { page: 1, total: 0, totalPages: 1, hasMore: false } },
|
|
||||||
}),
|
|
||||||
}) as jest.Mock
|
|
||||||
|
|
||||||
|
jest.mock('@/app/notification-logs/NotificationLogClient', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () => <div>通知记录内容</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('ProductDevelopmentOperationLogClient', () => {
|
||||||
|
beforeEach(() => replaceMock.mockReset())
|
||||||
|
|
||||||
|
it('shows operation and notification logs as tabs on the same page', async () => {
|
||||||
|
const actor = userEvent.setup()
|
||||||
render(<ProductDevelopmentOperationLogClient />)
|
render(<ProductDevelopmentOperationLogClient />)
|
||||||
|
|
||||||
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')
|
expect(screen.getByRole('link', { name: '\u8fd4\u56de\u4ea7\u54c1\u8d44\u6599' })).toHaveAttribute('href', '/intake')
|
||||||
await waitFor(() => {
|
expect(screen.getByRole('tab', { name: '操作日志' })).toHaveAttribute('aria-selected', 'true')
|
||||||
expect(global.fetch).toHaveBeenCalledWith(
|
expect(screen.getByRole('tab', { name: '通知记录' })).toHaveAttribute('aria-selected', 'false')
|
||||||
expect.stringContaining('/api/operation-logs?'),
|
expect(screen.getByRole('tabpanel', { name: '操作日志' })).toBeVisible()
|
||||||
{ cache: 'no-store' },
|
|
||||||
)
|
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 },
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,31 +1,110 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
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 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() {
|
export default function ProductDevelopmentOperationLogClient() {
|
||||||
|
const router = useRouter()
|
||||||
|
const pathname = usePathname()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const requestedTab = searchParams.get('tab')
|
||||||
|
const [activeTab, setActiveTab] = useState<LogTab>(
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="border-b border-zinc-200 bg-white">
|
<header className="border-b border-zinc-200 bg-white">
|
||||||
<div className="mx-auto flex max-w-[1680px] items-center justify-between gap-4 px-5 py-4">
|
<div className="mx-auto max-w-[1680px] px-5">
|
||||||
<div>
|
<div className="flex items-center justify-between gap-4 py-4">
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-500">
|
<div>
|
||||||
<FileWarning className="h-4 w-4" />
|
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-500">
|
||||||
产品开发
|
<FileWarning className="h-4 w-4" />
|
||||||
|
产品开发
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-1 text-xl font-bold">产品开发日志</h1>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-1 text-xl font-bold">产品开发操作日志</h1>
|
<Link
|
||||||
|
href="/intake"
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm font-semibold text-zinc-700 transition hover:bg-zinc-50"
|
||||||
|
>
|
||||||
|
返回产品资料
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div role="tablist" aria-label="产品开发日志类型" className="flex items-center gap-1">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const Icon = tab.icon
|
||||||
|
const selected = activeTab === tab.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={selected}
|
||||||
|
aria-controls={`${tab.value}-panel`}
|
||||||
|
id={`${tab.value}-tab`}
|
||||||
|
onClick={() => selectTab(tab.value)}
|
||||||
|
className={`inline-flex h-10 items-center gap-2 border-b-2 px-4 text-sm font-semibold transition ${
|
||||||
|
selected
|
||||||
|
? 'border-zinc-900 text-zinc-900'
|
||||||
|
: 'border-transparent text-zinc-500 hover:border-zinc-300 hover:text-zinc-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Link
|
|
||||||
href="/intake"
|
|
||||||
className="inline-flex items-center gap-2 rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm font-semibold text-zinc-700 transition hover:bg-zinc-50"
|
|
||||||
>
|
|
||||||
返回产品资料
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<OperationLogClient showHeader={false} />
|
<div
|
||||||
|
id="operations-panel"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="operations-tab"
|
||||||
|
hidden={activeTab !== 'operations'}
|
||||||
|
>
|
||||||
|
<OperationLogClient showHeader={false} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="notifications-panel"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="notifications-tab"
|
||||||
|
hidden={activeTab !== 'notifications'}
|
||||||
|
className="min-h-screen bg-[#f5f7f7] p-5"
|
||||||
|
>
|
||||||
|
<div className="mx-auto h-[calc(100vh-190px)] min-h-[620px] max-w-[1680px]">
|
||||||
|
<NotificationLogClient embedded />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { verifyToken } from '@/lib/jwt'
|
|||||||
import ProductDevelopmentOperationLogClient from './ProductDevelopmentOperationLogClient'
|
import ProductDevelopmentOperationLogClient from './ProductDevelopmentOperationLogClient'
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Product Development Operation Logs | JianSun Product Development',
|
title: '产品开发日志 | 家具业务系统',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ProductDevelopmentOperationLogPage() {
|
export default async function ProductDevelopmentOperationLogPage() {
|
||||||
|
|||||||
@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -39,13 +39,15 @@ export type PersistedNonIntegratedListState = {
|
|||||||
|
|
||||||
const NON_INTEGRATED_LIST_STATE_STORAGE_PREFIX =
|
const NON_INTEGRATED_LIST_STATE_STORAGE_PREFIX =
|
||||||
"non-integrated-list-state-v2";
|
"non-integrated-list-state-v2";
|
||||||
|
const PRODUCT_TYPE_DEFAULT_MIGRATION_KEY =
|
||||||
|
"non-integrated-product-type-default-v1";
|
||||||
|
|
||||||
export function createDefaultNonIntegratedListQuery(
|
export function createDefaultNonIntegratedListQuery(
|
||||||
productKind: NonIntegratedProductKindFilter,
|
productKind: NonIntegratedProductKindFilter,
|
||||||
): NonIntegratedListQuery {
|
): NonIntegratedListQuery {
|
||||||
return {
|
return {
|
||||||
productKind,
|
productKind,
|
||||||
productTagFilter: "finished",
|
productTagFilter: "all",
|
||||||
seriesName: "",
|
seriesName: "",
|
||||||
productCategory: "",
|
productCategory: "",
|
||||||
createdDateFrom: "",
|
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(
|
export function buildNonIntegratedListStateStorageKey(
|
||||||
productDevelopmentKind: ProductDevelopmentKind,
|
productDevelopmentKind: ProductDevelopmentKind,
|
||||||
fixedProductKind?: NonIntegratedProductKindFilter,
|
fixedProductKind?: NonIntegratedProductKindFilter,
|
||||||
@ -123,9 +169,21 @@ export function readPersistedNonIntegratedListState(
|
|||||||
if (!rawValue) return null;
|
if (!rawValue) return null;
|
||||||
const parsed = JSON.parse(rawValue);
|
const parsed = JSON.parse(rawValue);
|
||||||
|
|
||||||
return parsed && typeof parsed === "object"
|
if (!parsed || typeof parsed !== "object") return null;
|
||||||
? (parsed as PersistedNonIntegratedListState)
|
|
||||||
: 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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(<NotificationLogClient />)
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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<NotificationStatus>(['pending', 'sending', 'retrying', 'sent', 'unread'])
|
||||||
|
|
||||||
|
export default function NotificationLogClient({ embedded = false }: { embedded?: boolean }) {
|
||||||
|
const [records, setRecords] = useState<NotificationDeliveryRecord[]>([])
|
||||||
|
const [selected, setSelected] = useState<NotificationDeliveryRecord | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [configured, setConfigured] = useState(true)
|
||||||
|
const [syncWarning, setSyncWarning] = useState<string | null>(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<NotificationStatus | ''>('')
|
||||||
|
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 (
|
||||||
|
<main className={`relative flex min-h-[620px] flex-col bg-[#f5f7f7] text-zinc-900 ${embedded ? 'h-full' : 'h-full p-5'}`}>
|
||||||
|
{!embedded ? (
|
||||||
|
<header className="flex flex-wrap items-center justify-between gap-4 border border-zinc-200 bg-white px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-xs font-semibold text-zinc-500"><BellRing className="h-4 w-4" />管理员后台</div>
|
||||||
|
<h1 className="mt-1 text-xl font-bold">通知记录</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-zinc-500">钉钉个人通知投递审计</p>
|
||||||
|
</header>
|
||||||
|
) : null}
|
||||||
|
{!configured ? (
|
||||||
|
<div className={`${embedded ? 'border' : 'border-x border-b'} border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-800`}>通知服务参数尚未配置,当前只能查看已有记录。</div>
|
||||||
|
) : null}
|
||||||
|
{syncWarning ? (
|
||||||
|
<div className={`${embedded && configured ? 'border' : 'border-x border-b'} border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700`}>{syncWarning}</div>
|
||||||
|
) : null}
|
||||||
|
<section className={`${embedded ? '' : 'mt-3'} flex min-h-0 flex-1 flex-col overflow-hidden border border-zinc-200 bg-white`}>
|
||||||
|
<NotificationLogFilters
|
||||||
|
keyword={keyword}
|
||||||
|
status={status}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
refreshing={refreshing}
|
||||||
|
onKeywordChange={setKeyword}
|
||||||
|
onStatusChange={(value) => changeFilter(() => setStatus(value))}
|
||||||
|
onStartDateChange={(value) => changeFilter(() => setStartDate(value))}
|
||||||
|
onEndDateChange={(value) => changeFilter(() => setEndDate(value))}
|
||||||
|
onRefresh={() => void load(true)}
|
||||||
|
/>
|
||||||
|
<NotificationLogTable records={records} loading={loading} error={error} onSelect={setSelected} />
|
||||||
|
</section>
|
||||||
|
<NotificationLogPagination
|
||||||
|
page={page}
|
||||||
|
pageInput={pageInput}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
totalPages={totalPages}
|
||||||
|
loading={loading}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageInputChange={setPageInput}
|
||||||
|
onCommitPageInput={commitPageInput}
|
||||||
|
onPageSizeChange={(value) => { setPageSize(value); setPage(1) }}
|
||||||
|
/>
|
||||||
|
{selected ? <NotificationLogDetailDrawer record={selected} onClose={() => setSelected(null)} /> : null}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
<div className="fixed inset-0 z-[100] flex justify-end bg-black/20" onMouseDown={onClose}>
|
||||||
|
<aside
|
||||||
|
className="h-full w-full max-w-[600px] overflow-y-auto border-l border-zinc-200 bg-white p-5 shadow-2xl"
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-zinc-500">通知详情</p>
|
||||||
|
<h2 className="mt-1 text-lg font-bold text-zinc-900">{record.title}</h2>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} className="inline-flex h-8 w-8 items-center justify-center rounded-[5px] text-zinc-500 hover:bg-zinc-100" aria-label="关闭详情">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5"><NotificationStatusBadge status={record.status} /></div>
|
||||||
|
<dl className="mt-6 divide-y divide-zinc-100 border-y border-zinc-100 text-sm">
|
||||||
|
{fields.map(([label, value]) => (
|
||||||
|
<div key={label} className="grid grid-cols-[92px_minmax(0,1fr)] gap-3 py-3">
|
||||||
|
<dt className="text-zinc-500">{label}</dt>
|
||||||
|
<dd className="break-words font-medium text-zinc-800">{value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
<section className="mt-6">
|
||||||
|
<h3 className="text-sm font-semibold text-zinc-800">消息正文</h3>
|
||||||
|
<p className="mt-2 whitespace-pre-wrap border border-zinc-200 bg-zinc-50 p-4 text-sm leading-7 text-zinc-700">{record.content}</p>
|
||||||
|
</section>
|
||||||
|
<section className="mt-6 border-t border-zinc-100 pt-5">
|
||||||
|
<div className="text-xs text-zinc-500">按钮文字</div>
|
||||||
|
<div className="mt-1 text-sm font-semibold text-zinc-800">{record.actionTitle}</div>
|
||||||
|
<a href={record.actionUrl} target="_blank" rel="noreferrer" className="mt-3 inline-flex items-center gap-2 text-sm font-semibold text-zinc-700 underline decoration-zinc-300 underline-offset-4 hover:text-zinc-950">
|
||||||
|
打开消息链接
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
{record.lastErrorMessage ? (
|
||||||
|
<section className="mt-6 border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
|
{record.lastErrorMessage}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 border-b border-zinc-200 bg-white px-4 py-3">
|
||||||
|
<label className="relative min-w-[260px] flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
||||||
|
<input
|
||||||
|
value={props.keyword}
|
||||||
|
onChange={(event) => props.onKeywordChange(event.target.value)}
|
||||||
|
placeholder="搜索收件人、标题、正文或业务事件"
|
||||||
|
className="h-9 w-full rounded-[5px] border border-zinc-300 bg-white pl-9 pr-3 text-sm text-zinc-900 outline-none transition focus:border-zinc-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={props.status}
|
||||||
|
onChange={(event) => props.onStatusChange(event.target.value as NotificationStatus | '')}
|
||||||
|
aria-label="投递状态"
|
||||||
|
className="h-9 rounded-[5px] border border-zinc-300 bg-white px-3 text-sm text-zinc-800 outline-none"
|
||||||
|
>
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
{Object.entries(statusLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={props.startDate}
|
||||||
|
onChange={(event) => 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"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={props.endDate}
|
||||||
|
onChange={(event) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={props.onRefresh}
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-[5px] border border-zinc-300 bg-white text-zinc-600 transition hover:bg-zinc-50"
|
||||||
|
title="刷新通知状态"
|
||||||
|
aria-label="刷新通知状态"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${props.refreshing ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
<nav aria-label="通知记录分页" className="absolute bottom-6 left-1/2 z-30 flex max-w-[calc(100%-32px)] -translate-x-1/2 items-center gap-1 whitespace-nowrap rounded-full border border-zinc-200 bg-white/95 px-2 py-2 text-xs shadow-[0_4px_12px_rgba(0,0,0,0.06),0_2px_4px_rgba(0,0,0,0.04)] backdrop-blur-xl sm:gap-2.5 sm:px-4 sm:text-[13px]">
|
||||||
|
<span className="text-zinc-600 sm:mr-2">{props.loading ? '统计中...' : `共 ${props.total} 条`}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={props.loading || props.page <= 1}
|
||||||
|
onClick={() => props.onPageChange(Math.max(1, props.page - 1))}
|
||||||
|
className="inline-flex h-[30px] w-[30px] items-center justify-center gap-1 rounded-[4px] border border-zinc-200 bg-white text-zinc-800 disabled:cursor-not-allowed disabled:opacity-40 sm:w-auto sm:px-3"
|
||||||
|
aria-label="上一页"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3 w-3" /><span className="hidden sm:inline">上一页</span>
|
||||||
|
</button>
|
||||||
|
<span className="inline-flex items-center gap-1 text-zinc-600">
|
||||||
|
<input
|
||||||
|
value={props.pageInput}
|
||||||
|
onChange={(event) => props.onPageInputChange(event.target.value.replace(/\D/g, ''))}
|
||||||
|
onBlur={props.onCommitPageInput}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') props.onCommitPageInput()
|
||||||
|
}}
|
||||||
|
inputMode="numeric"
|
||||||
|
aria-label="跳转页码"
|
||||||
|
className="h-7 w-10 rounded-[4px] border border-zinc-200 bg-white px-1 text-center font-semibold text-zinc-800 outline-none focus:border-zinc-500 sm:w-[52px]"
|
||||||
|
/>
|
||||||
|
<span className="text-zinc-400">/</span>
|
||||||
|
<span>{props.totalPages} 页</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={props.loading || props.page >= props.totalPages}
|
||||||
|
onClick={() => props.onPageChange(Math.min(props.totalPages, props.page + 1))}
|
||||||
|
className="inline-flex h-[30px] w-[30px] items-center justify-center gap-1 rounded-[4px] border border-zinc-200 bg-white text-zinc-800 disabled:cursor-not-allowed disabled:opacity-40 sm:w-auto sm:px-3"
|
||||||
|
aria-label="下一页"
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">下一页</span><ArrowRight className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-zinc-600 sm:ml-2">
|
||||||
|
<span className="hidden sm:inline">每页</span>
|
||||||
|
<select
|
||||||
|
value={props.pageSize}
|
||||||
|
onChange={(event) => props.onPageSizeChange(Number(event.target.value))}
|
||||||
|
aria-label="每页条数"
|
||||||
|
className="h-7 rounded-[4px] border border-zinc-200 bg-white px-2 text-zinc-800 outline-none"
|
||||||
|
>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
<option value={100}>100</option>
|
||||||
|
<option value={200}>200</option>
|
||||||
|
</select>
|
||||||
|
<span className="hidden sm:inline">条</span>
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto bg-white pb-24">
|
||||||
|
<table className="w-full min-w-[1120px] border-collapse text-sm">
|
||||||
|
<thead className="sticky top-0 z-10 bg-zinc-100 text-left text-xs font-semibold text-zinc-600">
|
||||||
|
<tr>
|
||||||
|
<th className="border-b border-zinc-200 px-4 py-3">创建时间</th>
|
||||||
|
<th className="border-b border-zinc-200 px-4 py-3">状态</th>
|
||||||
|
<th className="border-b border-zinc-200 px-4 py-3">收件人</th>
|
||||||
|
<th className="border-b border-zinc-200 px-4 py-3">消息标题</th>
|
||||||
|
<th className="border-b border-zinc-200 px-4 py-3">触发来源</th>
|
||||||
|
<th className="border-b border-zinc-200 px-4 py-3">发送结果</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100">
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={6} className="px-4 py-16 text-center text-zinc-400">正在读取通知记录</td></tr>
|
||||||
|
) : error ? (
|
||||||
|
<tr><td colSpan={6} className="px-4 py-16 text-center font-medium text-red-600">{error}</td></tr>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<tr><td colSpan={6} className="px-4 py-16 text-center text-zinc-400">暂无通知记录</td></tr>
|
||||||
|
) : records.map((record) => (
|
||||||
|
<tr
|
||||||
|
key={record.id}
|
||||||
|
onClick={() => onSelect(record)}
|
||||||
|
className="cursor-pointer bg-white transition hover:bg-zinc-50"
|
||||||
|
>
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 text-zinc-500">{formatNotificationTime(record.createdAt)}</td>
|
||||||
|
<td className="px-4 py-3"><NotificationStatusBadge status={record.status} /></td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-semibold text-zinc-800">{record.recipientName}</div>
|
||||||
|
<div className="mt-0.5 text-xs text-zinc-400">{record.recipientEmail}</div>
|
||||||
|
</td>
|
||||||
|
<td className="max-w-[420px] px-4 py-3">
|
||||||
|
<p className="line-clamp-1 font-semibold text-zinc-800">{record.title}</p>
|
||||||
|
<p className="mt-1 line-clamp-1 text-xs text-zinc-500">{record.content}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="text-zinc-700">{record.businessEvent}</div>
|
||||||
|
<div className="mt-0.5 text-xs text-zinc-400">{record.source === 'SYSTEM' ? '系统任务' : record.initiatedByName}</div>
|
||||||
|
</td>
|
||||||
|
<td className="max-w-[320px] px-4 py-3 text-zinc-600">
|
||||||
|
{record.lastErrorMessage || (record.sentAt ? `发送于 ${formatNotificationTime(record.sentAt)}` : '已提交至通知中心')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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<NotificationStatus, string> = {
|
||||||
|
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 (
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-[4px] border px-2 py-1 text-xs font-semibold ${config.classes}`}>
|
||||||
|
<Icon className={`h-3.5 w-3.5 ${status === 'sending' ? 'animate-spin' : ''}`} />
|
||||||
|
{statusLabels[status]}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
furniture-3d-system/src/app/notification-logs/page.tsx
Normal file
9
furniture-3d-system/src/app/notification-logs/page.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: '通知记录 | 家具业务系统',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NotificationLogsPage() {
|
||||||
|
redirect('/intake/operation-logs?tab=notifications')
|
||||||
|
}
|
||||||
@ -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(<UserMenu user={user('ADMIN')} />)
|
||||||
|
|
||||||
|
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(<UserMenu user={user('USER')} />)
|
||||||
|
|
||||||
|
await actor.click(screen.getByRole('button', { name: /USER/ }))
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: '操作日志' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -11,7 +11,6 @@ type BasicInfoRequiredSkuField =
|
|||||||
| "versionNo"
|
| "versionNo"
|
||||||
| "attributeCode"
|
| "attributeCode"
|
||||||
| "drawingId"
|
| "drawingId"
|
||||||
| "installMethod"
|
|
||||||
| "executeStandard"
|
| "executeStandard"
|
||||||
| "nationalStandardCompliant"
|
| "nationalStandardCompliant"
|
||||||
| "batchNo";
|
| "batchNo";
|
||||||
@ -30,7 +29,6 @@ type BasicInfoConfirmationSku = {
|
|||||||
versionNo: string;
|
versionNo: string;
|
||||||
attributeCode: string;
|
attributeCode: string;
|
||||||
drawingId: string;
|
drawingId: string;
|
||||||
installMethod: string;
|
|
||||||
executeStandard: string;
|
executeStandard: string;
|
||||||
nationalStandardCompliant: string;
|
nationalStandardCompliant: string;
|
||||||
batchNo: number;
|
batchNo: number;
|
||||||
@ -52,7 +50,6 @@ export const BASIC_INFO_CONFIRMATION_REQUIRED_FIELDS: ReadonlyArray<{
|
|||||||
{ key: "versionNo", label: "版本号" },
|
{ key: "versionNo", label: "版本号" },
|
||||||
{ key: "attributeCode", label: "属性代码" },
|
{ key: "attributeCode", label: "属性代码" },
|
||||||
{ key: "drawingId", label: "图纸ID" },
|
{ key: "drawingId", label: "图纸ID" },
|
||||||
{ key: "installMethod", label: "安装方式" },
|
|
||||||
{ key: "executeStandard", label: "执行标准" },
|
{ key: "executeStandard", label: "执行标准" },
|
||||||
{ key: "nationalStandardCompliant", label: "符合国标" },
|
{ key: "nationalStandardCompliant", label: "符合国标" },
|
||||||
{ key: "batchNo", label: "创建批次" },
|
{ key: "batchNo", label: "创建批次" },
|
||||||
|
|||||||
@ -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<typeof fetch>
|
||||||
|
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<typeof fetch>,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(client.getDelivery('delivery-1')).rejects.toMatchObject<NotificationCenterRequestError>({
|
||||||
|
status: 401,
|
||||||
|
code: 'SIGNATURE_INVALID',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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<T>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> {
|
||||||
|
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<NotificationCenterDelivery>('POST', '/v1/notifications', input)
|
||||||
|
}
|
||||||
|
|
||||||
|
getDelivery(deliveryId: string) {
|
||||||
|
if (!deliveryId.trim()) throw new Error('通知投递编号不能为空')
|
||||||
|
return this.request<NotificationCenterDelivery>(
|
||||||
|
'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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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<NotificationRecipient | null> {
|
||||||
|
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 }
|
||||||
@ -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<ProductDevelopmentTodoSourceRecord[]> {
|
||||||
|
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<ProductDevelopmentTodoUser[]> {
|
||||||
|
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 }
|
||||||
@ -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<string, unknown> = {}) {
|
||||||
|
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<NotificationDeliveryError>({
|
||||||
|
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<NotificationDeliveryError>({
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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> = {}): 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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<string, unknown>) {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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<NotificationDeliveryStatus>([
|
||||||
|
'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 }
|
||||||
@ -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<BusinessAccountType>(['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<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string>()
|
||||||
|
: 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<string, {
|
||||||
|
recipient: RecipientResolution
|
||||||
|
items: PreparedTodoItem[]
|
||||||
|
}>()
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@ -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<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ProductDevelopmentTodoCandidate, 'productId' | 'spu'> & {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@ -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 }
|
||||||
@ -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<string, unknown>
|
||||||
|
approvedAt: Record<string, unknown>
|
||||||
|
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'
|
||||||
|
}
|
||||||
@ -333,6 +333,23 @@ ensure_bom_costing_worker_secret() {
|
|||||||
chmod 600 "$SHARED/.env" || true
|
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() {
|
ensure_image_search_model() {
|
||||||
local model_dir="$SHARED/models/dinov2-small"
|
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}"
|
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_file
|
||||||
local cron_line
|
local cron_line
|
||||||
local fixed_cron_line
|
local fixed_cron_line
|
||||||
|
local todo_cron_line
|
||||||
local lock_file="/tmp/furniture-3d-system-virtual-inventory-push.lock"
|
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 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 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 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 log_file="$SHARED/logs/jushuitan-virtual-inventory-push.log"
|
||||||
local fixed_log_file="$SHARED/logs/jushuitan-fixed-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"
|
mkdir -p "$SHARED/logs"
|
||||||
cron_file="$(mktemp)"
|
cron_file="$(mktemp)"
|
||||||
@ -402,12 +423,18 @@ install_virtual_inventory_push_cron() {
|
|||||||
sed '\|/api/intake/non-integrated/jushuitan-virtual-inventory-push|d' |
|
sed '\|/api/intake/non-integrated/jushuitan-virtual-inventory-push|d' |
|
||||||
sed '\|furniture-3d-system-virtual-inventory-push|d' |
|
sed '\|furniture-3d-system-virtual-inventory-push|d' |
|
||||||
sed '\|/api/intake/non-integrated/jushuitan-fixed-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"
|
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"
|
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' "$cron_line" >> "$cron_file"
|
||||||
printf '%s\n' "$fixed_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"
|
crontab "$cron_file"
|
||||||
rm -f -- "$cron_file"
|
rm -f -- "$cron_file"
|
||||||
}
|
}
|
||||||
@ -520,6 +547,7 @@ ensure_shared_non_integrated_assets() {
|
|||||||
mkdir -p "$BASE" "$RELEASES" "$SHARED"
|
mkdir -p "$BASE" "$RELEASES" "$SHARED"
|
||||||
copy_legacy_env_if_needed
|
copy_legacy_env_if_needed
|
||||||
ensure_bom_costing_worker_secret
|
ensure_bom_costing_worker_secret
|
||||||
|
ensure_product_development_todo_secret
|
||||||
ensure_repo
|
ensure_repo
|
||||||
fetch_target_commit "$(resolve_fetch_ref)"
|
fetch_target_commit "$(resolve_fetch_ref)"
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user