diff --git a/furniture-3d-system/src/app/api/supply-chain/management/modular-product-processing-orders/alert-recipients/route.ts b/furniture-3d-system/src/app/api/supply-chain/management/modular-product-processing-orders/alert-recipients/route.ts new file mode 100644 index 00000000..2b1569bf --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/management/modular-product-processing-orders/alert-recipients/route.ts @@ -0,0 +1,33 @@ +import { NextRequest } from 'next/server' +import { handleRequest, jsonSuccess } from '@/server/core/response' +import { modularProcessingAlertRecipientService } from '@/server/services/modular-processing-alert-recipient.service' + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => jsonSuccess( + await modularProcessingAlertRecipientService.getSettings(ctx.user), + 200, + ctx.requestId, + ), + { requireAuth: true, allowedRoles: ['ADMIN', 'SUPER_ADMIN'] }, + ) +} + +export async function PUT(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const body = await request.json().catch(() => null) as { userIds?: unknown } | null + return jsonSuccess( + await modularProcessingAlertRecipientService.saveSettings( + ctx.user, + body?.userIds, + ), + 200, + ctx.requestId, + ) + }, + { requireAuth: true, allowedRoles: ['ADMIN', 'SUPER_ADMIN'] }, + ) +} diff --git a/furniture-3d-system/src/app/supply-chain/management/modular-product-processing-orders/ModularProductProcessingOrdersView.test.tsx b/furniture-3d-system/src/app/supply-chain/management/modular-product-processing-orders/ModularProductProcessingOrdersView.test.tsx index beb5fc6b..708645c1 100644 --- a/furniture-3d-system/src/app/supply-chain/management/modular-product-processing-orders/ModularProductProcessingOrdersView.test.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/modular-product-processing-orders/ModularProductProcessingOrdersView.test.tsx @@ -826,7 +826,7 @@ describe('ModularProductProcessingOrdersView pane resizing', () => { } }) - it('shows the scheduled generation failure reason and administrator alert status', () => { + it('shows the scheduled generation failure reason and alert status', () => { render( { const alert = screen.getByRole('alert') expect(alert).toHaveTextContent('今日生成失败') expect(alert).toHaveTextContent('数据库读取超时,定时任务未完成。') - expect(alert).toHaveTextContent('管理员预警已发送') + expect(alert).toHaveTextContent('异常预警已发送') expect(within(alert).getByRole('button', { name: '立即重新生成' })).toBeInTheDocument() }) @@ -991,7 +991,8 @@ describe('ModularProductProcessingOrdersView pane resizing', () => { render() fireEvent.click(screen.getByRole('button', { name: '打开设置' })) - const dialog = screen.getByRole('dialog', { name: '设置' }) + fireEvent.click(screen.getByRole('menuitem', { name: '列设置' })) + const dialog = screen.getByRole('dialog', { name: '列设置' }) fireEvent.click(within(dialog).getByRole('checkbox', { name: '展示条码列' })) // 拖拽排序:把「商品名称」拖到 SKU 前面 const dragSource = dialog.querySelector('[data-column-key="productName"]') @@ -1006,7 +1007,7 @@ describe('ModularProductProcessingOrdersView pane resizing', () => { fireEvent.dragOver(dropTarget!, { dataTransfer }) fireEvent.drop(dropTarget!, { dataTransfer }) fireEvent.dragEnd(dragSource!, { dataTransfer }) - fireEvent.click(within(dialog).getByRole('button', { name: '完成' })) + fireEvent.click(within(dialog).getByRole('button', { name: '应用' })) expect(screen.queryByRole('button', { name: '筛选主表条码' })).toBeNull() const labels = screen @@ -1015,7 +1016,8 @@ describe('ModularProductProcessingOrdersView pane resizing', () => { expect(labels.indexOf('筛选主表商品名称')).toBeLessThan(labels.indexOf('筛选主表SKU')) fireEvent.click(screen.getByRole('button', { name: '打开设置' })) - const reopened = screen.getByRole('dialog', { name: '设置' }) + fireEvent.click(screen.getByRole('menuitem', { name: '列设置' })) + const reopened = screen.getByRole('dialog', { name: '列设置' }) expect(within(reopened).getByRole('checkbox', { name: '展示条码列' })).not.toBeChecked() window.localStorage.clear() }) @@ -1031,9 +1033,10 @@ describe('ModularProductProcessingOrdersView pane resizing', () => { render() fireEvent.click(screen.getByRole('button', { name: '打开设置' })) - const dialog = screen.getByRole('dialog', { name: '设置' }) + fireEvent.click(screen.getByRole('menuitem', { name: '筛选设置' })) + const dialog = screen.getByRole('dialog', { name: '筛选设置' }) fireEvent.click(within(dialog).getByRole('checkbox', { name: '快速筛选采购' })) - fireEvent.click(within(dialog).getByRole('button', { name: '完成' })) + fireEvent.click(within(dialog).getByRole('button', { name: '应用' })) const input = screen.getByPlaceholderText('输入采购,回车应用') fireEvent.change(input, { target: { value: '士元' } }) @@ -1052,6 +1055,133 @@ describe('ModularProductProcessingOrdersView pane resizing', () => { } }) + it('lets administrators configure alert recipients and excludes unbound accounts', async () => { + const previousFetch = global.fetch + const recipientSettings = { + configured: false, + selectedUserIds: ['admin-1'], + users: [ + { + id: 'admin-1', + name: '管理员甲', + email: 'admin@example.com', + role: 'ADMIN', + departmentName: '供应链中心', + dingtalkBound: true, + }, + { + id: 'business-1', + name: '业务甲', + email: 'business@example.com', + role: 'USER', + departmentName: '运营中心', + dingtalkBound: true, + }, + { + id: 'unbound-1', + name: '未绑定甲', + email: 'unbound@example.com', + role: 'USER', + departmentName: '', + dingtalkBound: false, + }, + ], + } + global.fetch = jest.fn(async (url, init) => { + if (String(url).includes('/alert-recipients')) { + const selectedUserIds = init?.method === 'PUT' + ? JSON.parse(String(init.body)).userIds + : recipientSettings.selectedUserIds + return { + ok: true, + json: async () => ({ + success: true, + data: { + ...recipientSettings, + configured: init?.method === 'PUT' || recipientSettings.configured, + selectedUserIds, + }, + }), + } as Response + } + return { + ok: true, + json: async () => ({ success: true, data: INITIAL_DATA }), + } as Response + }) + + try { + window.localStorage.clear() + const permissions = modularPermissions() + permissions.main.view = true + permissions.main.settings = true + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: '打开设置' })) + fireEvent.click(screen.getByRole('menuitem', { name: '异常预警人员' })) + const dialog = screen.getByRole('dialog', { name: '异常预警人员' }) + expect(within(dialog).getByText('异常预警人员')).toBeInTheDocument() + const candidateSearch = await within(dialog) + .findByRole('textbox', { name: '搜索可添加人员' }) + expect(within(dialog).getByRole('checkbox', { name: '选择当前预警人员 管理员甲' })) + .toBeInTheDocument() + + fireEvent.change(candidateSearch, { target: { value: '业务' } }) + fireEvent.click(within(dialog).getByRole('button', { name: '搜索' })) + fireEvent.click(await within(dialog).findByRole('checkbox', { name: '选择添加人员 业务甲' })) + fireEvent.click(within(dialog).getByRole('button', { name: '批量添加选中' })) + expect(within(dialog).getByRole('checkbox', { name: '选择当前预警人员 业务甲' })) + .toBeInTheDocument() + + fireEvent.change(candidateSearch, { target: { value: '未绑定' } }) + fireEvent.click(within(dialog).getByRole('button', { name: '搜索' })) + expect(await within(dialog).findByRole('checkbox', { name: '选择添加人员 未绑定甲' })) + .toBeDisabled() + expect(within(dialog).getByRole('button', { name: '添加预警人员 未绑定甲' })) + .toBeDisabled() + fireEvent.click(within(dialog).getByRole('button', { name: '保存预警人员' })) + + expect(await within(dialog).findByText('已保存 2 名预警人员')).toBeInTheDocument() + const saveRequest = (global.fetch as jest.Mock).mock.calls.find( + ([url, init]) => + String(url).includes('/alert-recipients') && init?.method === 'PUT', + ) + expect(JSON.parse(saveRequest[1].body)).toEqual({ + userIds: ['admin-1', 'business-1'], + }) + + fireEvent.click(within(dialog).getByRole('checkbox', { name: '选择当前预警人员 业务甲' })) + fireEvent.click(within(dialog).getByRole('button', { name: '批量删除' })) + expect(within(dialog).queryByRole('checkbox', { name: '选择当前预警人员 业务甲' })) + .not.toBeInTheDocument() + } finally { + global.fetch = previousFetch + window.localStorage.clear() + } + }) + + it('keeps alert-recipient settings hidden from non-admin settings managers', () => { + const permissions = modularPermissions() + permissions.main.view = true + permissions.main.settings = true + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: '打开设置' })) + expect(screen.queryByRole('menuitem', { name: '异常预警人员' })) + .not.toBeInTheDocument() + }) + it('renders an in-page source warning and retries the first load by itself', async () => { const previousFetch = global.fetch global.fetch = jest.fn(async () => ({ diff --git a/furniture-3d-system/src/app/supply-chain/management/modular-product-processing-orders/ModularProductProcessingOrdersView.tsx b/furniture-3d-system/src/app/supply-chain/management/modular-product-processing-orders/ModularProductProcessingOrdersView.tsx index 01ca2254..43a53ca4 100644 --- a/furniture-3d-system/src/app/supply-chain/management/modular-product-processing-orders/ModularProductProcessingOrdersView.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/modular-product-processing-orders/ModularProductProcessingOrdersView.tsx @@ -12,6 +12,7 @@ import { ChevronDown, CircleAlert, ClipboardList, + Columns3, Database, Download, Filter, @@ -22,8 +23,10 @@ import { RefreshCw, Search, Settings2, + SlidersHorizontal, Table2, Upload, + Users, X, } from 'lucide-react' import { @@ -57,6 +60,7 @@ import { type ModularProcessingFilterOption, type ModularProcessingOrderRow, type ModularProcessingSort, + type ModularProcessingAlertRecipientSettings, type ProcessingFieldGroup, type ProcessingFieldState, } from '@/lib/modular-product-processing-orders' @@ -65,6 +69,7 @@ import styles from './modular-product-processing-orders.module.css' import { ModularProcessingRulesPanel } from './ModularProcessingRulesPanel' type TabKey = 'orders' | 'workorders' | 'rules' | 'fields' | 'schedule' +type SettingsDialogKey = 'columns' | 'filters' | 'alerts' /** 允许在单元格里人工编辑的字段:公式算不出来的两个人工维护项。 */ type EditableManualKey = 'finalProcessingQuantity' | 'processingNote' @@ -568,14 +573,21 @@ function formatGenerationTime(value: string | null | undefined) { } function generationNotificationLabel(status: string) { - if (status === 'SENT') return '管理员预警已发送' - if (status === 'PARTIAL') return '管理员预警部分发送成功' - if (status === 'FAILED') return '管理员预警发送失败' + if (status === 'SENT') return '异常预警已发送' + if (status === 'PARTIAL') return '异常预警部分发送成功' + if (status === 'FAILED') return '异常预警发送失败' if (status === 'NOT_CONFIGURED') return '通知中心未配置' - if (status === 'NO_RECIPIENT') return '未找到可用系统管理员' + if (status === 'NO_RECIPIENT') return '未配置可用预警人员' return '' } +function accountRoleLabel(role: string) { + if (role === 'SUPER_ADMIN') return '超级管理员' + if (role === 'ADMIN') return '管理员' + if (role === 'SUPERVISOR') return '主管' + return '普通用户' +} + function displayValue( value: string | number | null, state: ProcessingFieldState, @@ -624,11 +636,13 @@ function displayComponentValue(value: string | number | boolean | null) { export function ModularProductProcessingOrdersView({ initialData, permissions: providedPermissions, + canConfigureAlertRecipients = false, canEdit = false, canDownload = false, }: { initialData: ModularProcessingOrderPage permissions?: ModularProductProcessingOrdersPermissions + canConfigureAlertRecipients?: boolean canEdit?: boolean canDownload?: boolean }) { @@ -685,8 +699,21 @@ export function ModularProductProcessingOrdersView({ const [columnWidthsLoaded, setColumnWidthsLoaded] = useState(false) const [viewSettings, setViewSettings] = useState(defaultOrderViewSettings) const [viewSettingsLoaded, setViewSettingsLoaded] = useState(false) - const [settingsOpen, setSettingsOpen] = useState(false) + const [settingsMenuOpen, setSettingsMenuOpen] = useState(false) + const [settingsDialog, setSettingsDialog] = useState(null) const [settingsDraft, setSettingsDraft] = useState(null) + const [alertRecipientSettings, setAlertRecipientSettings] = + useState(null) + const [alertRecipientIds, setAlertRecipientIds] = useState([]) + const [alertRecipientLoading, setAlertRecipientLoading] = useState(false) + const [alertRecipientSaving, setAlertRecipientSaving] = useState(false) + const [alertRecipientError, setAlertRecipientError] = useState(null) + const [alertRecipientNotice, setAlertRecipientNotice] = useState(null) + const [alertRecipientCurrentSearch, setAlertRecipientCurrentSearch] = useState('') + const [alertRecipientCandidateSearchInput, setAlertRecipientCandidateSearchInput] = useState('') + const [alertRecipientCandidateSearch, setAlertRecipientCandidateSearch] = useState('') + const [alertRecipientCurrentChecked, setAlertRecipientCurrentChecked] = useState([]) + const [alertRecipientCandidateChecked, setAlertRecipientCandidateChecked] = useState([]) const [draggedColumnKey, setDraggedColumnKey] = useState(null) const [dragOverColumnKey, setDragOverColumnKey] = useState(null) const [quickDrafts, setQuickDrafts] = useState>({}) @@ -741,6 +768,7 @@ export function ModularProductProcessingOrdersView({ const orderTableScrollRef = useRef(null) const masterDetailRegionRef = useRef(null) const exportMenuRef = useRef(null) + const settingsMenuRef = useRef(null) const paneResizeCleanupRef = useRef<(() => void) | null>(null) const columnResizeCleanupRef = useRef<(() => void) | null>(null) @@ -869,6 +897,57 @@ export function ModularProductProcessingOrdersView({ } }, [viewSettings, viewSettingsLoaded]) + useEffect(() => { + if (settingsDialog !== 'alerts' || !canConfigureAlertRecipients) return + let active = true + setAlertRecipientLoading(true) + setAlertRecipientError(null) + setAlertRecipientNotice(null) + void (async () => { + try { + const next = await readResponse( + await fetch( + '/api/supply-chain/management/modular-product-processing-orders/alert-recipients', + { cache: 'no-store' }, + ), + ) + if (!active) return + setAlertRecipientSettings(next) + setAlertRecipientIds(next.selectedUserIds) + } catch (cause) { + if (!active) return + setAlertRecipientSettings(null) + setAlertRecipientIds([]) + setAlertRecipientError( + cause instanceof Error ? cause.message : '预警人员配置加载失败', + ) + } finally { + if (active) setAlertRecipientLoading(false) + } + })() + return () => { + active = false + } + }, [canConfigureAlertRecipients, settingsDialog]) + + useEffect(() => { + if (!settingsMenuOpen) return + const handlePointerDown = (event: PointerEvent) => { + if (!settingsMenuRef.current?.contains(event.target as Node)) { + setSettingsMenuOpen(false) + } + } + const handleKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.key === 'Escape') setSettingsMenuOpen(false) + } + document.addEventListener('pointerdown', handlePointerDown) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('pointerdown', handlePointerDown) + document.removeEventListener('keydown', handleKeyDown) + } + }, [settingsMenuOpen]) + const startColumnResize = ( event: ReactPointerEvent, table: TableKind, @@ -1500,15 +1579,130 @@ export function ModularProductProcessingOrdersView({ // 重置只清理条件;有筛选时由状态变化触发普通查询,不触发生成。 } - const openSettings = () => { + const resetAlertRecipientWorkspace = () => { + setAlertRecipientCurrentSearch('') + setAlertRecipientCandidateSearchInput('') + setAlertRecipientCandidateSearch('') + setAlertRecipientCurrentChecked([]) + setAlertRecipientCandidateChecked([]) + } + + const openSettingsDialog = (dialog: SettingsDialogKey) => { if (!canOpenSettings) return - setSettingsDraft(JSON.parse(JSON.stringify(viewSettings)) as OrderViewSettings) - setSettingsOpen(true) + if (dialog === 'alerts' && !canConfigureAlertRecipients) return + setSettingsMenuOpen(false) + if (dialog === 'alerts') resetAlertRecipientWorkspace() + setSettingsDraft( + dialog === 'alerts' + ? null + : JSON.parse(JSON.stringify(viewSettings)) as OrderViewSettings, + ) + setSettingsDialog(dialog) } const closeSettings = () => { - setSettingsOpen(false) + setSettingsDialog(null) setSettingsDraft(null) + resetAlertRecipientWorkspace() + } + + const resetSettingsDraft = (dialog: Exclude) => { + const defaults = defaultOrderViewSettings() + setSettingsDraft((current) => current && { + columns: dialog === 'columns' ? defaults.columns : current.columns, + quickFields: dialog === 'filters' ? defaults.quickFields : current.quickFields, + }) + } + + const toggleAlertRecipient = (userId: string) => { + setAlertRecipientIds((current) => ( + current.includes(userId) + ? current.filter((id) => id !== userId) + : [...current, userId] + )) + setAlertRecipientCurrentChecked((current) => + current.filter((id) => id !== userId)) + setAlertRecipientNotice(null) + } + + const toggleCurrentAlertRecipient = (userId: string) => { + setAlertRecipientCurrentChecked((current) => ( + current.includes(userId) + ? current.filter((id) => id !== userId) + : [...current, userId] + )) + } + + const toggleCandidateAlertRecipient = (userId: string) => { + setAlertRecipientCandidateChecked((current) => ( + current.includes(userId) + ? current.filter((id) => id !== userId) + : [...current, userId] + )) + } + + const addAlertRecipient = (userId: string) => { + setAlertRecipientIds((current) => ( + current.includes(userId) ? current : [...current, userId] + )) + setAlertRecipientCandidateChecked((current) => + current.filter((id) => id !== userId)) + setAlertRecipientNotice(null) + } + + const removeCheckedAlertRecipients = () => { + const checked = new Set(alertRecipientCurrentChecked) + setAlertRecipientIds((current) => current.filter((id) => !checked.has(id))) + setAlertRecipientCurrentChecked([]) + setAlertRecipientNotice(null) + } + + const addCheckedAlertRecipients = (ids: string[]) => { + const addable = new Set( + ids.filter((id) => ( + alertRecipientSettings?.users.find((user) => user.id === id)?.dingtalkBound + )), + ) + if (addable.size === 0) return + setAlertRecipientIds((current) => [ + ...current, + ...Array.from(addable).filter((id) => !current.includes(id)), + ]) + setAlertRecipientCandidateChecked([]) + setAlertRecipientNotice(null) + } + + const saveAlertRecipients = async () => { + if (!canConfigureAlertRecipients || alertRecipientSaving) return + setAlertRecipientSaving(true) + setAlertRecipientError(null) + setAlertRecipientNotice(null) + try { + const next = await readResponse( + await fetch( + '/api/supply-chain/management/modular-product-processing-orders/alert-recipients', + { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ userIds: alertRecipientIds }), + }, + ), + ) + setAlertRecipientSettings(next) + setAlertRecipientIds(next.selectedUserIds) + resetAlertRecipientWorkspace() + setAlertRecipientNotice( + next.selectedUserIds.length > 0 + ? `已保存 ${next.selectedUserIds.length} 名预警人员` + : '已保存为不发送异常预警', + ) + } catch (cause) { + setAlertRecipientError( + cause instanceof Error ? cause.message : '预警人员保存失败', + ) + } finally { + setAlertRecipientSaving(false) + } } const toggleDraftColumn = (key: string) => { @@ -1605,7 +1799,7 @@ export function ModularProductProcessingOrdersView({ return next }) } - setSettingsOpen(false) + setSettingsDialog(null) setSettingsDraft(null) } @@ -1842,6 +2036,36 @@ export function ModularProductProcessingOrdersView({ const notificationLabel = generationHealth ? generationNotificationLabel(generationHealth.notificationStatus) : '' + const alertRecipientUsers = alertRecipientSettings?.users ?? [] + const selectedAlertRecipientUsers = alertRecipientUsers.filter((user) => + alertRecipientIds.includes(user.id)) + const matchesAlertRecipient = ( + user: (typeof alertRecipientUsers)[number], + search: string, + ) => { + const normalized = search.trim().toLocaleLowerCase('zh-CN') + if (!normalized) return true + return [ + user.name, + user.email, + user.departmentName, + accountRoleLabel(user.role), + ].some((value) => value.toLocaleLowerCase('zh-CN').includes(normalized)) + } + const visibleSelectedAlertRecipientUsers = selectedAlertRecipientUsers.filter( + (user) => matchesAlertRecipient(user, alertRecipientCurrentSearch), + ) + const normalizedCandidateSearch = + alertRecipientCandidateSearch.trim().toLocaleLowerCase('zh-CN') + const alertRecipientSearchResults = normalizedCandidateSearch + ? alertRecipientUsers.filter((user) => ( + !alertRecipientIds.includes(user.id) && + matchesAlertRecipient(user, alertRecipientCandidateSearch) + )) + : [] + const selectableAlertRecipientSearchIds = alertRecipientSearchResults + .filter((user) => user.dingtalkBound) + .map((user) => user.id) return (
@@ -2090,15 +2314,52 @@ export function ModularProductProcessingOrdersView({ )} {refreshNotice && {refreshNotice}} {canOpenSettings && ( - + + + {settingsMenuOpen && ( +
+ + + {canConfigureAlertRecipients ? ( + <> +
+ + + ) : null} +
+ )} + )} {canExportMain && ( @@ -2996,109 +3257,375 @@ export function ModularProductProcessingOrdersView({ document.body, )} - {canOpenSettings && settingsOpen && settingsDraft && ( -
{ if (event.target === event.currentTarget) closeSettings() }}> -
-
-
- 设置 -

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

-
- -
-
-
-
-

列设置

-

按住任意字段拖动即可调整顺序;勾选控制是否展示(至少保留一列)。

-
- {settingsDraft.columns.map((column) => { - const field = ORDER_FIELD_BY_KEY.get(column.key) - if (!field) return null - return ( -
{ - setDraggedColumnKey(column.key) - event.dataTransfer.effectAllowed = 'move' - event.dataTransfer.setData('text/plain', column.key) - }} - onDragOver={(event) => { - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - if (dragOverColumnKey !== column.key) setDragOverColumnKey(column.key) - }} - onDragLeave={() => setDragOverColumnKey((current) => (current === column.key ? null : current))} - onDrop={(event) => { - event.preventDefault() - const fromKey = draggedColumnKey || event.dataTransfer.getData('text/plain') - if (fromKey) reorderDraftColumn(fromKey, column.key) - setDraggedColumnKey(null) - setDragOverColumnKey(null) - }} - onDragEnd={() => { - setDraggedColumnKey(null) - setDragOverColumnKey(null) - }} - > - -
+ )} +
+
+
+ + + +
+
-
- - -
- -
+ ) : null} + + {settingsDialog === 'alerts' && canConfigureAlertRecipients ? ( +
{ if (event.target === event.currentTarget) closeSettings() }}> +
+
+
+ 异常预警人员 +

+ 系统生成失败时只提醒勾选人员。未绑定钉钉的人员不可选; + {alertRecipientSettings?.configured + ? '当前已启用自定义名单。' + : '首次保存前暂时沿用有钉钉的系统管理员作为默认名单。'} +

+
+ +
+
+
+ {alertRecipientLoading ? ( +
正在加载可配置人员...
+ ) : alertRecipientUsers.length === 0 ? ( +
暂无可配置的内部在职人员
+ ) : ( + <> +
+
+
+ 当前预警人员 +

只有名单内人员会收到生成失败预警。

+
+ {selectedAlertRecipientUsers.length} 人 +
+
+ + + + +
+
+ {selectedAlertRecipientUsers.length === 0 ? ( +
+ 当前未配置预警人员,生成失败时不会发送预警 +
+ ) : visibleSelectedAlertRecipientUsers.length === 0 ? ( +
没有匹配的当前预警人员
+ ) : visibleSelectedAlertRecipientUsers.map((user) => ( +
+ toggleCurrentAlertRecipient(user.id)} + /> + + {user.name} + + {user.departmentName || '未分配部门'} + {' · '} + {accountRoleLabel(user.role)} + + + {user.email} + 启用 · 已绑定钉钉 + +
+ ))} +
+
+ +
+
+
+ 添加预警人员 +

按姓名、邮箱、部门或角色搜索;未绑定钉钉的人员不可添加。

+
+
+
+ + + + + +
+
+ {!normalizedCandidateSearch ? ( +
+ 输入姓名、邮箱、部门或角色后点击搜索 +
+ ) : alertRecipientSearchResults.length === 0 ? ( +
没有可添加的匹配人员
+ ) : alertRecipientSearchResults.slice(0, 50).map((user) => { + const unavailable = !user.dingtalkBound + return ( +
+ toggleCandidateAlertRecipient(user.id)} + /> + + {user.name} + + {user.departmentName || '未分配部门'} + {' · '} + {accountRoleLabel(user.role)} + + + {user.email} + + {unavailable ? '未绑定钉钉' : '已绑定钉钉'} + + +
+ ) + })} +
+
+ + )} + {alertRecipientError ? ( +

{alertRecipientError}

+ ) : null} +
+
+
+ {alertRecipientNotice ? ( + {alertRecipientNotice} + ) : } + + +
+
+
+ ) : null} + )}
diff --git a/furniture-3d-system/src/app/supply-chain/management/modular-product-processing-orders/modular-product-processing-orders.module.css b/furniture-3d-system/src/app/supply-chain/management/modular-product-processing-orders/modular-product-processing-orders.module.css index 5984f009..b9137283 100644 --- a/furniture-3d-system/src/app/supply-chain/management/modular-product-processing-orders/modular-product-processing-orders.module.css +++ b/furniture-3d-system/src/app/supply-chain/management/modular-product-processing-orders/modular-product-processing-orders.module.css @@ -790,14 +790,30 @@ text-align: left; cursor: pointer; } .exportMenu button:hover { background: var(--sc-hover); } -.settingsPanel { display: flex; width: min(1000px, 100%); height: min(86vh, 820px); flex-direction: column; } +.settingsMenuWrap { position: relative; display: inline-flex; } +.settingsMenu { + position: absolute; top: calc(100% + 6px); left: 0; z-index: 45; + display: flex; min-width: 190px; flex-direction: column; gap: 2px; + padding: 5px; border: 1px solid var(--sc-border); border-radius: 7px; + background: var(--sc-surface); box-shadow: 0 12px 28px rgb(24 24 27 / 14%); +} +.settingsMenu button { + display: flex; align-items: center; gap: 9px; + padding: 9px 10px; border: 0; border-radius: 4px; + background: transparent; color: var(--sc-ink); font: inherit; font-size: 13px; + text-align: left; cursor: pointer; +} +.settingsMenu button:hover { background: var(--sc-hover); } +.settingsMenu button svg { flex: 0 0 auto; color: var(--sc-muted); } +.settingsMenuDivider { height: 1px; margin: 4px 6px; background: var(--sc-border); } +.settingsPanel { display: flex; width: min(760px, 100%); height: min(82vh, 760px); flex-direction: column; } +.settingsPanelWide { width: min(1080px, 100%); height: min(90vh, 900px); } .settingsHeaderText { display: flex; flex-direction: column; gap: 4px; } .settingsHeaderText p { margin: 0; color: var(--sc-muted); font-size: 12px; } .settingsBody { display: flex; min-height: 0; flex: 1 1 auto; flex-direction: column; overflow: hidden; padding: 16px 20px 18px; } -.settingsLayout { display: grid; min-height: 0; flex: 1 1 auto; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 30px; } .settingsSection { display: flex; min-height: 0; flex-direction: column; } -.settingsSection h3 { margin: 0; color: var(--sc-ink); font-size: 15px; font-weight: 750; } .settingsSection p { margin: 6px 0 0; color: var(--sc-muted); font-size: 12px; line-height: 1.6; } +.settingsHint { color: var(--sc-muted); font-size: 12px; } .settingsList { display: flex; min-height: 0; flex: 1 1 auto; flex-direction: column; gap: 2px; margin-top: 8px; padding-right: 6px; overflow-y: auto; } .settingsColumnItem { display: flex; min-width: 0; align-items: center; gap: 6px; border-radius: 5px; padding: 7px 8px; color: var(--sc-ink); font-size: 13px; cursor: grab; } .settingsColumnItem:hover { background: var(--sc-hover); } @@ -813,6 +829,50 @@ /* 移动端全局会把 checkbox/button 撑到 44px 触摸目标,设置弹窗保持紧凑尺寸。 */ .settingsColumnItem input[type='checkbox'], .settingsCheck input[type='checkbox'] { width: 15px; height: 15px; min-width: 15px; min-height: 15px; margin: 0; flex: 0 0 auto; accent-color: #27272a; cursor: pointer; } +.alertRecipientState { margin-top: 10px; border: 1px dashed var(--sc-border); border-radius: 5px; padding: 14px; color: var(--sc-muted); font-size: 12px; text-align: center; } +.alertRecipientManager { display: grid; min-height: 0; flex: 1 1 auto; grid-template-rows: minmax(220px, .9fr) minmax(280px, 1.1fr); gap: 18px; overflow: hidden; } +.alertRecipientManagerSection { display: flex; min-height: 0; flex-direction: column; gap: 10px; padding-bottom: 16px; border-bottom: 1px solid var(--sc-border); } +.alertRecipientManagerSection:last-of-type { padding-bottom: 0; border-bottom: 0; } +.alertRecipientSectionHeader { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } +.alertRecipientSectionHeader > div { display: flex; flex-direction: column; gap: 4px; } +.alertRecipientSectionHeader strong { color: var(--sc-ink); font-size: 14px; } +.alertRecipientSectionHeader p { margin: 0; color: var(--sc-muted); font-size: 11px; line-height: 1.5; } +.alertRecipientSectionHeader > span { flex: 0 0 auto; color: var(--sc-muted); font-size: 11px; } +.alertRecipientCurrentToolbar, .alertRecipientAddToolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.alertRecipientCurrentToolbar > button, .alertRecipientAddToolbar > button { width: auto; min-height: 34px; padding: 0 12px; font-size: 11px; white-space: nowrap; } +.alertRecipientSearch { + display: flex; align-items: center; gap: 8px; + min-width: 250px; min-height: 36px; flex: 1 1 280px; padding: 0 11px; + border: 1px solid var(--sc-border); border-radius: 6px; + background: var(--sc-surface); color: var(--sc-muted); +} +.alertRecipientSearch:focus-within { border-color: #71717a; box-shadow: 0 0 0 2px rgb(39 39 42 / 8%); } +.alertRecipientSearch input { + min-width: 0; flex: 1 1 auto; border: 0 !important; padding: 0 !important; + background: transparent !important; color: var(--sc-ink); font: inherit; font-size: 13px; outline: 0; +} +.alertRecipientList { min-height: 110px; flex: 1 1 auto; overflow-y: auto; border: 1px solid var(--sc-border); border-radius: 6px; background: var(--sc-surface); } +.alertRecipientListEmpty { display: grid; min-height: 110px; place-items: center; padding: 16px; color: var(--sc-subtle); font-size: 12px; text-align: center; } +.alertRecipientManagerRow { + display: grid; min-width: 0; min-height: 48px; + grid-template-columns: 18px minmax(180px, 1fr) minmax(170px, .8fr) 130px auto; + align-items: center; gap: 10px; padding: 8px 10px; + border-bottom: 1px solid var(--sc-border); color: var(--sc-ink); font-size: 12px; +} +.alertRecipientManagerRow:last-child { border-bottom: 0; } +.alertRecipientManagerRow:hover { background: var(--sc-hover); } +.alertRecipientManagerRow input[type='checkbox'] { width: 15px; height: 15px; min-width: 15px; min-height: 15px; margin: 0; accent-color: #27272a; cursor: pointer; } +.alertRecipientManagerRow > button { width: auto; min-height: 30px; padding: 0 11px; font-size: 11px; white-space: nowrap; } +.alertRecipientStatus { color: var(--sc-muted); font-size: 10px; } +.alertRecipientRemoveButton { width: auto; min-height: 28px; padding: 3px 10px; border: 0; border-radius: 4px; background: transparent; color: #b42318; font: inherit; font-size: 11px; cursor: pointer; } +.alertRecipientRemoveButton:hover { background: #fef3f2; } +.alertRecipientIdentity { display: flex; min-width: 0; flex-direction: column; gap: 2px; } +.alertRecipientIdentity strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.alertRecipientIdentity small { overflow: hidden; color: var(--sc-subtle); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.alertRecipientEmail { overflow: hidden; color: var(--sc-muted); font-size: 10px; text-align: right; text-overflow: ellipsis; white-space: nowrap; } +.alertRecipientUnavailable { cursor: not-allowed; opacity: .55; } +.alertRecipientUnavailable:hover { background: transparent; } +.modalFooter .alertRecipientNotice { margin-right: auto; color: #15803d; font-size: 11px; } @media (max-width: 900px) { @@ -833,5 +893,8 @@ .timeline em { grid-column: 2; justify-self: start; } .columnEditorRow { grid-template-columns: 20px minmax(120px, 1fr) 82px 48px 48px 54px 30px; gap: 4px; } .ruleForm { grid-template-columns: minmax(0, 1fr); } - .settingsLayout { grid-template-columns: minmax(0, 1fr); gap: 18px; } + .alertRecipientManager { grid-template-rows: minmax(240px, 1fr) minmax(320px, 1.2fr); } + .alertRecipientManagerRow { grid-template-columns: 18px minmax(150px, 1fr) auto; } + .alertRecipientManagerRow .alertRecipientEmail, .alertRecipientManagerRow .alertRecipientStatus { display: none; } + .alertRecipientSearch { min-width: 200px; flex-basis: 100%; } } diff --git a/furniture-3d-system/src/app/supply-chain/management/modular-product-processing-orders/page.tsx b/furniture-3d-system/src/app/supply-chain/management/modular-product-processing-orders/page.tsx index a7542e49..921fc85c 100644 --- a/furniture-3d-system/src/app/supply-chain/management/modular-product-processing-orders/page.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/modular-product-processing-orders/page.tsx @@ -91,6 +91,9 @@ export default async function ModularProductProcessingOrdersPage() { ) } diff --git a/furniture-3d-system/src/lib/modular-product-processing-orders.ts b/furniture-3d-system/src/lib/modular-product-processing-orders.ts index 081d731f..b720ebf8 100644 --- a/furniture-3d-system/src/lib/modular-product-processing-orders.ts +++ b/furniture-3d-system/src/lib/modular-product-processing-orders.ts @@ -32,6 +32,21 @@ export type ModularProcessingGenerationHealth = { notificationError: string } +export type ModularProcessingAlertRecipientOption = { + id: string + name: string + email: string + role: string + departmentName: string + dingtalkBound: boolean +} + +export type ModularProcessingAlertRecipientSettings = { + configured: boolean + selectedUserIds: string[] + users: ModularProcessingAlertRecipientOption[] +} + export const MODULAR_PROCESSING_SELLABILITY = { NORMAL: '正常销售:有加工成品库存', STOCK_WARNING: '库存预警:成品库存不足安全库存天数且无部件可打包', diff --git a/furniture-3d-system/src/lib/supply-chain-page-permissions.test.ts b/furniture-3d-system/src/lib/supply-chain-page-permissions.test.ts index 8f99a931..cce01ca7 100644 --- a/furniture-3d-system/src/lib/supply-chain-page-permissions.test.ts +++ b/furniture-3d-system/src/lib/supply-chain-page-permissions.test.ts @@ -60,6 +60,10 @@ describe('independent supply-chain page permissions', () => { .toEqual({ targetKey: 'modularProcessingMainRefresh', action: 'edit' }) expect(getSupplyChainApiPermissionRequirement(`${base}/manual-values`, 'POST')) .toEqual({ targetKey: 'modularProcessingMainEdit', action: 'edit' }) + expect(getSupplyChainApiPermissionRequirement(`${base}/alert-recipients`, 'GET')) + .toEqual({ targetKey: 'modularProcessingMainSettings', action: 'edit' }) + expect(getSupplyChainApiPermissionRequirement(`${base}/alert-recipients`, 'PUT')) + .toEqual({ targetKey: 'modularProcessingMainSettings', action: 'edit' }) expect(getSupplyChainApiPermissionRequirement(`${base}/export`, 'POST')) .toEqual({ targetKey: 'modularProcessingMainExport', action: 'download' }) expect(getSupplyChainApiPermissionRequirement(`${base}/processing-orders`, 'GET')) diff --git a/furniture-3d-system/src/lib/supply-chain-permission-paths.ts b/furniture-3d-system/src/lib/supply-chain-permission-paths.ts index 9bc9555a..b0fd54dc 100644 --- a/furniture-3d-system/src/lib/supply-chain-permission-paths.ts +++ b/furniture-3d-system/src/lib/supply-chain-permission-paths.ts @@ -64,6 +64,9 @@ export function getSupplyChainApiPermissionRequirement(pathname: string, method: if (pathname === `${modularBase}/refresh`) { return { targetKey: 'modularProcessingMainRefresh' as const, action: 'edit' as const } } + if (pathname === `${modularBase}/alert-recipients`) { + return { targetKey: 'modularProcessingMainSettings' as const, action: 'edit' as const } + } if (pathname === `${modularBase}/manual-values`) { return { targetKey: 'modularProcessingMainEdit' as const, action: 'edit' as const } } diff --git a/furniture-3d-system/src/server/repositories/notification-delivery.repository.ts b/furniture-3d-system/src/server/repositories/notification-delivery.repository.ts index 6fef5ab5..4f8c0e94 100644 --- a/furniture-3d-system/src/server/repositories/notification-delivery.repository.ts +++ b/furniture-3d-system/src/server/repositories/notification-delivery.repository.ts @@ -110,6 +110,26 @@ class NotificationDeliveryRepository { return prisma.notificationDeliveryLog.findUnique({ where: { idempotencyKey } }) } + expireActiveForBusinessRecord(input: { + businessEvent: string + businessRecordId: string + now: Date + }) { + return prisma.notificationDeliveryLog.updateMany({ + where: { + businessEvent: input.businessEvent, + businessRecordId: input.businessRecordId, + status: { + in: ['pending', 'sending', 'retrying', 'sent', 'unread', 'failed'], + }, + }, + data: { + status: 'expired', + lastSyncedAt: input.now, + }, + }) + } + create(data: Prisma.NotificationDeliveryLogCreateInput) { return prisma.notificationDeliveryLog.create({ data }) } diff --git a/furniture-3d-system/src/server/services/modular-processing-alert-recipient.service.test.ts b/furniture-3d-system/src/server/services/modular-processing-alert-recipient.service.test.ts new file mode 100644 index 00000000..ff15de11 --- /dev/null +++ b/furniture-3d-system/src/server/services/modular-processing-alert-recipient.service.test.ts @@ -0,0 +1,187 @@ +/** @jest-environment node */ + +import type { AuthUser } from '@/server/core/types' +import { + ModularProcessingAlertRecipientService, +} from './modular-processing-alert-recipient.service' + +const admin = { + id: 'actor-admin', + email: 'admin@example.com', + name: '配置管理员', + role: 'ADMIN', + status: 'ACTIVE', +} as AuthUser + +const users = [ + { + id: 'admin-1', + name: '管理员甲', + email: 'admin1@example.com', + role: 'ADMIN' as const, + status: 'ACTIVE' as const, + businessRoles: [], + dingtalkUserId: 'ding-admin-1', + department: { name: '供应链中心' }, + }, + { + id: 'business-1', + name: '业务甲', + email: 'business1@example.com', + role: 'USER' as const, + status: 'ACTIVE' as const, + businessRoles: ['MANAGEMENT' as const], + dingtalkUserId: 'ding-business-1', + department: { name: '运营中心' }, + }, + { + id: 'unbound-1', + name: '未绑定甲', + email: 'unbound@example.com', + role: 'USER' as const, + status: 'ACTIVE' as const, + businessRoles: [], + dingtalkUserId: null, + department: null, + }, + { + id: 'supplier-1', + name: '供应商甲', + email: 'supplier@example.com', + role: 'USER' as const, + status: 'ACTIVE' as const, + businessRoles: ['SUPPLIER' as const], + dingtalkUserId: 'ding-supplier-1', + department: null, + }, +] + +describe('ModularProcessingAlertRecipientService', () => { + it('defaults the settings selection to DingTalk-bound administrators before custom configuration', async () => { + const service = new ModularProcessingAlertRecipientService({ + getConfig: jest.fn().mockResolvedValue(null), + setConfig: jest.fn(), + listActiveUsers: jest.fn().mockResolvedValue(users), + }) + + const result = await service.getSettings(admin) + + expect(result.configured).toBe(false) + expect(result.selectedUserIds).toEqual(['admin-1']) + expect(result.users.map((user) => user.id)).toEqual([ + 'admin-1', + 'business-1', + 'unbound-1', + ]) + expect(result.users.find((user) => user.id === 'unbound-1')?.dingtalkBound).toBe(false) + }) + + it('uses only explicitly configured recipients for warnings', async () => { + const service = new ModularProcessingAlertRecipientService({ + getConfig: jest.fn().mockResolvedValue({ userIds: ['business-1', 'unbound-1'] }), + setConfig: jest.fn(), + listActiveUsers: jest.fn().mockResolvedValue(users), + }) + + const recipients = await service.listAlertRecipients() + + expect(recipients.map((recipient) => recipient.id)).toEqual(['business-1']) + }) + + it('does not fall back to every administrator when stored configuration is malformed', async () => { + const service = new ModularProcessingAlertRecipientService({ + getConfig: jest.fn().mockResolvedValue({ unexpected: true }), + setConfig: jest.fn(), + listActiveUsers: jest.fn().mockResolvedValue(users), + }) + + const settings = await service.getSettings(admin) + + expect(settings.configured).toBe(true) + expect(settings.selectedUserIds).toEqual([]) + await expect(service.listAlertRecipients()).resolves.toEqual([]) + }) + + it('rejects unbound or external accounts when saving recipients', async () => { + const setConfig = jest.fn() + const service = new ModularProcessingAlertRecipientService({ + getConfig: jest.fn().mockResolvedValue(null), + setConfig, + listActiveUsers: jest.fn().mockResolvedValue(users), + }) + + await expect(service.saveSettings(admin, ['unbound-1'])) + .rejects.toThrow('未启用或未绑定钉钉') + await expect(service.saveSettings(admin, ['supplier-1'])) + .rejects.toThrow('未启用或未绑定钉钉') + expect(setConfig).not.toHaveBeenCalled() + }) + + it('rejects malformed recipient payloads instead of disabling alerts', async () => { + const setConfig = jest.fn() + const service = new ModularProcessingAlertRecipientService({ + getConfig: jest.fn().mockResolvedValue(null), + setConfig, + listActiveUsers: jest.fn().mockResolvedValue(users), + }) + + await expect(service.saveSettings(admin, 'business-1')) + .rejects.toThrow('预警人员名单格式不正确') + await expect(service.saveSettings(admin, ['business-1', 1])) + .rejects.toThrow('预警人员名单格式不正确') + expect(setConfig).not.toHaveBeenCalled() + }) + + it('allows only system administrators to read or update recipient settings', async () => { + const service = new ModularProcessingAlertRecipientService({ + getConfig: jest.fn().mockResolvedValue(null), + setConfig: jest.fn(), + listActiveUsers: jest.fn().mockResolvedValue(users), + }) + const manager = { ...admin, role: 'SUPERVISOR' } as AuthUser + + await expect(service.getSettings(manager)) + .rejects.toThrow('只有系统管理员可以配置预警人员') + await expect(service.saveSettings(manager, ['business-1'])) + .rejects.toThrow('只有系统管理员可以配置预警人员') + }) + + it('saves a custom recipient list for administrators', async () => { + let savedConfig: unknown = null + const setConfig = jest.fn().mockImplementation(async (value: unknown) => { + savedConfig = value + }) + const getConfig = jest.fn().mockImplementation(async () => savedConfig) + const service = new ModularProcessingAlertRecipientService({ + getConfig, + setConfig, + listActiveUsers: jest.fn().mockResolvedValue(users), + }) + + const result = await service.saveSettings(admin, ['business-1']) + + expect(setConfig).toHaveBeenCalledWith( + { userIds: ['business-1'] }, + '模块化加工单生成失败预警人员', + ) + expect(result.configured).toBe(true) + expect(result.selectedUserIds).toEqual(['business-1']) + }) + + it('allows administrators to save an empty list to disable failure alerts', async () => { + let savedConfig: unknown = null + const service = new ModularProcessingAlertRecipientService({ + getConfig: jest.fn().mockImplementation(async () => savedConfig), + setConfig: jest.fn().mockImplementation(async (value: unknown) => { + savedConfig = value + }), + listActiveUsers: jest.fn().mockResolvedValue(users), + }) + + const settings = await service.saveSettings(admin, []) + + expect(settings.configured).toBe(true) + expect(settings.selectedUserIds).toEqual([]) + await expect(service.listAlertRecipients()).resolves.toEqual([]) + }) +}) diff --git a/furniture-3d-system/src/server/services/modular-processing-alert-recipient.service.ts b/furniture-3d-system/src/server/services/modular-processing-alert-recipient.service.ts new file mode 100644 index 00000000..f17dc47b --- /dev/null +++ b/furniture-3d-system/src/server/services/modular-processing-alert-recipient.service.ts @@ -0,0 +1,176 @@ +import type { BusinessAccountType, Role } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import { AppError, ErrorCode } from '@/server/core/errors' +import type { AuthUser } from '@/server/core/types' +import type { NotificationRecipient } from '@/server/repositories/notification-delivery.repository' +import type { + ModularProcessingAlertRecipientOption, + ModularProcessingAlertRecipientSettings, +} from '@/lib/modular-product-processing-orders' +import { systemConfigService } from './system-config.service' + +const CONFIG_KEY = 'modularProcessingGenerationAlertRecipients' +const EXTERNAL_BUSINESS_ROLES = new Set([ + 'SUPPLIER', + 'EXTERNAL_INTAKE', +]) + +type AlertRecipientUser = { + id: string + name: string + email: string + role: Role + status: 'ACTIVE' | 'PENDING' | 'INACTIVE' | 'SUSPENDED' + businessRoles: BusinessAccountType[] + dingtalkUserId: string | null + department: { name: string } | null +} + +type RecipientDependencies = { + getConfig(): Promise + setConfig(value: unknown, description: string): Promise + listActiveUsers(): Promise +} + +function isAdministrator(user: Pick) { + return user.role === 'ADMIN' || user.role === 'SUPER_ADMIN' +} + +function assertAdministrator(user: Pick) { + if (!isAdministrator(user)) { + throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只有系统管理员可以配置预警人员') + } +} + +function isInternalUser(user: AlertRecipientUser) { + return !user.businessRoles.some((role) => EXTERNAL_BUSINESS_ROLES.has(role)) +} + +function normalizeUserIds(value: unknown): string[] { + return [...new Set( + (Array.isArray(value) ? value : []) + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean), + )] +} + +function parseUserIdsForSave(value: unknown): string[] { + if ( + !Array.isArray(value) || + value.some((item) => typeof item !== 'string') + ) { + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + '预警人员名单格式不正确', + ) + } + return normalizeUserIds(value) +} + +function storedUserIds(value: unknown): string[] | null { + if (value === null || value === undefined) return null + if (Array.isArray(value)) return normalizeUserIds(value) + if (typeof value !== 'object') return [] + const candidate = value as { userIds?: unknown } + return Array.isArray(candidate.userIds) ? normalizeUserIds(candidate.userIds) : [] +} + +function defaultAdministratorIds(users: AlertRecipientUser[]) { + return users + .filter((user) => ( + user.status === 'ACTIVE' && + isInternalUser(user) && + Boolean(user.dingtalkUserId?.trim()) && + (user.role === 'ADMIN' || user.role === 'SUPER_ADMIN') + )) + .map((user) => user.id) +} + +function toOption(user: AlertRecipientUser): ModularProcessingAlertRecipientOption { + return { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + departmentName: user.department?.name ?? '', + dingtalkBound: Boolean(user.dingtalkUserId?.trim()), + } +} + +export class ModularProcessingAlertRecipientService { + constructor( + private readonly deps: RecipientDependencies = { + getConfig: () => systemConfigService.get(CONFIG_KEY), + setConfig: (value, description) => + systemConfigService.set(CONFIG_KEY, value, description), + listActiveUsers: () => prisma.user.findMany({ + where: { status: 'ACTIVE' }, + orderBy: [{ role: 'asc' }, { name: 'asc' }], + select: { + id: true, + name: true, + email: true, + role: true, + status: true, + businessRoles: true, + dingtalkUserId: true, + department: { select: { name: true } }, + }, + }), + }, + ) {} + + async getSettings(user: AuthUser): Promise { + assertAdministrator(user) + const users = (await this.deps.listActiveUsers()).filter(isInternalUser) + const configuredIds = storedUserIds(await this.deps.getConfig()) + return { + configured: configuredIds !== null, + selectedUserIds: configuredIds ?? defaultAdministratorIds(users), + users: users.map(toOption), + } + } + + async saveSettings(user: AuthUser, userIds: unknown) { + assertAdministrator(user) + const selectedIds = parseUserIdsForSave(userIds) + const users = (await this.deps.listActiveUsers()).filter(isInternalUser) + const userById = new Map(users.map((item) => [item.id, item])) + const unavailableIds = selectedIds.filter((id) => { + const selected = userById.get(id) + return !selected || !selected.dingtalkUserId?.trim() + }) + if (unavailableIds.length > 0) { + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + `存在未启用或未绑定钉钉的预警人员:${unavailableIds.join('、')}`, + ) + } + await this.deps.setConfig( + { userIds: selectedIds }, + '模块化加工单生成失败预警人员', + ) + return this.getSettings(user) + } + + async listAlertRecipients(): Promise { + const users = (await this.deps.listActiveUsers()).filter(isInternalUser) + const configuredIds = storedUserIds(await this.deps.getConfig()) + const selectedIds = configuredIds ?? defaultAdministratorIds(users) + const selected = new Set(selectedIds) + return users + .filter((user) => selected.has(user.id) && Boolean(user.dingtalkUserId?.trim())) + .map((user) => ({ + id: user.id, + name: user.name, + email: user.email, + status: user.status, + businessRoles: user.businessRoles, + dingtalkUserId: user.dingtalkUserId, + })) + } +} + +export const modularProcessingAlertRecipientService = + new ModularProcessingAlertRecipientService() diff --git a/furniture-3d-system/src/server/services/modular-processing-generation-alert.service.test.ts b/furniture-3d-system/src/server/services/modular-processing-generation-alert.service.test.ts index 3b47b68a..01019412 100644 --- a/furniture-3d-system/src/server/services/modular-processing-generation-alert.service.test.ts +++ b/furniture-3d-system/src/server/services/modular-processing-generation-alert.service.test.ts @@ -5,11 +5,12 @@ import { } from './modular-processing-generation-alert.service' describe('ModularProcessingGenerationAlertService', () => { - it('sends the generation failure reason to every active administrator', async () => { + it('sends the generation failure reason to every configured recipient', async () => { const sendToEmployee = jest.fn().mockResolvedValue(undefined) const updateNotification = jest.fn().mockResolvedValue(null) + const expireFailureNotifications = jest.fn().mockResolvedValue({ count: 0 }) const service = new ModularProcessingGenerationAlertService({ - findActiveAdministrators: jest.fn().mockResolvedValue([ + listConfiguredRecipients: jest.fn().mockResolvedValue([ { id: 'admin-1', name: '管理员甲', @@ -29,6 +30,7 @@ describe('ModularProcessingGenerationAlertService', () => { ]), sendToEmployee, updateNotification, + expireFailureNotifications, notificationCenterConfigured: () => true, now: () => new Date('2026-11-07T02:00:00.000Z'), }) @@ -60,10 +62,12 @@ describe('ModularProcessingGenerationAlertService', () => { it('records that administrator alerts cannot be sent when the notification center is not configured', async () => { const sendToEmployee = jest.fn() const updateNotification = jest.fn().mockResolvedValue(null) + const expireFailureNotifications = jest.fn().mockResolvedValue({ count: 0 }) const service = new ModularProcessingGenerationAlertService({ - findActiveAdministrators: jest.fn().mockResolvedValue([]), + listConfiguredRecipients: jest.fn().mockResolvedValue([]), sendToEmployee, updateNotification, + expireFailureNotifications, notificationCenterConfigured: () => false, now: () => new Date('2026-11-07T02:00:00.000Z'), }) @@ -82,4 +86,23 @@ describe('ModularProcessingGenerationAlertService', () => { status: 'NOT_CONFIGURED', })) }) + + it('expires active failure notifications after a successful regeneration', async () => { + const expireFailureNotifications = jest.fn().mockResolvedValue({ count: 2 }) + const service = new ModularProcessingGenerationAlertService({ + listConfiguredRecipients: jest.fn().mockResolvedValue([]), + sendToEmployee: jest.fn(), + updateNotification: jest.fn(), + expireFailureNotifications, + notificationCenterConfigured: () => true, + now: () => new Date('2026-11-07T03:00:00.000Z'), + }) + + await expect(service.resolveFailure('2026-11-07')).resolves.toBe(2) + expect(expireFailureNotifications).toHaveBeenCalledWith({ + businessEvent: 'modular_processing_generation_failed', + businessRecordId: '2026-11-07', + now: new Date('2026-11-07T03:00:00.000Z'), + }) + }) }) diff --git a/furniture-3d-system/src/server/services/modular-processing-generation-alert.service.ts b/furniture-3d-system/src/server/services/modular-processing-generation-alert.service.ts index d73bd8b3..ac883d38 100644 --- a/furniture-3d-system/src/server/services/modular-processing-generation-alert.service.ts +++ b/furniture-3d-system/src/server/services/modular-processing-generation-alert.service.ts @@ -14,6 +14,7 @@ import { notificationDeliveryService, type SendEmployeeNotificationInput, } from './notification-delivery.service' +import { modularProcessingAlertRecipientService } from './modular-processing-alert-recipient.service' export const MODULAR_PROCESSING_GENERATION_FAILED_EVENT = 'modular_processing_generation_failed' @@ -36,9 +37,14 @@ export type ModularProcessingGenerationAlertResult = { } type AlertDependencies = { - findActiveAdministrators(): Promise + listConfiguredRecipients(): Promise sendToEmployee(input: SendEmployeeNotificationInput): Promise updateNotification: ModularProcessingGenerationRunRepository['updateNotification'] + expireFailureNotifications(input: { + businessEvent: string + businessRecordId: string + now: Date + }): Promise<{ count: number }> notificationCenterConfigured(): boolean now(): Date } @@ -63,10 +69,13 @@ function clampReason(value: string) { export class ModularProcessingGenerationAlertService { constructor( private readonly deps: AlertDependencies = { - findActiveAdministrators: () => notificationDeliveryRepository.findActiveAdministrators(), + listConfiguredRecipients: () => + modularProcessingAlertRecipientService.listAlertRecipients(), sendToEmployee: (input) => notificationDeliveryService.sendToEmployee(input), updateNotification: (input) => modularProcessingGenerationRunRepository.updateNotification(input), + expireFailureNotifications: (input) => + notificationDeliveryRepository.expireActiveForBusinessRecord(input), notificationCenterConfigured: () => isNotificationCenterConfigured(), now: () => new Date(), }, @@ -81,19 +90,19 @@ export class ModularProcessingGenerationAlertService { status: 'NOT_CONFIGURED', sent: 0, failed: 0, - error: '统一通知中心未配置,无法向系统管理员发送钉钉预警。', + error: '统一通知中心未配置,无法向已配置的预警人员发送钉钉预警。', } await this.safeUpdateNotification(input.snapshotDate, result, attemptedAt) return result } - const administrators = await this.deps.findActiveAdministrators() - if (administrators.length === 0) { + const recipients = await this.deps.listConfiguredRecipients() + if (recipients.length === 0) { const result: ModularProcessingGenerationAlertResult = { status: 'NO_RECIPIENT', sent: 0, failed: 0, - error: '未找到启用中的系统管理员账号。', + error: '未配置可用的预警人员;请由系统管理员在加工主表设置中选择已绑定钉钉的人员。', } await this.safeUpdateNotification(input.snapshotDate, result, attemptedAt) return result @@ -112,7 +121,7 @@ export class ModularProcessingGenerationAlertService { '处理建议:请检查数据源连接、定时任务状态和生产日志;修复后在模块化商品加工单管理页面重新生成。', ].join('\n\n') - await Promise.all(administrators.map(async (administrator) => { + await Promise.all(recipients.map(async (recipient) => { try { await this.deps.sendToEmployee({ idempotencyKey: [ @@ -120,14 +129,14 @@ export class ModularProcessingGenerationAlertService { input.snapshotDate, input.failureCode, reasonKey, - administrator.id, + recipient.id, 'v1', ].join(':'), businessEvent: MODULAR_PROCESSING_GENERATION_FAILED_EVENT, businessRecordId: input.snapshotDate, source: 'SYSTEM', initiatedByName: '模块化加工单生成监控', - recipientUserId: administrator.id, + recipientUserId: recipient.id, title: `模块化加工单生成失败:${input.snapshotDate}`, content, actionTitle: '查看加工单', @@ -137,7 +146,7 @@ export class ModularProcessingGenerationAlertService { } catch (error) { failed += 1 errors.push( - `${administrator.name || administrator.id}:${error instanceof Error ? error.message : String(error)}`, + `${recipient.name || recipient.id}:${error instanceof Error ? error.message : String(error)}`, ) } })) @@ -152,6 +161,23 @@ export class ModularProcessingGenerationAlertService { return result } + async resolveFailure(snapshotDate: string) { + try { + const result = await this.deps.expireFailureNotifications({ + businessEvent: MODULAR_PROCESSING_GENERATION_FAILED_EVENT, + businessRecordId: snapshotDate, + now: this.deps.now(), + }) + return result.count + } catch (error) { + console.error( + '[modular-processing-generation-alert] failed to expire failure alerts', + error, + ) + return 0 + } + } + private async safeUpdateNotification( snapshotDate: string, result: ModularProcessingGenerationAlertResult, diff --git a/furniture-3d-system/src/server/services/modular-processing-generation-health.service.test.ts b/furniture-3d-system/src/server/services/modular-processing-generation-health.service.test.ts index ab2a5b92..0b399661 100644 --- a/furniture-3d-system/src/server/services/modular-processing-generation-health.service.test.ts +++ b/furniture-3d-system/src/server/services/modular-processing-generation-health.service.test.ts @@ -63,7 +63,7 @@ describe('ModularProcessingGenerationHealthService', () => { snapshotRepository: { latestSnapshotForDate: jest.fn().mockResolvedValue(null), }, - alertService: { notifyFailure }, + alertService: { notifyFailure, resolveFailure: jest.fn() }, now: () => new Date('2026-11-07T02:00:00.000Z'), }) @@ -99,7 +99,7 @@ describe('ModularProcessingGenerationHealthService', () => { snapshotRepository: { latestSnapshotForDate: jest.fn().mockResolvedValue(null), }, - alertService: { notifyFailure: jest.fn() }, + alertService: { notifyFailure: jest.fn(), resolveFailure: jest.fn() }, now: () => new Date('2026-11-07T02:00:00.000Z'), }) @@ -126,7 +126,7 @@ describe('ModularProcessingGenerationHealthService', () => { snapshotRepository: { latestSnapshotForDate: jest.fn().mockResolvedValue(null), }, - alertService: { notifyFailure }, + alertService: { notifyFailure, resolveFailure: jest.fn() }, now: () => new Date('2026-11-07T01:35:00.000Z'), }) @@ -159,7 +159,7 @@ describe('ModularProcessingGenerationHealthService', () => { new Date('2026-11-07T01:31:00.000Z'), ), }, - alertService: { notifyFailure }, + alertService: { notifyFailure, resolveFailure: jest.fn() }, now: () => new Date(now), }) @@ -190,7 +190,7 @@ describe('ModularProcessingGenerationHealthService', () => { new Date('2026-11-07T01:31:00.000Z'), ), }, - alertService: { notifyFailure }, + alertService: { notifyFailure, resolveFailure: jest.fn() }, now: () => new Date('2026-11-07T02:00:00.000Z'), }) @@ -199,4 +199,43 @@ describe('ModularProcessingGenerationHealthService', () => { expect(result.status).toBe('SUCCEEDED') expect(notifyFailure).not.toHaveBeenCalled() }) + + it('expires the day failure alert after a successful regeneration', async () => { + const resolveFailure = jest.fn().mockResolvedValue(2) + const completeSuccess = jest.fn().mockResolvedValue(runRecord({ + status: 'SUCCEEDED', + finishedAt: new Date('2026-11-07T03:00:00.000Z'), + generatedAt: new Date('2026-11-07T03:00:00.000Z'), + rowCount: 10, + processingOrderCount: 3, + })) + const service = new ModularProcessingGenerationHealthService({ + runRepository: { + beginAttempt: jest.fn(), + completeSuccess, + completeFailure: jest.fn(), + findByDate: jest.fn(), + }, + snapshotRepository: { + latestSnapshotForDate: jest.fn().mockResolvedValue(null), + }, + alertService: { notifyFailure: jest.fn(), resolveFailure }, + now: () => new Date('2026-11-07T03:00:00.000Z'), + }) + + await service.markSucceeded({ + snapshotDate: '2026-11-07', + generatedAt: new Date('2026-11-07T03:00:00.000Z'), + rowCount: 10, + processingOrderCount: 3, + warnings: [], + }) + + expect(completeSuccess).toHaveBeenCalledWith(expect.objectContaining({ + snapshotDate: '2026-11-07', + rowCount: 10, + processingOrderCount: 3, + })) + expect(resolveFailure).toHaveBeenCalledWith('2026-11-07') + }) }) diff --git a/furniture-3d-system/src/server/services/modular-processing-generation-health.service.ts b/furniture-3d-system/src/server/services/modular-processing-generation-health.service.ts index 046f1a2f..b893d797 100644 --- a/furniture-3d-system/src/server/services/modular-processing-generation-health.service.ts +++ b/furniture-3d-system/src/server/services/modular-processing-generation-health.service.ts @@ -32,7 +32,10 @@ type SnapshotHealthRepository = Pick< 'latestSnapshotForDate' > -type AlertService = Pick +type AlertService = Pick< + ModularProcessingGenerationAlertService, + 'notifyFailure' | 'resolveFailure' +> type HealthDependencies = { runRepository: HealthRepository @@ -196,10 +199,12 @@ export class ModularProcessingGenerationHealthService { processingOrderCount: number warnings: string[] }) { - return this.deps.runRepository.completeSuccess({ + const run = await this.deps.runRepository.completeSuccess({ ...input, finishedAt: this.deps.now(), }) + await this.deps.alertService.resolveFailure(input.snapshotDate) + return run } async markFailedAndNotify(input: {