feat(modular-processing): 增加可配置预警人员与独立设置界面

- 支持按当前名单查询、勾选和批量删除异常预警人员\n- 支持搜索人员、勾选和批量添加预警人员并校验钉钉绑定状态\n- 将列设置、筛选设置和异常预警人员拆分为独立操作界面\n- 人工重新生成成功后自动失效当日失败预警
This commit is contained in:
apple 2026-11-09 14:27:21 +08:00 committed by WuYongJie
parent 6898190f0b
commit c5ed14ef53
15 changed files with 1402 additions and 148 deletions

View File

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

View File

@ -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(
<ModularProductProcessingOrdersView
initialData={{
@ -856,7 +856,7 @@ describe('ModularProductProcessingOrdersView pane resizing', () => {
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(<ModularProductProcessingOrdersView initialData={INITIAL_DATA} canEdit />)
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(<ModularProductProcessingOrdersView initialData={INITIAL_DATA} canEdit />)
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(
<ModularProductProcessingOrdersView
initialData={INITIAL_DATA}
permissions={permissions}
canConfigureAlertRecipients
/>,
)
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(
<ModularProductProcessingOrdersView
initialData={INITIAL_DATA}
permissions={permissions}
/>,
)
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 () => ({

View File

@ -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<OrderViewSettings>(defaultOrderViewSettings)
const [viewSettingsLoaded, setViewSettingsLoaded] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false)
const [settingsDialog, setSettingsDialog] = useState<SettingsDialogKey | null>(null)
const [settingsDraft, setSettingsDraft] = useState<OrderViewSettings | null>(null)
const [alertRecipientSettings, setAlertRecipientSettings] =
useState<ModularProcessingAlertRecipientSettings | null>(null)
const [alertRecipientIds, setAlertRecipientIds] = useState<string[]>([])
const [alertRecipientLoading, setAlertRecipientLoading] = useState(false)
const [alertRecipientSaving, setAlertRecipientSaving] = useState(false)
const [alertRecipientError, setAlertRecipientError] = useState<string | null>(null)
const [alertRecipientNotice, setAlertRecipientNotice] = useState<string | null>(null)
const [alertRecipientCurrentSearch, setAlertRecipientCurrentSearch] = useState('')
const [alertRecipientCandidateSearchInput, setAlertRecipientCandidateSearchInput] = useState('')
const [alertRecipientCandidateSearch, setAlertRecipientCandidateSearch] = useState('')
const [alertRecipientCurrentChecked, setAlertRecipientCurrentChecked] = useState<string[]>([])
const [alertRecipientCandidateChecked, setAlertRecipientCandidateChecked] = useState<string[]>([])
const [draggedColumnKey, setDraggedColumnKey] = useState<string | null>(null)
const [dragOverColumnKey, setDragOverColumnKey] = useState<string | null>(null)
const [quickDrafts, setQuickDrafts] = useState<Record<string, string>>({})
@ -741,6 +768,7 @@ export function ModularProductProcessingOrdersView({
const orderTableScrollRef = useRef<HTMLDivElement>(null)
const masterDetailRegionRef = useRef<HTMLDivElement>(null)
const exportMenuRef = useRef<HTMLSpanElement>(null)
const settingsMenuRef = useRef<HTMLSpanElement>(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<ModularProcessingAlertRecipientSettings>(
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<HTMLSpanElement>,
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<SettingsDialogKey, 'alerts'>) => {
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<ModularProcessingAlertRecipientSettings>(
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 (
<main className={styles.page}>
@ -2090,15 +2314,52 @@ export function ModularProductProcessingOrdersView({
)}
{refreshNotice && <span className={styles.refreshNotice} role="status">{refreshNotice}</span>}
{canOpenSettings && (
<button
type="button"
className={`${styles.iconButton} ${styles.textButton}`}
title="设置列展示、列顺序与快速查询字段"
aria-label="打开设置"
onClick={openSettings}
>
<Settings2 size={14} />设置
</button>
<span className={styles.settingsMenuWrap} ref={settingsMenuRef}>
<button
type="button"
className={`${styles.iconButton} ${styles.textButton}`}
title="分别配置列、筛选条件和异常预警人员"
aria-label="打开设置"
aria-haspopup="menu"
aria-expanded={settingsMenuOpen}
onClick={() => setSettingsMenuOpen((current) => !current)}
>
<Settings2 size={14} />设置<ChevronDown size={12} />
</button>
{settingsMenuOpen && (
<div className={styles.settingsMenu} role="menu" aria-label="设置选项">
<button
type="button"
role="menuitem"
onClick={() => openSettingsDialog('columns')}
>
<Columns3 size={15} />
<span>列设置</span>
</button>
<button
type="button"
role="menuitem"
onClick={() => openSettingsDialog('filters')}
>
<SlidersHorizontal size={15} />
<span>筛选设置</span>
</button>
{canConfigureAlertRecipients ? (
<>
<div className={styles.settingsMenuDivider} role="separator" />
<button
type="button"
role="menuitem"
onClick={() => openSettingsDialog('alerts')}
>
<Users size={15} />
<span>异常预警人员</span>
</button>
</>
) : null}
</div>
)}
</span>
)}
{canExportMain && (
<span className={styles.exportMenuWrap} ref={exportMenuRef}>
@ -2996,109 +3257,375 @@ export function ModularProductProcessingOrdersView({
document.body,
)}
{canOpenSettings && settingsOpen && settingsDraft && (
<div className={styles.modalBackdrop} role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) closeSettings() }}>
<section className={`${styles.modalPanel} ${styles.settingsPanel}`} role="dialog" aria-modal="true" aria-labelledby="modular-processing-settings-title">
<div className={styles.modalHeader}>
<div className={styles.settingsHeaderText}>
<strong id="modular-processing-settings-title">设置</strong>
<p>配置列表展示字段与快速查询栏筛选项。</p>
</div>
<button type="button" className={styles.iconButton} aria-label="关闭设置" title="关闭" onClick={closeSettings}><X size={15} /></button>
</div>
<div className={styles.settingsBody}>
<div className={styles.settingsLayout}>
<section className={styles.settingsSection}>
<h3>列设置</h3>
<p>按住任意字段拖动即可调整顺序;勾选控制是否展示(至少保留一列)。</p>
<div className={styles.settingsList}>
{settingsDraft.columns.map((column) => {
const field = ORDER_FIELD_BY_KEY.get(column.key)
if (!field) return null
return (
<div
key={column.key}
data-column-key={column.key}
className={`${styles.settingsColumnItem} ${draggedColumnKey === column.key ? styles.settingsColumnDragging : ''} ${dragOverColumnKey === column.key && draggedColumnKey !== column.key ? styles.settingsColumnDragOver : ''}`.trim()}
draggable
onDragStart={(event) => {
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)
}}
>
<button
type="button"
className={styles.settingsDragHandle}
aria-label={`拖动调整${field.label}顺序`}
title="按住拖动调整顺序(也可用方向键)"
onKeyDown={(event) => {
if (event.key === 'ArrowUp') {
event.preventDefault()
moveDraftColumn(column.key, -1)
}
if (event.key === 'ArrowDown') {
event.preventDefault()
moveDraftColumn(column.key, 1)
}
}}
><GripVertical size={16} /></button>
<label>
{canOpenSettings && settingsDialog && (
<>
{settingsDialog !== 'alerts' && settingsDraft ? (
<div className={styles.modalBackdrop} role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) closeSettings() }}>
<section
className={`${styles.modalPanel} ${styles.settingsPanel}`}
role="dialog"
aria-modal="true"
aria-labelledby={`modular-processing-${settingsDialog}-settings-title`}
>
<div className={styles.modalHeader}>
<div className={styles.settingsHeaderText}>
<strong id={`modular-processing-${settingsDialog}-settings-title`}>
{settingsDialog === 'columns' ? '列设置' : '筛选设置'}
</strong>
<p>
{settingsDialog === 'columns'
? '拖动调整列顺序,勾选控制列展示。修改仅作用于加工主表。'
: '勾选需要出现在快速查询栏的筛选项,隐藏时会同步清空对应筛选条件。'}
</p>
</div>
<button type="button" className={styles.iconButton} aria-label="关闭设置" title="关闭" onClick={closeSettings}><X size={15} /></button>
</div>
<div className={styles.settingsBody}>
<section className={styles.settingsSection}>
{settingsDialog === 'columns' ? (
<>
<div className={styles.settingsHint}>至少保留一列展示</div>
<div className={styles.settingsList}>
{settingsDraft.columns.map((column) => {
const field = ORDER_FIELD_BY_KEY.get(column.key)
if (!field) return null
return (
<div
key={column.key}
data-column-key={column.key}
className={`${styles.settingsColumnItem} ${draggedColumnKey === column.key ? styles.settingsColumnDragging : ''} ${dragOverColumnKey === column.key && draggedColumnKey !== column.key ? styles.settingsColumnDragOver : ''}`.trim()}
draggable
onDragStart={(event) => {
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)
}}
>
<button
type="button"
className={styles.settingsDragHandle}
aria-label={`拖动调整${field.label}顺序`}
title="按住拖动调整顺序(也可用方向键)"
onKeyDown={(event) => {
if (event.key === 'ArrowUp') {
event.preventDefault()
moveDraftColumn(column.key, -1)
}
if (event.key === 'ArrowDown') {
event.preventDefault()
moveDraftColumn(column.key, 1)
}
}}
><GripVertical size={16} /></button>
<label>
<input
type="checkbox"
checked={column.visible}
aria-label={`展示${field.label}列`}
onChange={() => toggleDraftColumn(column.key)}
/>
<span>{field.label}</span>
</label>
</div>
)
})}
</div>
</>
) : (
<div className={styles.settingsList}>
{QUICK_FILTER_FIELD_OPTIONS.map((option) => (
<label key={option.key} className={styles.settingsCheck}>
<input
type="checkbox"
checked={column.visible}
aria-label={`展示${field.label}列`}
onChange={() => toggleDraftColumn(column.key)}
checked={settingsDraft.quickFields.includes(option.key)}
aria-label={`快速筛选${option.label}`}
onChange={() => toggleDraftQuickField(option.key)}
/>
<span>{field.label}</span>
<span>{option.label}</span>
</label>
</div>
)
})}
</div>
</section>
<section className={styles.settingsSection}>
<h3>筛选设置</h3>
<p>勾选需要出现在快速查询栏的筛选项;隐藏时会同步清空该筛选条件。</p>
<div className={styles.settingsList}>
{QUICK_FILTER_FIELD_OPTIONS.map((option) => (
<label key={option.key} className={styles.settingsCheck}>
<input
type="checkbox"
checked={settingsDraft.quickFields.includes(option.key)}
aria-label={`快速筛选${option.label}`}
onChange={() => toggleDraftQuickField(option.key)}
/>
<span>{option.label}</span>
</label>
))}
</div>
</section>
</div>
))}
</div>
)}
</section>
</div>
<footer className={styles.modalFooter}>
<button
type="button"
className={styles.secondaryButton}
onClick={() => resetSettingsDraft(settingsDialog)}
>
恢复默认
</button>
<button type="button" className={styles.secondaryButton} onClick={closeSettings}>取消</button>
<button type="button" className={styles.primaryButton} onClick={applySettings}>应用</button>
</footer>
</section>
</div>
<footer className={styles.modalFooter}>
<button type="button" className={styles.secondaryButton} onClick={() => setSettingsDraft(defaultOrderViewSettings())}>恢复默认</button>
<button type="button" className={styles.primaryButton} onClick={applySettings}>完成</button>
</footer>
</section>
</div>
) : null}
{settingsDialog === 'alerts' && canConfigureAlertRecipients ? (
<div className={styles.modalBackdrop} role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) closeSettings() }}>
<section
className={`${styles.modalPanel} ${styles.settingsPanel} ${styles.settingsPanelWide}`}
role="dialog"
aria-modal="true"
aria-labelledby="modular-processing-alerts-settings-title"
>
<div className={styles.modalHeader}>
<div className={styles.settingsHeaderText}>
<strong id="modular-processing-alerts-settings-title">异常预警人员</strong>
<p>
系统生成失败时只提醒勾选人员。未绑定钉钉的人员不可选;
{alertRecipientSettings?.configured
? '当前已启用自定义名单。'
: '首次保存前暂时沿用有钉钉的系统管理员作为默认名单。'}
</p>
</div>
<button type="button" className={styles.iconButton} aria-label="关闭设置" title="关闭" onClick={closeSettings}><X size={15} /></button>
</div>
<div className={styles.settingsBody}>
<div className={styles.alertRecipientManager}>
{alertRecipientLoading ? (
<div className={styles.alertRecipientState}>正在加载可配置人员...</div>
) : alertRecipientUsers.length === 0 ? (
<div className={styles.alertRecipientState}>暂无可配置的内部在职人员</div>
) : (
<>
<section className={styles.alertRecipientManagerSection}>
<div className={styles.alertRecipientSectionHeader}>
<div>
<strong>当前预警人员</strong>
<p>只有名单内人员会收到生成失败预警。</p>
</div>
<span>{selectedAlertRecipientUsers.length} 人</span>
</div>
<div className={styles.alertRecipientCurrentToolbar}>
<label className={styles.alertRecipientSearch}>
<Search size={15} />
<input
value={alertRecipientCurrentSearch}
aria-label="查询当前预警人员"
placeholder="查询当前预警人员姓名"
onChange={(event) => setAlertRecipientCurrentSearch(event.target.value)}
/>
</label>
<button
type="button"
className={styles.secondaryButton}
onClick={() => setAlertRecipientCurrentChecked(
visibleSelectedAlertRecipientUsers.map((user) => user.id),
)}
>
全选查询结果
</button>
<button
type="button"
className={styles.secondaryButton}
onClick={() => setAlertRecipientCurrentChecked([])}
>
取消选择
</button>
<button
type="button"
className={styles.dangerButton}
disabled={alertRecipientCurrentChecked.length === 0}
onClick={removeCheckedAlertRecipients}
>
批量删除
</button>
</div>
<div className={styles.alertRecipientList} role="list">
{selectedAlertRecipientUsers.length === 0 ? (
<div className={styles.alertRecipientListEmpty}>
当前未配置预警人员,生成失败时不会发送预警
</div>
) : visibleSelectedAlertRecipientUsers.length === 0 ? (
<div className={styles.alertRecipientListEmpty}>没有匹配的当前预警人员</div>
) : visibleSelectedAlertRecipientUsers.map((user) => (
<div key={user.id} role="listitem" className={styles.alertRecipientManagerRow}>
<input
type="checkbox"
checked={alertRecipientCurrentChecked.includes(user.id)}
aria-label={`选择当前预警人员 ${user.name}`}
onChange={() => toggleCurrentAlertRecipient(user.id)}
/>
<span className={styles.alertRecipientIdentity}>
<strong>{user.name}</strong>
<small>
{user.departmentName || '未分配部门'}
{' · '}
{accountRoleLabel(user.role)}
</small>
</span>
<span className={styles.alertRecipientEmail}>{user.email}</span>
<span className={styles.alertRecipientStatus}>启用 · 已绑定钉钉</span>
<button
type="button"
className={styles.alertRecipientRemoveButton}
aria-label={`移除预警人员 ${user.name}`}
onClick={() => toggleAlertRecipient(user.id)}
>
删除
</button>
</div>
))}
</div>
</section>
<section className={styles.alertRecipientManagerSection}>
<div className={styles.alertRecipientSectionHeader}>
<div>
<strong>添加预警人员</strong>
<p>按姓名、邮箱、部门或角色搜索;未绑定钉钉的人员不可添加。</p>
</div>
</div>
<div className={styles.alertRecipientAddToolbar}>
<label className={styles.alertRecipientSearch}>
<Search size={15} />
<input
value={alertRecipientCandidateSearchInput}
aria-label="搜索可添加人员"
placeholder="搜索姓名、邮箱、部门或角色"
onChange={(event) => setAlertRecipientCandidateSearchInput(event.target.value)}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
setAlertRecipientCandidateSearch(
alertRecipientCandidateSearchInput.trim(),
)
setAlertRecipientCandidateChecked([])
}}
/>
</label>
<button
type="button"
className={styles.secondaryButton}
onClick={() => {
setAlertRecipientCandidateSearch(
alertRecipientCandidateSearchInput.trim(),
)
setAlertRecipientCandidateChecked([])
}}
>
搜索
</button>
<button
type="button"
className={styles.secondaryButton}
disabled={selectableAlertRecipientSearchIds.length === 0}
onClick={() => setAlertRecipientCandidateChecked(
selectableAlertRecipientSearchIds,
)}
>
全选查询结果
</button>
<button
type="button"
className={styles.secondaryButton}
disabled={alertRecipientCandidateChecked.length === 0}
onClick={() => setAlertRecipientCandidateChecked([])}
>
取消选择
</button>
<button
type="button"
className={styles.primaryButton}
disabled={alertRecipientCandidateChecked.length === 0}
onClick={() => addCheckedAlertRecipients(alertRecipientCandidateChecked)}
>
批量添加选中
</button>
</div>
<div className={styles.alertRecipientList} role="list">
{!normalizedCandidateSearch ? (
<div className={styles.alertRecipientListEmpty}>
输入姓名、邮箱、部门或角色后点击搜索
</div>
) : alertRecipientSearchResults.length === 0 ? (
<div className={styles.alertRecipientListEmpty}>没有可添加的匹配人员</div>
) : alertRecipientSearchResults.slice(0, 50).map((user) => {
const unavailable = !user.dingtalkBound
return (
<div
key={user.id}
role="listitem"
className={`${styles.alertRecipientManagerRow} ${unavailable ? styles.alertRecipientUnavailable : ''}`}
>
<input
type="checkbox"
checked={alertRecipientCandidateChecked.includes(user.id)}
disabled={unavailable}
aria-label={`选择添加人员 ${user.name}`}
onChange={() => toggleCandidateAlertRecipient(user.id)}
/>
<span className={styles.alertRecipientIdentity}>
<strong>{user.name}</strong>
<small>
{user.departmentName || '未分配部门'}
{' · '}
{accountRoleLabel(user.role)}
</small>
</span>
<span className={styles.alertRecipientEmail}>{user.email}</span>
<span className={styles.alertRecipientStatus}>
{unavailable ? '未绑定钉钉' : '已绑定钉钉'}
</span>
<button
type="button"
className={styles.secondaryButton}
disabled={unavailable}
aria-label={`添加预警人员 ${user.name}`}
onClick={() => addAlertRecipient(user.id)}
>
+ 添加
</button>
</div>
)
})}
</div>
</section>
</>
)}
{alertRecipientError ? (
<p className={styles.modalError} role="alert">{alertRecipientError}</p>
) : null}
</div>
</div>
<footer className={styles.modalFooter}>
{alertRecipientNotice ? (
<span className={styles.alertRecipientNotice}>{alertRecipientNotice}</span>
) : <span />}
<button
type="button"
className={styles.secondaryButton}
disabled={alertRecipientLoading || alertRecipientSaving}
onClick={() => void saveAlertRecipients()}
>
{alertRecipientSaving ? '保存中...' : '保存预警人员'}
</button>
<button type="button" className={styles.primaryButton} onClick={closeSettings}>关闭</button>
</footer>
</section>
</div>
) : null}
</>
)}
</main>

View File

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

View File

@ -91,6 +91,9 @@ export default async function ModularProductProcessingOrdersPage() {
<ModularProductProcessingOrdersView
initialData={initial.data}
permissions={permissions}
canConfigureAlertRecipients={
authUser.role === 'ADMIN' || authUser.role === 'SUPER_ADMIN'
}
/>
)
}

View File

@ -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: '库存预警:成品库存不足安全库存天数且无部件可打包',

View File

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

View File

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

View File

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

View File

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

View File

@ -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<BusinessAccountType>([
'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<unknown | null>
setConfig(value: unknown, description: string): Promise<void>
listActiveUsers(): Promise<AlertRecipientUser[]>
}
function isAdministrator(user: Pick<AuthUser, 'role'>) {
return user.role === 'ADMIN' || user.role === 'SUPER_ADMIN'
}
function assertAdministrator(user: Pick<AuthUser, 'role'>) {
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<unknown>(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<ModularProcessingAlertRecipientSettings> {
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<NotificationRecipient[]> {
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()

View File

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

View File

@ -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<NotificationRecipient[]>
listConfiguredRecipients(): Promise<NotificationRecipient[]>
sendToEmployee(input: SendEmployeeNotificationInput): Promise<unknown>
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,

View File

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

View File

@ -32,7 +32,10 @@ type SnapshotHealthRepository = Pick<
'latestSnapshotForDate'
>
type AlertService = Pick<ModularProcessingGenerationAlertService, 'notifyFailure'>
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: {