feat(modular-processing): 增加可配置预警人员与独立设置界面
- 支持按当前名单查询、勾选和批量删除异常预警人员\n- 支持搜索人员、勾选和批量添加预警人员并校验钉钉绑定状态\n- 将列设置、筛选设置和异常预警人员拆分为独立操作界面\n- 人工重新生成成功后自动失效当日失败预警
This commit is contained in:
parent
6898190f0b
commit
c5ed14ef53
@ -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'] },
|
||||
)
|
||||
}
|
||||
@ -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 () => ({
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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%; }
|
||||
}
|
||||
|
||||
@ -91,6 +91,9 @@ export default async function ModularProductProcessingOrdersPage() {
|
||||
<ModularProductProcessingOrdersView
|
||||
initialData={initial.data}
|
||||
permissions={permissions}
|
||||
canConfigureAlertRecipients={
|
||||
authUser.role === 'ADMIN' || authUser.role === 'SUPER_ADMIN'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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: '库存预警:成品库存不足安全库存天数且无部件可打包',
|
||||
|
||||
@ -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'))
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
@ -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'),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user