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:
WuYongJie 2026-08-27 16:39:54 +08:00
parent d8141a63a9
commit 78cfa85232
47 changed files with 3814 additions and 111 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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("安装方式"),
); );
}); });
}); });

View File

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

View File

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

View File

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

View File

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

View File

@ -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级" ? "是" : "否",
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -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: "创建批次" },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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