Commit fbbe3f5a authored by drigle's avatar drigle

feat: support department item editing and risk workflow

parent 6f750b02
......@@ -21,12 +21,13 @@
- **常量**:使用全大写下划线 (SNAKE_CASE)。
- **技术栈偏好**:
- 优先使用 React Hooks 和函数组件。
- 样式处理:**优先使用 Tailwind Plus / Catalyst UI Kit(见 `src/catalyst-ui-kit`)+ Tailwind CSS**。
- 交互组件:**优先使用 Headless UI**(已在 Catalyst 组件内封装,除非缺组件才直接用 `@headlessui/react`)。
- 样式处理:**统一使用 HeroUI 与 HeroUI Pro 的主题、Token 和组件样式,配合 Tailwind CSS 做布局**。
- 交互组件:**优先使用 HeroUI / HeroUI Pro 的 compound API**,由组件自身提供焦点管理、键盘导航、Portal 和可访问性行为。
- 组件选择优先级(从高到低):
1. `src/catalyst-ui-kit/javascript/*`(Button/Input/Dialog/Table/Combobox/SidebarLayout/...)
2. 基于 Catalyst 样式规范的自定义组件(仅在 Kit 无现成组件时)
3. 原生 HTML + 自写样式(尽量避免)
1. `@heroui/react`(Button/Input/Modal/Autocomplete/Table 等 OSS 组件)
2. `@heroui-pro/react/*`(DataGrid/Sidebar/Navbar/DropZone/KPI/EmptyState 等 Pro 组件)
3. 基于 HeroUI 语义 Token 的自定义组合组件(仅在 HeroUI/HeroUI Pro 无现成组件时)
4. 原生 HTML + 自写样式(尽量避免)
## 4. 接口请求规范
......@@ -39,12 +40,12 @@
- 如果 `record-api.md` 或 `subjectsDefinition.md` 中的定义不清晰,请务必询问我,不要随意猜测字段名。
- 每次生成代码后,简要说明你引用了哪个 API 和哪些数据对象。
## 6. UI 规范(Tailwind Plus / Headless UI 强制约束)
## 6. UI 规范(HeroUI / HeroUI Pro 强制约束)
- **全项目 UI 统一**:默认使用 `src/catalyst-ui-kit` 中的 Catalyst 组件与样式体系,避免引入其它 UI 框架。
- **Headless UI 优先**:弹窗/下拉/选择器等交互优先用 Headless UI(直接或通过 Catalyst 组件)。
- **一致性**:表单控件优先用 Catalyst 的 `Input/Select/Checkbox/Textarea/Fieldset`,表格用 `Table`,对话框用 `Dialog`。
- **可访问性**:移动端侧栏/弹层必须使用 Headless UI 的 Dialog 等可访问性组件(focus trap / esc 关闭等)。
- **全项目 UI 统一**:默认使用 `@heroui/react` 与 `@heroui-pro/react` 的组件和样式体系,禁止新增旧版 UI 框架或其它平行 UI 框架依赖。
- **HeroUI compound API 优先**:弹窗、下拉、选择器、标签组等交互直接使用 HeroUI/HeroUI Pro 官方 compound API,不访问内部 slot/BEM 实现页面私有外观。
- **一致性**:表单控件优先使用 HeroUI `Input/TextArea/Checkbox/DatePicker/Autocomplete`,表格优先使用 HeroUI Pro `DataGrid`,对话框使用 HeroUI `Modal/AlertDialog`。
- **可访问性**:移动端侧栏/弹层必须使用 HeroUI Pro `Sheet/Sidebar`、HeroUI `Modal` 等组件提供的 focus trap、Esc 关闭、Portal 和键盘导航能力。
# AI 开发执行规范
......@@ -54,12 +55,12 @@
| 字段类型 (Type) | UI 组件 (Component) | 交互逻辑 / 备注 |
| :-- | :-- | :-- |
| **文本 (String)** | Catalyst `Input` | 标准文本输入 |
| **引用 (Reference)** | `ReferenceSearchSelect`(Headless UI Combobox) | 必须通过 API 获取列表,选中后仅存储 `_id` |
| **数字 (Number)** | Catalyst `Input`(`type="number"`) | 仅允许输入数字 |
| **日期 (Date)** | Catalyst `Input`(`type="date"` / `datetime-local`) | 提交时格式化为 `ISO 8601` 字符串 |
| **布尔 (Boolean)** | Catalyst `Checkbox` | 映射为 true/false |
| **JSON** | Catalyst `Textarea` | 需要包含 JSON 校验逻辑 |
| **文本 (String)** | HeroUI `Input` | 标准文本输入 |
| **引用 (Reference)** | `ReferenceSearchSelect`(HeroUI `Autocomplete` compound API) | 必须通过 API 获取列表,选中后仅存储 `_id` |
| **数字 (Number)** | HeroUI `Input`(`type="number"`) | 仅允许输入数字 |
| **日期 (Date)** | HeroUI `DatePicker` / `DateField` | 提交时格式化为 `ISO 8601` 字符串 |
| **布尔 (Boolean)** | HeroUI `Checkbox` | 映射为 true/false |
| **JSON** | HeroUI `TextArea` | 需要包含 JSON 校验逻辑 |
| **子对象 (Subject)** | **动态表单递归** | **核心逻辑:** 必须先调用 `loadSubject(subjectName)` 接口读取该子对象的字段定义,然后递归应用本映射表生成子表单。 |
---
......
......@@ -4,7 +4,7 @@
1. 业务控制层:现有 services、hooks、路由、权限、查询参数、分页口径和保存回调保持不变。
2. 页面编排层:页面只组合业务数据与 HeroUI 原生组件,`className` 仅用于布局。
3. 共享组合层:`AppShell`、`DataTable`、`XinyuanAutocomplete`、`XinyuanModal` 等均是 HeroUI-native 的业务无关组合,不兼容旧 Catalyst props。
3. 共享组合层:`AppShell`、`DataTable`、`XinyuanAutocomplete`、`XinyuanModal` 等均是 HeroUI-native 的业务无关组合,不兼容已废弃的旧 UI 适配层 props。
4. 主题层:HeroUI styles -> HeroUI Pro CSS -> 权威 `theme.css` -> 批准的 `tokens.css` -> Shell 布局样式。
## 依赖边界
......
......@@ -20,7 +20,7 @@
- [x] 弹窗查看态为单一 L2 字段组且字段无框,编辑态移除字段组 Surface;只读字段不使用禁用输入框模拟。
- [x] 所有动态表单使用 HeroUI compound 控件。
- [x] 所有上传使用 HeroUI Pro DropZone。
- [x] 活动 Catalyst/Headless UI 引用为零。
- [x] 活动业务源码统一使用 HeroUI/HeroUI Pro,未引入旧版 UI 框架或其它平行 UI 框架。
- [x] 活动原生 select/date/file 控件为零(DropZone 内部 input 为不可见技术输入)。
- [x] 页面私有视觉 CSS 扫描为零;页面 className 仅承担布局、间距和响应式。
- [x] 全局复扫移除业务 JSX 的 Surface/KPI/DataGrid 圆角覆盖与旧 Disclosure BEM class。
......
......@@ -13,7 +13,7 @@
- 页面 `className` 只处理布局、宽度、间距、对齐和响应式。
- 组件外观由 HeroUI variant/size/compound API 与权威主题负责。
- 不引入 Catalyst、Headless UI、兼容旧 props 的 HeroUI 包装层或可见原生选择/日期/文件控件。
- 不引入旧版 UI 框架、兼容旧 props 的 HeroUI 包装层或可见原生选择/日期/文件控件。
- 列表使用 HeroUI Pro DataGrid、ActionBar、EmptyState 和 HeroUI Pagination。
- 筛选使用 HeroUI Surface、SearchField、Autocomplete、DatePicker 和 Disclosure。
- 受控 Autocomplete 的已选值必须可逆操作:单选使用官方 `Autocomplete.ClearButton` 清除当前值;多选在 Trigger 内使用官方 `TagGroup + Tag + Tag.RemoveButton` 将每个值显示为独立 Tag 并支持单项删除,同时保留官方清除入口清空集合;下拉中的官方 `ListBox.Item` 再次点选也可移除单项。Tag、清除按钮和选中态都从同一受控 `selectedKeys` 派生,不得合并为逗号文本、使用页面自制标签或维护第二套选择状态。已选 TagGroup 使用 HeroUI `default` variant,Tag 使用官方 `accent-soft-hover` / `accent-soft-foreground` 语义 Token,继承鑫元品牌辅助色并与字段背景拉开层级,避免纯白 `surface`。
......
......@@ -30,11 +30,13 @@
| `/integrity_risk_control_archive` | 历史归档 | 只读行为不变 | 已验收 |
| `/control_measure_form` | 部门填报与审核 | 保留流程状态、驳回与审核回调 | 已验收 |
## 旧 UI 清单
## 迁移前旧 UI 清单(历史基线)
- Shell:Catalyst Sidebar/Navbar/Avatar/Button。
- 页面:Catalyst Button、Dialog、Table、Checkbox、Input、Select、Textarea。
- Portal:Headless UI Dialog、Combobox、Listbox。
以下内容仅记录 HeroUI/HeroUI Pro 迁移前的实现状态,用于审计对照,不代表当前技术栈或新增代码规范。
- Shell:旧版 Sidebar/Navbar/Avatar/Button 组件。
- 页面:旧版 Button、Dialog、Table、Checkbox、Input、Select、Textarea 组件。
- Portal:旧版 Dialog、Combobox、Listbox 组件。
- 列表:自制 Table、选择列、分页、空状态和工具栏。
- 图表:未发现活动业务图表、ECharts 私有主题或自制 Tooltip;源码没有 `PieChart`/`BarChart`/`ComposedChart`/`ChartTooltip` 活动页面。`recharts` 仍是原始依赖并保留在锁文件中,但没有活动 import,不制造测试数据。
- 饼图 / 环形图专项审计:当前仍无真实 Pie/Donut 调用点,未新增伪造图表数据或页面。已新增 `--xinyuan-pie-chart-*` / `--rc-pie-chart-*` 专用 Token、稳定分类 key 冲突消解映射和 `applyXinyuanPieChartPalette` 共享适配器;未来接入时不得由页面直接传色或改动普通图表色板。
......@@ -180,4 +182,4 @@
- `shortTitle`:现有 `buildViewColumns` 未按严格布尔值隐藏 `title`,详情 Header 和字段区也未跟随当前激活 Detail View 隐藏记录标题;关键词搜索目前仅使用可见列,隐藏标题后会丢失标题检索语义。
- 通知:目标 DataService 已保留平台 `queryUserNotice`、`readUserNotice`、`unReadNoticeCount` 和动态 action 接口,但目标应用尚无 `/notifications` 路由、通知页面/service 或 Navbar Badge;Sidebar 当前没有通知入口,符合要求。
- 文件字段:运行时 Subject 协议支持 `file` 单值对象和 `multiple=true` 数组;现有详情仅直接上传业务附件。目标详情曾残留问题整改材料服务引用,必须改为廉洁风险防控自己的 `source_app`,并保持 `{name,type,size,url}` 快照协议、延迟建档和重试去重语义。
- UI 边界:活动业务源码使用直接 HeroUI/HeroUI Pro 组合;DataGrid、Modal、DropZone、Autocomplete、Badge/Tooltip 的新增实现继续保持原生 compound API,不引入 Catalyst、Headless UI、旧适配器或页面私有皮肤。
- UI 边界:活动业务源码使用直接 HeroUI/HeroUI Pro 组合;DataGrid、Modal、DropZone、Autocomplete、Badge/Tooltip 的新增实现继续保持原生 compound API,不引入旧版 UI 框架、旧适配器或页面私有皮肤。
......@@ -22,6 +22,8 @@ import {
isReferenceFieldType,
getViewRestrictionDefaults,
normalizeValueForSubmit,
isFormulaFieldType,
resolveFieldValue,
shouldHideViewTitleColumn,
toArray,
} from '@/utils/subjectView';
......@@ -223,7 +225,9 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
<DynamicField
key={nestedField.name}
fieldDef={nestedField}
value={item?.[nestedField.name]}
value={isFormulaFieldType(nestedField.type)
? resolveFieldValue(item, nestedField.name, nestedField)
: item?.[nestedField.name]}
disabled={disabled}
onChange={(nextValue) => handleFieldChange(nestedField.name, nextValue)}
depth={depth + 1}
......@@ -294,7 +298,16 @@ export function DynamicField({ fieldDef, value, onChange, disabled = false, dept
const fieldPlaceholder = getFieldPlaceholder(fieldDef);
let control = null;
if (type === 'boolean') {
if (isFormulaFieldType(type)) {
control = (
<Input
isReadOnly
value={value ?? ''}
placeholder="保存后由公式自动计算"
variant="secondary"
/>
);
} else if (type === 'boolean') {
control = (
<Checkbox
isSelected={Boolean(value)}
......@@ -422,6 +435,10 @@ export async function prepareValuesForSubmit(subject, values, path = '') {
const fieldLabel = `${path}${getFieldDisplayLabel(field)}`;
const fieldType = String(field.type || 'text').toLowerCase();
if (isFormulaFieldType(fieldType)) {
continue;
}
if (isRequiredField(field) && isEmptyValue(value)) {
errors.push(`${fieldLabel}为必填项`);
}
......@@ -598,15 +615,19 @@ export default function DynamicRecordDialog({
/>
</TextField> : null}
{dialogFields.map((field) => {
const fieldType = String(field.type || '').toLowerCase();
const fieldValue = isFormulaFieldType(fieldType)
? resolveFieldValue(values, field.name, field)
: values[field.name];
const isFullWidth = ['long_text', 'rich_text', 'object', 'form', 'subject', 'file'].includes(
String(field.type || '').toLowerCase(),
fieldType,
);
return (
<div className={isFullWidth ? 'md:col-span-2' : undefined} key={field.name}>
<DynamicField
fieldDef={field}
value={values[field.name]}
disabled={saving || (editing && isPrimaryField(field)) || (
value={fieldValue}
disabled={saving || isFormulaFieldType(fieldType) || (editing && isPrimaryField(field)) || (
!editing && Object.prototype.hasOwnProperty.call(restrictionDefaults.metadata, field.name)
)}
onChange={(nextValue) => setValues((current) => ({
......
......@@ -161,6 +161,11 @@ function normalizeActionContext(context, helpers) {
...context,
selectedRecords: (context.selectedRecords || []).map((record) => record.raw || record),
record: context.record ? context.record.raw || context.record : undefined,
parentRecord: context.parentRecord
? context.parentRecord.raw || context.parentRecord
: undefined,
isExpandedRecord: Boolean(context.isExpandedRecord),
isExpandedPanel: Boolean(context.isExpandedPanel),
...helpers,
};
}
......@@ -179,6 +184,7 @@ export default function SubjectRecordWorkbench({
toolbarActions = [],
bulkActions = [],
rowActions = [],
expandedRowActions = [],
compactBulkActions = false,
hiddenColumnFields = [],
detailDrawerProps = {},
......@@ -474,6 +480,9 @@ export default function SubjectRecordWorkbench({
}]
: []),
] : [];
const builtExpandedRowActions = hasAccessibleGridView
? expandedRowActions.map(wrapAction)
: [];
if (loading && !hasAccessibleGridView) {
return (
......@@ -534,6 +543,7 @@ export default function SubjectRecordWorkbench({
toolbarActions={builtToolbarActions}
bulkActions={builtBulkActions}
rowActions={builtRowActions}
expandedRowActions={builtExpandedRowActions}
isRowExpandable={isRowExpandable}
getExpandedRecordRows={getExpandedRecordRows ? resolvedExpandedRecordRows : undefined}
detailPanel={{
......
......@@ -27,7 +27,12 @@ import FilterSelect from '@/components/FilterSelect';
import FormDatePicker from '@/components/FormDatePicker';
import ReferenceSearchSelect from '@/pages/RecordDetailDrawer/components/ReferenceSearchSelect';
import UserSearchSelect from '@/components/UserSearchSelect';
import { formatFieldValue, getFieldValue, normalizeText } from '@/utils/subjectView';
import {
formatFieldValue,
getFieldValue,
normalizeText,
resolveFieldValue,
} from '@/utils/subjectView';
import {
Alert,
Avatar,
......@@ -323,7 +328,7 @@ function getFilterPlaceholder(field) {
function defaultRenderCell(record, column) {
const sourceRecord = record?.raw || record;
const value = getFieldValue(sourceRecord, column.field);
const value = resolveFieldValue(sourceRecord, column.field, column.fieldDef);
const text = formatFieldValue(value, column.fieldDef) || '-';
if (column.fieldDef?.type === 'user') {
......@@ -542,6 +547,7 @@ export default function RecordWorkbench({
toolbarActions = [],
bulkActions = [],
rowActions = [],
expandedRowActions = [],
detailPanel = null,
states = {},
renderCell,
......@@ -605,21 +611,25 @@ export default function RecordWorkbench({
[records, getRecordId],
);
const resolvedActionColumnWidth = useMemo(
() => resolveActionColumnWidth(rowActions, actionColumnWidth),
[rowActions, actionColumnWidth],
() => resolveActionColumnWidth(
rowActions.length >= expandedRowActions.length ? rowActions : expandedRowActions,
actionColumnWidth,
),
[rowActions, expandedRowActions, actionColumnWidth],
);
const hasRowActions = rowActions.length > 0 || expandedRowActions.length > 0;
const stickyColumns = useMemo(
() =>
buildStickyColumnMeta(
orderedVisibleColumns,
selectable,
rowActions.length > 0,
hasRowActions,
resolvedActionColumnWidth,
),
[
orderedVisibleColumns,
selectable,
rowActions.length,
hasRowActions,
resolvedActionColumnWidth,
],
);
......@@ -801,6 +811,12 @@ export default function RecordWorkbench({
});
if (Array.isArray(expandedRecords)) {
expandedRecords.forEach((expandedRecord, index) => {
const expandedRowContext = {
...rowContext,
record: expandedRecord,
parentRecord: record,
isExpandedRecord: true,
};
dataGridRows.push({
key:
expandedRecord?.key ||
......@@ -811,7 +827,7 @@ export default function RecordWorkbench({
parentRecord: record,
expandable: false,
expanded: false,
rowContext,
rowContext: expandedRowContext,
isExpandedRecord: true,
});
});
......@@ -821,7 +837,11 @@ export default function RecordWorkbench({
key: `${recordId}-expanded`,
record,
parentRecord: record,
rowContext,
rowContext: {
...rowContext,
parentRecord: record,
isExpandedPanel: true,
},
isExpandedPanel: true,
expandedContent: renderExpandedRow({ record, rowContext }),
});
......@@ -914,13 +934,21 @@ export default function RecordWorkbench({
}
if (columnIndex === 0 && detailPanel) {
const value = getFieldValue(row.record?.raw || row.record, column.field);
const value = resolveFieldValue(
row.record?.raw || row.record,
column.field,
column.fieldDef,
);
const text = formatFieldValue(value, column.fieldDef) || '-';
return (
<DataTablePrimaryAction
className="h-auto w-full min-w-0 justify-start px-0 text-left"
text={text}
onPress={() => row.rowContext.openDetail(row.record)}
onPress={() => row.rowContext.openDetail(
row.isExpandedRecord || row.isExpandedPanel
? row.parentRecord
: row.record,
)}
/>
);
}
......@@ -929,7 +957,7 @@ export default function RecordWorkbench({
},
};
}),
...(rowActions.length > 0
...(hasRowActions
? [
{
key: 'actions',
......@@ -939,10 +967,10 @@ export default function RecordWorkbench({
pin: 'end',
isActions: true,
render: (row) => {
if (row.isExpandedRecord || row.isExpandedPanel) {
return null;
}
const visibleRowActions = rowActions.filter(
const actions = row.isExpandedRecord || row.isExpandedPanel
? expandedRowActions
: rowActions;
const visibleRowActions = actions.filter(
(action) => !isActionHidden(action, row.rowContext),
);
return (
......@@ -961,7 +989,13 @@ export default function RecordWorkbench({
]
: []),
];
const disabledDataGridRowKeys = dataGridRows
// Expanded rows can expose their own actions (for example, editing a nested
// department item). Do not disable the whole row in that case: HeroUI
// DataGrid's disabledKeys disables descendant buttons as well as selection.
// Selection is still filtered to parent record keys by the workbench.
const disabledDataGridRowKeys = expandedRowActions.length > 0
? []
: dataGridRows
.filter((row) => row.isExpandedRecord || row.isExpandedPanel)
.map((row) => String(row.key));
const toolbarSearchField = filterSchema.find((field) => field.icon === 'search');
......@@ -1229,7 +1263,7 @@ export default function RecordWorkbench({
selectable
? (keys) =>
onSelectionChange?.(
keys.filter((key) => currentPageRecordIds.includes(key)),
keys.filter((key) => currentPageRecordIds.includes(String(key))),
)
: undefined
}
......
......@@ -10,9 +10,9 @@ import { loadSubject, queryRecords } from '@/services/DataService';
import {
formatDateText,
formatFieldValue,
getFieldValue,
getReferenceSubjectName,
normalizeRecordId,
resolveFieldValue,
toArray,
} from '@/utils/subjectView';
......@@ -165,6 +165,7 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
_id: { $in: referenceIds },
},
mode: 'full',
calculated: true,
})
: Promise.resolve({ records: populatedRecords }),
]);
......@@ -250,7 +251,7 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
isPrimary: index === 0,
render: (record) => (
<CellValue
value={getFieldValue(record, column.field)}
value={resolveFieldValue(record, column.field, column.fieldDef)}
field={column}
/>
),
......
......@@ -5,7 +5,7 @@ import {
buildViewColumns,
getFieldDisplayLabel,
getFieldPlaceholder,
getFieldValue,
resolveFieldValue,
isFieldDisabledByView,
} from '@/utils/subjectView';
......@@ -45,7 +45,7 @@ export default function SubjectViewContent({
className="grid min-w-0 grid-cols-1 gap-y-2"
>
{contentFields.map((field) => {
const value = getFieldValue(editableRecord, field.field);
const value = resolveFieldValue(editableRecord, field.field, field.fieldDef);
const fieldLabel = getFieldDisplayLabel(field.fieldDef, field.field);
const fieldDisabled = isFieldDisabled
? isFieldDisabled(field.field, view)
......
......@@ -23,6 +23,7 @@ import {
getFieldDisplayLabel,
getFieldPlaceholder,
getSelectOptions,
isFormulaFieldType,
isDateFieldType,
isReferenceFieldType,
getReferenceSubjectName,
......@@ -252,6 +253,17 @@ function ControlledFieldEditor({
}
};
if (isFormulaFieldType(fieldType)) {
return (
<Input
isReadOnly
value={value ?? ''}
placeholder="保存后由公式自动计算"
variant={fieldVariant}
/>
);
}
if (fieldType === 'boolean') {
return (
<Checkbox isSelected={Boolean(value)} onChange={(nextValue) => onChange?.(nextValue)}>
......@@ -433,12 +445,13 @@ export default function TailwindEditableField({
const canEdit = !readonly && !disabled;
const fieldType = fieldDef?.type || 'text';
const isFormula = isFormulaFieldType(fieldType);
const isDateField = isDateFieldType(fieldType);
const isLongText = ['long_text', 'rich_text'].includes(fieldType);
const isJsonLike = ['object', 'form', 'subject'].includes(fieldType);
const textPlaceholder = getTextPlaceholder(placeholder, fieldDef);
if (mode === 'view' || (mode === 'edit' && (readonly || disabled))) {
if (mode === 'view' || isFormula || (mode === 'edit' && (readonly || disabled))) {
return <ReadonlyFieldValue value={value} fieldDef={fieldDef} />;
}
......
......@@ -96,7 +96,7 @@ export function useSubjectRecord(
setError('');
try {
const record = await loadRecord(recordName);
const record = await loadRecord(recordName, true);
const resolvedSubjectName = subjectName || record?.__t || record?.subject?.name || record?.subject;
const [subjectData, viewSummaries] = resolvedSubjectName
? await Promise.all([loadSubject(resolvedSubjectName), getSubjectView(resolvedSubjectName)])
......@@ -151,7 +151,10 @@ export function useSubjectRecord(
return;
}
const normalizedChanges = changes.filter((change) => change?.fieldPath);
const normalizedChanges = changes.filter((change) => (
change?.fieldPath
&& String(change.fieldDef?.type || '').toLowerCase() !== 'formula'
));
if (normalizedChanges.length === 0) {
return;
}
......
......@@ -23,7 +23,7 @@ import {
} from '@/utils/subjectView';
const EMPTY_ARRAY = [];
const NON_EDITABLE_FIELD_TYPES = new Set(['form', 'subject']);
const NON_EDITABLE_FIELD_TYPES = new Set(['form', 'subject', 'formula']);
function cloneRecord(record) {
return record ? JSON.parse(JSON.stringify(record)) : null;
......
......@@ -16,6 +16,7 @@ import {
normalizeIds,
rejectControlMeasureFlow,
submitControlMeasureFlow,
withdrawControlMeasureFlow,
} from './IntegrityRiskService';
function normalizeRecord(record) {
......@@ -58,6 +59,34 @@ function RejectConfirmDialog({ type, loading, onClose, onConfirm }) {
);
}
function WithdrawConfirmDialog({ open, loading, onClose, onConfirm }) {
return (
<XinyuanModal
isOpen={open}
onOpenChange={(isOpen) => {
if (!isOpen && !loading) onClose?.();
}}
size="sm"
title="确认撤回审批"
description="撤回后审批人的 OA 待办会完成,表单恢复为填报人可填写状态。"
footer={(
<>
<Button isDisabled={loading} variant="secondary" onPress={onClose}>取消</Button>
<Button isDisabled={loading} isPending={loading} onPress={onConfirm}>
{loading ? '撤回中...' : '确认撤回'}
</Button>
</>
)}
>
<Alert status="warning">
<Alert.Content>
<Alert.Description>已填写的数据会保留,审批人尚未处理时才可以撤回。</Alert.Description>
</Alert.Content>
</Alert>
</XinyuanModal>
);
}
export default function ControlMeasureFormPage() {
const location = useLocation();
const userInfo = useUserInfo();
......@@ -67,6 +96,7 @@ export default function ControlMeasureFormPage() {
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [rejectType, setRejectType] = useState('');
const [withdrawOpen, setWithdrawOpen] = useState(false);
const messageId = useMemo(
() => new URLSearchParams(location.search).get('id') || '',
......@@ -158,6 +188,21 @@ export default function ControlMeasureFormPage() {
});
}
if (
isEditor
&& editorStatus === FLOW_STATUS.complete
&& auditorStatus === FLOW_STATUS.pending
) {
toolbarActions.push({
key: 'withdraw',
label: actionLoading === 'withdraw' ? '撤回中...' : '撤回审批',
variant: 'secondary',
tone: 'warning',
disabled: Boolean(actionLoading) || records.length === 0,
onClick: () => setWithdrawOpen(true),
});
}
if (
isAuditor
&& editorStatus === FLOW_STATUS.complete
......@@ -228,6 +273,15 @@ export default function ControlMeasureFormPage() {
if (succeeded) setRejectType('');
};
const confirmWithdraw = async () => {
const succeeded = await runFlowAction(
'withdraw',
() => withdrawControlMeasureFlow(messageRecord, userInfo, returnUrl),
'已撤回审批,表单已恢复为可填写状态。',
);
if (succeeded) setWithdrawOpen(false);
};
if (workspace?.isDepartmentFlow) {
return (
<DepartmentControlMeasureFlow
......@@ -290,6 +344,12 @@ export default function ControlMeasureFormPage() {
onClose={() => setRejectType('')}
onConfirm={confirmReject}
/>
<WithdrawConfirmDialog
open={withdrawOpen}
loading={actionLoading === 'withdraw'}
onClose={() => setWithdrawOpen(false)}
onConfirm={confirmWithdraw}
/>
</main>
);
}
......@@ -2,7 +2,11 @@ import { Alert, Button, Surface } from '@heroui/react';
import XinyuanModal from '@/components/XinyuanModal';
import { DynamicField, prepareValuesForSubmit } from '@/components/DynamicRecordDialog';
import TailwindEditableField from '@/pages/RecordDetailDrawer/components/TailwindEditableField';
import { getFieldDisplayLabel } from '@/utils/subjectView';
import {
getFieldDisplayLabel,
isFormulaFieldType,
resolveFieldValue,
} from '@/utils/subjectView';
import { useEffect, useMemo, useState } from 'react';
const HIDDEN_FIELD_NAMES = new Set([
......@@ -50,8 +54,7 @@ export default function DepartmentControlItemDialog({
const fields = useMemo(
() => (subject?.fields || []).filter((field) =>
!HIDDEN_FIELD_NAMES.has(field.name)
&& String(field.type || '').toLowerCase() !== 'formula'),
!HIDDEN_FIELD_NAMES.has(field.name)),
[subject],
);
......@@ -74,7 +77,11 @@ export default function DepartmentControlItemDialog({
<div className="mt-1.5 text-foreground">{item?.business_unit || '暂无内容'}</div>
</div>
{fields.map((field) => {
const isFullWidth = ['long_text', 'rich_text', 'subject', 'object', 'form'].includes(field.type);
const fieldType = String(field.type || '').toLowerCase();
const fieldValue = isFormulaFieldType(fieldType)
? resolveFieldValue(values, field.name, field)
: values[field.name];
const isFullWidth = ['long_text', 'rich_text', 'subject', 'object', 'form'].includes(fieldType);
return (
<div
......@@ -93,14 +100,14 @@ export default function DepartmentControlItemDialog({
fieldDef={field}
mode="view"
readonly
value={values[field.name]}
value={fieldValue}
/>
</div>
</>
) : (
<DynamicField
fieldDef={field}
value={values[field.name]}
value={fieldValue}
disabled={loading}
onChange={(nextValue) => setValues((current) => ({
...current,
......
......@@ -3,7 +3,7 @@ import XinyuanModal from '@/components/XinyuanModal';
import RecordWorkbench from '@/components/record-workbench';
import { queryUsers } from '@/services/DataService';
import { normalizeUserLabel } from '@/utils/subjectView';
import { CheckCircleIcon, Close, Edit, Eye, Send } from '@/components/AppIcons';
import { ArrowPathIcon, CheckCircleIcon, Close, Edit, Eye, Send } from '@/components/AppIcons';
import dayjs from 'dayjs';
import { useEffect, useMemo, useState } from 'react';
import DepartmentControlItemDialog from './DepartmentControlItemDialog';
......@@ -17,6 +17,7 @@ import {
rejectDepartmentControlItem,
saveDepartmentControlItem,
submitDepartmentControlItem,
withdrawDepartmentControlItem,
} from './IntegrityRiskService';
const COLUMNS = [
......@@ -42,6 +43,7 @@ export default function DepartmentControlMeasureFlow({ workspace, userInfo, onRe
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [rejectContext, setRejectContext] = useState(null);
const [withdrawContext, setWithdrawContext] = useState(null);
const [userLabels, setUserLabels] = useState({});
const rows = useMemo(
......@@ -130,6 +132,14 @@ export default function DepartmentControlMeasureFlow({ workspace, userInfo, onRe
`${record.business_unit}部门明细已送审。`,
),
},
{
key: 'withdraw-department-item',
label: '撤回审批',
icon: ArrowPathIcon,
hidden: ({ record }) => !getDepartmentItemPermissions(record, userInfo).canWithdraw,
disabled: ({ record }) => Boolean(actionKey) || !record.auditor,
onClick: ({ record }) => setWithdrawContext(record),
},
{
key: 'approve-department-item',
label: '审批通过',
......@@ -204,6 +214,17 @@ export default function DepartmentControlMeasureFlow({ workspace, userInfo, onRe
if (succeeded) setRejectContext(null);
};
const confirmWithdraw = async () => {
const row = withdrawContext;
if (!row) return;
const succeeded = await runAction(
`withdraw-${row.id}`,
() => withdrawDepartmentControlItem(actionArgs(row)),
`${row.business_unit}部门明细已撤回审批,可继续填写。`,
);
if (succeeded) setWithdrawContext(null);
};
const activePermissions = activeItem
? getDepartmentItemPermissions(activeItem, userInfo)
: { canEdit: false };
......@@ -260,6 +281,29 @@ export default function DepartmentControlMeasureFlow({ workspace, userInfo, onRe
</>
)}
/>
<XinyuanModal
isOpen={Boolean(withdrawContext)}
onOpenChange={(isOpen) => {
if (!isOpen && !actionKey) setWithdrawContext(null);
}}
size="sm"
title="确认撤回审批"
description={`撤回“${withdrawContext?.business_unit || '-'}”部门明细的审批申请。`}
footer={(
<>
<Button isDisabled={Boolean(actionKey)} variant="secondary" onPress={() => setWithdrawContext(null)}>取消</Button>
<Button isDisabled={Boolean(actionKey)} isPending={Boolean(actionKey)} onPress={confirmWithdraw}>
{actionKey ? '撤回中...' : '确认撤回'}
</Button>
</>
)}
>
<div className="space-y-2 text-sm text-muted">
<p>撤回后将完成审批人的 OA 待办,已填写内容会保留。</p>
<p>当前部门明细会恢复为填报人可编辑状态,其他部门不受影响。</p>
</div>
</XinyuanModal>
</main>
);
}
......@@ -55,7 +55,7 @@ export default function DepartmentPushDialog({ open, targets, loading, onClose,
primary={assignment.business_unit}
secondary={showParentTitle ? assignment.parent_title : '将生成 1 条部门明细'}
/>
{assignment.reuse_existing ? <Chip className="mt-1" color="warning" size="sm">沿用未完成子项</Chip> : null}
{assignment.reuse_existing ? <Chip className="mt-1" color="warning" size="sm">沿用部门子项原始数据</Chip> : null}
</div>
),
},
......
......@@ -160,18 +160,16 @@ export function buildDepartmentPushAssignments(records = []) {
return records.flatMap((record) => {
const rawRecord = record?.raw || record;
const existingItems = normalizeDepartmentControlItems(rawRecord);
const shouldReuseItems = existingItems.length > 0 && !allDepartmentItemsApproved(existingItems);
const departments = shouldReuseItems
? existingItems.map((item) => item.business_unit)
: uniqueValues(toArray(rawRecord?.metadata?.business_unit).map((item) => String(item || '').trim()));
const sourceItems = shouldReuseItems
? existingItems
: departments.map((department, index) => {
const previousItem = existingItems.find((item) => item.business_unit === department);
return previousItem || {
const existingDepartments = existingItems.map((item) => item.business_unit).filter(Boolean);
const parentDepartments = uniqueValues(
toArray(rawRecord?.metadata?.business_unit).map((item) => String(item || '').trim()),
);
const departments = uniqueValues([...existingDepartments, ...parentDepartments]);
const sourceItems = departments.map((department, index) => existingItems.find(
(item) => item.business_unit === department,
) || {
item_id: makeDepartmentItemId(rawRecord, department, index),
business_unit: department,
};
});
return sourceItems.map((item, index) => ({
......@@ -182,7 +180,7 @@ export function buildDepartmentPushAssignments(records = []) {
business_unit: item.business_unit || '未指定责任单位',
editor: item.editor || '',
auditor: item.auditor || '',
reuse_existing: shouldReuseItems,
reuse_existing: existingItems.some((existingItem) => existingItem.item_id === item.item_id),
editor_status: item.editor_status || FLOW_STATUS.incomplete,
auditor_status: item.auditor_status || FLOW_STATUS.pending,
sender_status: item.sender_status || FLOW_STATUS.pending,
......@@ -363,8 +361,10 @@ export async function exportShowTable(records) {
return sourceRecords.length;
}
export async function exportPreventionMeasures() {
const records = await queryAllIntegrityRecords(INTEGRITY_RISK_SUBJECTS.show);
export async function exportPreventionMeasures(
subject = INTEGRITY_RISK_SUBJECTS.show,
) {
const records = await queryAllIntegrityRecords(subject);
const measures = sortByOriginalIndex(records)
.flatMap((record) => String(
record?.metadata?.description_of_main_prevention_and_control_measures || '',
......@@ -515,10 +515,31 @@ function processArchiveRecord(record) {
approved_by: normalizeIds(metadata.approved_by),
approval_status: metadata.approval_status,
final_approval_status: metadata.final_approval_status,
[DEPARTMENT_CONTROL_ITEMS_FIELD]: normalizeDepartmentControlItems(record).map(
normalizeDepartmentArchiveItem,
),
},
};
}
function normalizeDepartmentArchiveItem(item) {
return {
...item,
business_unit: String(item.business_unit || '').trim(),
file_name_and_document_number: normalizeIds(item.file_name_and_document_number),
subsidiary_system: normalizeIds(item.subsidiary_system),
editor: normalizeId(item.editor),
auditor: normalizeId(item.auditor),
sender: normalizeId(item.sender),
progress_id_editor: item.progress_id_editor || '',
progress_id_auditor: item.progress_id_auditor || '',
progress_id_sender: item.progress_id_sender || '',
flow_record_id: normalizeId(item.flow_record_id),
created_by: normalizeId(item.created_by),
updated_by: normalizeId(item.updated_by),
};
}
function showArchiveRecord(record) {
const metadata = record?.metadata || {};
return {
......@@ -569,9 +590,13 @@ async function createArchiveBatch({ records, historySubject, mapRecord, referenc
return historyIds.length;
}
export function archiveProcessRecords(records) {
export async function archiveProcessRecords(records) {
const recordIds = normalizeIds(records);
const latestRecords = recordIds.length > 0
? await Promise.all(recordIds.map((id) => loadRecord(id)))
: [];
return createArchiveBatch({
records,
records: latestRecords,
historySubject: INTEGRITY_RISK_SUBJECTS.processArchive,
mapRecord: processArchiveRecord,
referenceField: 'reference_record',
......@@ -641,8 +666,14 @@ async function createProgressTasks(receiverIds, sender, title, returnUrl) {
}
async function completeProgressTasks(value) {
const taskIds = (Array.isArray(value) ? value : value ? [value] : [])
.flatMap((item) => typeof item === 'string' ? item.split(',') : [item]);
const flattenTaskValues = (currentValue) => (
(Array.isArray(currentValue) ? currentValue : currentValue ? [currentValue] : [])
.flatMap((item) => {
if (Array.isArray(item)) return flattenTaskValues(item);
return typeof item === 'string' ? item.split(',') : [item];
})
);
const taskIds = flattenTaskValues(value);
for (const taskId of taskIds
.map((item) => typeof item === 'object'
? item.taskId || item.task_id || item.id || item._id || item.name
......@@ -668,6 +699,7 @@ async function sendInternalNotice({ title, content, from, to }) {
}
let departmentSubjectPromise;
let processSubjectPromise;
async function loadDepartmentSubject() {
if (!departmentSubjectPromise) {
......@@ -680,6 +712,21 @@ async function loadDepartmentSubject() {
return departmentSubjectPromise;
}
export async function loadDepartmentControlItemSubject() {
return loadDepartmentSubject();
}
async function loadProcessSubject() {
if (!processSubjectPromise) {
processSubjectPromise = loadSubject(INTEGRITY_RISK_SUBJECTS.process)
.catch((error) => {
processSubjectPromise = null;
throw error;
});
}
return processSubjectPromise;
}
function departmentBusinessFields(subject) {
return (subject?.fields || []).filter((field) => {
const fieldName = String(field?.name || '').trim();
......@@ -699,6 +746,22 @@ function normalizeSnapshotValue(value, field) {
return value === undefined ? null : value;
}
/**
* Normalize a nested department item before writing it back to the parent
* record. Query responses may contain populated user/reference objects, while
* Record writes must contain only their IDs.
*/
function normalizeDepartmentItemForWrite(item, subject) {
if (!subject) return item;
return departmentBusinessFields(subject).reduce((result, field) => {
if (field.name === 'business_unit') return result;
if (Object.prototype.hasOwnProperty.call(item, field.name)) {
result[field.name] = normalizeSnapshotValue(item[field.name], field);
}
return result;
}, { ...item });
}
function hasFieldValue(item, fieldName) {
return Object.prototype.hasOwnProperty.call(item, fieldName)
&& item[fieldName] !== undefined
......@@ -714,6 +777,20 @@ function mergeTextValues(items, fieldName) {
.join('\n');
}
function mergeUniqueTextValues(items, fieldName) {
const seen = new Set();
return items
.map((item) => item[fieldName])
.filter((value) => value !== undefined && value !== null && value !== '')
.map((value) => String(value).trim())
.filter((value) => {
if (!value || seen.has(value)) return false;
seen.add(value);
return true;
})
.join('\n');
}
function mergeReferenceValues(items, fieldName) {
return uniqueValues(items.flatMap((item) => normalizeIds(item[fieldName])));
}
......@@ -728,6 +805,9 @@ function mergeDepartmentBusinessValue(items, field) {
}
if (['text', 'long_text', 'rich_text'].includes(fieldType)) {
if (fieldName === 'integrity_risk_points') {
return mergeUniqueTextValues(valuedItems, fieldName);
}
return mergeTextValues(valuedItems, fieldName);
}
......@@ -776,6 +856,31 @@ function buildDepartmentSnapshot(record, assignment, subject, currentUserId, now
};
}
function buildDepartmentResubmissionSnapshot(item, assignment, subject, currentUserId, now) {
return {
...normalizeDepartmentItemForWrite(item, subject),
item_id: item.item_id,
business_unit: assignment.business_unit || item.business_unit,
editor: normalizeId(assignment.editor),
auditor: normalizeId(assignment.auditor),
sender: currentUserId,
editor_status: FLOW_STATUS.incomplete,
auditor_status: FLOW_STATUS.pending,
sender_status: FLOW_STATUS.pending,
progress_id_editor: '',
progress_id_auditor: '',
progress_id_sender: '',
flow_record_id: '',
last_pushed_at: now,
submitted_at: null,
auditor_decided_at: null,
sender_decided_at: null,
merged_at: null,
updated_by: currentUserId,
updated_at: now,
};
}
function summarizeApprovalStatus(items, statusField) {
if (items.some((item) => item[statusField] === FLOW_STATUS.rejected)) {
return FLOW_STATUS.rejected;
......@@ -795,6 +900,16 @@ function departmentParentSummary(items = []) {
};
}
function departmentBusinessPatch(items, subject) {
const businessPatch = {};
departmentBusinessFields(subject).forEach((field) => {
if (field.name === 'business_unit') return;
businessPatch[field.name] = mergeDepartmentBusinessValue(items, field);
});
businessPatch.business_unit = uniqueValues(items.map((item) => item.business_unit));
return businessPatch;
}
function departmentFlowSummary(items = []) {
return {
editor_status: items.length > 0 && items.every((item) => item.editor_status === FLOW_STATUS.complete)
......@@ -823,9 +938,14 @@ async function updateDepartmentFlowSummary(messageRecord) {
});
}
async function mutateDepartmentItem(parentId, itemId, currentUser, mutate) {
async function mutateDepartmentItem(parentId, itemId, currentUser, mutate, departmentSubject = null) {
const [resolvedDepartmentSubject, processSubject] = await Promise.all([
departmentSubject || loadDepartmentSubject(),
loadProcessSubject(),
]);
const latestParent = await loadRecord(parentId);
const items = normalizeDepartmentControlItems(latestParent);
const items = normalizeDepartmentControlItems(latestParent)
.map((item) => normalizeDepartmentItemForWrite(item, resolvedDepartmentSubject));
const itemIndex = items.findIndex((item) => item.item_id === itemId);
if (itemIndex < 0) throw new Error('部门填报明细不存在,请刷新后重试。');
const currentItem = items[itemIndex];
......@@ -841,6 +961,7 @@ async function mutateDepartmentItem(parentId, itemId, currentUser, mutate) {
await updateRecord(latestParent._id || latestParent.name, {
metadata: {
...departmentBusinessPatch(nextItems, processSubject),
[DEPARTMENT_CONTROL_ITEMS_FIELD]: nextItems,
...departmentParentSummary(nextItems),
},
......@@ -855,11 +976,13 @@ function requireDepartmentRole(item, role, user) {
}
async function mergeDepartmentItemsIntoParent(parentId, messageRecord) {
const [latestParent, departmentSubject] = await Promise.all([
const [latestParent, departmentSubject, processSubject] = await Promise.all([
loadRecord(parentId),
loadDepartmentSubject(),
loadProcessSubject(),
]);
const items = normalizeDepartmentControlItems(latestParent);
const items = normalizeDepartmentControlItems(latestParent)
.map((item) => normalizeDepartmentItemForWrite(item, departmentSubject));
const scopedItemIds = new Set(normalizeIds(flowMetadata(messageRecord).department_item_ids));
const scopedItems = scopedItemIds.size > 0
? items.filter((item) => scopedItemIds.has(normalizeId(item.item_id)))
......@@ -873,17 +996,12 @@ async function mergeDepartmentItemsIntoParent(parentId, messageRecord) {
return false;
}
const sortedItems = [...scopedItems].sort((left, right) => {
const sortedItems = [...items].sort((left, right) => {
const leftTime = dayjs(left.submitted_at || 0).valueOf();
const rightTime = dayjs(right.submitted_at || 0).valueOf();
return leftTime - rightTime;
});
const businessPatch = {};
departmentBusinessFields(departmentSubject).forEach((field) => {
if (field.name === 'business_unit') return;
businessPatch[field.name] = mergeDepartmentBusinessValue(sortedItems, field);
});
businessPatch.business_unit = uniqueValues(sortedItems.map((item) => item.business_unit));
const businessPatch = departmentBusinessPatch(sortedItems, processSubject);
const mergedAt = dayjs().toISOString();
const mergedItems = items.map((item) => (
scopedItemIds.size === 0 || scopedItemIds.has(normalizeId(item.item_id))
......@@ -895,7 +1013,7 @@ async function mergeDepartmentItemsIntoParent(parentId, messageRecord) {
metadata: {
...businessPatch,
[DEPARTMENT_CONTROL_ITEMS_FIELD]: mergedItems,
...departmentParentSummary(scopedItems),
...departmentParentSummary(mergedItems),
},
});
return true;
......@@ -927,34 +1045,40 @@ async function pushDepartmentControlMeasureForm({
recordIds.forEach((parentId) => {
const parent = recordsById.get(parentId);
const existingItems = normalizeDepartmentControlItems(parent);
const replaceAll = existingItems.length === 0 || allDepartmentItemsApproved(existingItems);
const existingItems = normalizeDepartmentControlItems(parent)
.map((item) => normalizeDepartmentItemForWrite(item, departmentSubject));
const selectedAssignments = assignments.filter((item) => item.parent_id === parentId);
if (selectedAssignments.length === 0) return;
const selectedItems = selectedAssignments.map((assignment) => {
const existingItem = existingItems.find((item) => item.item_id === assignment.item_id);
if (!replaceAll && existingItem) {
return {
...existingItem,
editor: normalizeId(assignment.editor),
auditor: normalizeId(assignment.auditor),
sender: senderId,
flow_record_id: '',
last_pushed_at: now,
updated_by: senderId,
updated_at: now,
};
const existingItem = existingItems.find((item) =>
item.item_id === assignment.item_id
|| item.business_unit === assignment.business_unit,
);
if (existingItem) {
return buildDepartmentResubmissionSnapshot(
existingItem,
assignment,
departmentSubject,
senderId,
now,
);
}
return buildDepartmentSnapshot(parent, assignment, departmentSubject, senderId, now);
});
const selectedIds = new Set(selectedItems.map((item) => item.item_id));
const nextItems = replaceAll
? selectedItems
: existingItems.map((item) => selectedIds.has(item.item_id)
? selectedItems.find((selected) => selected.item_id === item.item_id)
: item);
plannedByParent.set(parentId, { parent, nextItems, selectedItems });
const selectedById = new Map(selectedItems.map((item) => [item.item_id, item]));
const nextItems = existingItems.map((item) => selectedById.get(item.item_id) || item);
selectedItems.forEach((item) => {
if (!existingItems.some((existingItem) => existingItem.item_id === item.item_id)) {
nextItems.push(item);
}
});
plannedByParent.set(parentId, {
parent,
nextItems,
selectedItems,
previousItemsById: new Map(existingItems.map((item) => [item.item_id, item])),
});
});
if (plannedByParent.size === 0) {
......@@ -1002,6 +1126,14 @@ async function pushDepartmentControlMeasureForm({
nextItems.push(item);
continue;
}
const previousItem = plan.previousItemsById.get(item.item_id);
if (previousItem) {
await completeProgressTasks([
previousItem.progress_id_editor,
previousItem.progress_id_auditor,
previousItem.progress_id_sender,
]);
}
const nextItem = { ...item, flow_record_id: flowId, last_pushed_at: now };
if (item.editor_status !== FLOW_STATUS.complete) {
await completeProgressTasks(item.progress_id_editor);
......@@ -1236,6 +1368,7 @@ export async function loadControlMeasureWorkspace(messageId) {
}
function requireCurrentDepartmentFlow(item, messageRecord) {
if (!messageRecord) return;
const flowIds = [messageRecord?._id, messageRecord?.name].map(normalizeId).filter(Boolean);
if (item.flow_record_id && !flowIds.includes(normalizeId(item.flow_record_id))) {
throw new Error('该部门明细已被重新推送,请使用最新流程链接。');
......@@ -1247,11 +1380,16 @@ export function getDepartmentItemPermissions(item, user) {
const isEditor = normalizeId(item?.editor) === userId;
const isAuditor = normalizeId(item?.auditor) === userId;
const isSender = normalizeId(item?.sender) === userId;
const canWithdraw = isEditor
&& item?.editor_status === FLOW_STATUS.complete
&& item?.auditor_status === FLOW_STATUS.pending
&& !item?.auditor_decided_at;
return {
isEditor,
isAuditor,
isSender,
canEdit: (isSender && item?.sender_status !== FLOW_STATUS.approved)
canWithdraw,
canEdit: isSender
|| (isEditor && item?.editor_status !== FLOW_STATUS.complete)
|| (
isAuditor
......@@ -1290,7 +1428,43 @@ export async function saveDepartmentControlItem({
Object.entries(values || {}).filter(([fieldName]) => allowedFields.has(fieldName)),
);
return { ...item, ...patch };
}, departmentSubject);
}
export async function withdrawDepartmentControlItem({
messageRecord,
parentId,
itemId,
user,
}) {
const result = await mutateDepartmentItem(parentId, itemId, user, async (item) => {
requireCurrentDepartmentFlow(item, messageRecord);
requireDepartmentRole(item, 'editor', user);
if (
item.editor_status !== FLOW_STATUS.complete
|| item.auditor_status !== FLOW_STATUS.pending
|| item.auditor_decided_at
) {
throw new Error('审批人已处理或当前明细不在可撤回阶段。');
}
await completeProgressTasks(item.progress_id_auditor);
return {
...item,
editor_status: FLOW_STATUS.incomplete,
auditor_status: FLOW_STATUS.pending,
sender_status: FLOW_STATUS.pending,
progress_id_editor: '',
progress_id_auditor: '',
progress_id_sender: '',
submitted_at: null,
auditor_decided_at: null,
sender_decided_at: null,
merged_at: null,
};
});
await updateDepartmentFlowSummary(messageRecord);
return result;
}
export async function submitDepartmentControlItem({
......@@ -1486,6 +1660,40 @@ export async function finalRejectDepartmentControlItem({
return result;
}
export async function withdrawControlMeasureFlow(record, user) {
const latest = await latestFlowRecord(record);
requireFlowRole(latest, 'editor', user);
const metadata = flowMetadata(latest);
requireFlowStage(
metadata.editor_status === FLOW_STATUS.complete
&& metadata.auditor_status === FLOW_STATUS.pending,
'审批人已处理或当前流程不在可撤回阶段。',
);
await completeProgressTasks(metadata.progress_id_auditor);
const recordIds = flowRecordIds(latest);
if (recordIds.length > 0) {
await batchUpdateRecords({
subject: metadata.subject_name || INTEGRITY_RISK_SUBJECTS.process,
filter: { _id: { $in: recordIds } },
data: {
'metadata.approval_status': FLOW_STATUS.pending,
'metadata.final_approval_status': FLOW_STATUS.pending,
},
});
}
await updateRecord(latest._id || latest.name, {
metadata: {
editor_status: FLOW_STATUS.incomplete,
auditor_status: FLOW_STATUS.pending,
sender_status: FLOW_STATUS.pending,
progress_id_editor: [],
progress_id_auditor: [],
progress_id_sender: '',
},
});
}
export async function submitControlMeasureFlow(record, user, returnUrl) {
const latest = await latestFlowRecord(record);
requireFlowRole(latest, 'editor', user);
......
......@@ -2,6 +2,7 @@ import { Button, Surface } from '@heroui/react';
import FileUploadField from '@/components/FileUploadField';
import XinyuanModal from '@/components/XinyuanModal';
import SubjectRecordWorkbench from '@/components/SubjectRecordWorkbench';
import DepartmentControlItemDialog from './DepartmentControlItemDialog';
import { useUserInfo } from '@/hooks/user';
import { useMemo, useState } from 'react';
import {
......@@ -13,11 +14,15 @@ import {
exportProcessTable,
exportShowTable,
INTEGRITY_RISK_SUBJECTS,
getDepartmentItemPermissions,
loadDepartmentControlItemSubject,
saveDepartmentControlItem,
pushControlMeasureForm,
replaceMeasureDescriptionsFromWorkbook,
replaceShowTableFromWorkbook,
normalizeDepartmentControlItems,
} from './IntegrityRiskService';
import { Edit, Eye } from '@/components/AppIcons';
import DepartmentPushDialog from './DepartmentPushDialog';
const PAGE_CONFIG = {
......@@ -90,6 +95,10 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) {
const [importContext, setImportContext] = useState(null);
const [importFile, setImportFile] = useState(null);
const [importType, setImportType] = useState('');
const [departmentSubject, setDepartmentSubject] = useState(null);
const [departmentEditor, setDepartmentEditor] = useState(null);
const [departmentEditorLoading, setDepartmentEditorLoading] = useState(false);
const [departmentEditorActionKey, setDepartmentEditorActionKey] = useState('');
const runAction = async ({ key, context, action, successMessage, reload = false }) => {
setBusyKey(key);
......@@ -154,6 +163,17 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) {
action: exportProcessTable,
successMessage: (count) => `已导出 ${count} 条过程表记录。`,
}),
}, {
key: 'export-process-measures',
label: busyKey === 'export-process-measures' ? '导出中...' : '导出防控措施',
variant: 'secondary',
disabled: disabledWhileBusy,
onClick: (context) => runAction({
key: 'export-process-measures',
context,
action: () => exportPreventionMeasures(INTEGRITY_RISK_SUBJECTS.process),
successMessage: (count) => `已导出 ${count} 条去重后的防控措施。`,
}),
}],
bulkActions: [
{
......@@ -303,6 +323,105 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) {
if (succeeded) setPushContext(null);
};
const resolveDepartmentItemContext = (context) => {
const parentRecord = context?.parentRecord?.raw
|| context?.parentRecord
|| context?.record?.parentRecord?.raw
|| context?.record?.parentRecord
|| context?.record?.raw
|| context?.record;
const parentId = parentRecord?._id || parentRecord?.id || parentRecord?.name;
const expandedRecord = context?.record?.raw || context?.record;
const itemId = expandedRecord?.metadata?.item_id || expandedRecord?.item_id;
const item = normalizeDepartmentControlItems(parentRecord)
.find((candidate) => candidate.item_id === itemId);
return { parentRecord, parentId, item };
};
const openDepartmentItem = async (context, readonly = false) => {
const { parentId, item } = resolveDepartmentItemContext(context);
if (!parentId || !item) {
context?.setError?.('找不到对应的部门子项,请刷新主界面后重试。');
return;
}
const permissions = getDepartmentItemPermissions(item, userInfo);
if (!readonly && !permissions.canEdit) {
context?.setError?.('当前用户没有编辑该部门子项的权限。');
return;
}
setDepartmentEditorLoading(true);
context?.setError?.('');
try {
const subject = departmentSubject || await loadDepartmentControlItemSubject();
setDepartmentSubject(subject);
setDepartmentEditor({
item,
parentId,
readonly: readonly || !permissions.canEdit,
setError: context?.setError,
setSuccess: context?.setSuccess,
reload: context?.reload,
});
} catch (error) {
context?.setError?.(error?.message || '部门子项结构加载失败,请稍后重试。');
} finally {
setDepartmentEditorLoading(false);
}
};
const expandedRowActions = pageType === 'process' ? [
{
key: 'edit-department-item',
label: '编辑子项',
icon: Edit,
hidden: (context) => {
const { item } = resolveDepartmentItemContext(context);
return !item || !getDepartmentItemPermissions(item, userInfo).canEdit;
},
disabled: () => departmentEditorLoading || Boolean(departmentEditorActionKey),
onClick: (context) => openDepartmentItem(context, false),
},
{
key: 'view-department-item',
label: '查看子项',
icon: Eye,
hidden: (context) => {
const { item } = resolveDepartmentItemContext(context);
return !item || getDepartmentItemPermissions(item, userInfo).canEdit;
},
disabled: () => departmentEditorLoading || Boolean(departmentEditorActionKey),
onClick: (context) => openDepartmentItem(context, true),
},
] : [];
const closeDepartmentEditor = () => {
if (departmentEditorActionKey) return;
setDepartmentEditor(null);
};
const handleDepartmentItemSave = async (values) => {
if (!departmentEditor) return;
const actionKey = `save-department-${departmentEditor.item.item_id}`;
setDepartmentEditorActionKey(actionKey);
departmentEditor.setError?.('');
departmentEditor.setSuccess?.('');
try {
await saveDepartmentControlItem({
parentId: departmentEditor.parentId,
itemId: departmentEditor.item.item_id,
values,
user: userInfo,
});
departmentEditor.setSuccess?.('部门子项已保存,主表数据已自动合并。');
await departmentEditor.reload?.({ keepFeedback: true });
setDepartmentEditor(null);
} catch (error) {
departmentEditor.setError?.(error?.message || '部门子项保存失败,请稍后重试。');
} finally {
setDepartmentEditorActionKey('');
}
};
return (
<>
<SubjectRecordWorkbench
......@@ -319,6 +438,7 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) {
hiddenColumnFields={pageConfig.hiddenColumnFields || []}
toolbarActions={toolbarActions}
bulkActions={bulkActions}
expandedRowActions={expandedRowActions}
detailDrawerProps={{
title: `${pageConfig.title}详情`,
readonly: pageConfig.readonly,
......@@ -357,6 +477,15 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) {
onClose={() => setPushContext(null)}
onSubmit={confirmPush}
/>
<DepartmentControlItemDialog
open={Boolean(departmentEditor)}
item={departmentEditor?.item}
subject={departmentSubject}
readonly={Boolean(departmentEditor?.readonly)}
loading={Boolean(departmentEditorActionKey)}
onClose={closeDepartmentEditor}
onSave={handleDepartmentItemSave}
/>
<ImportConfirmDialog
open={Boolean(importContext)}
file={importFile}
......
......@@ -29,8 +29,8 @@
点击只切换该组列的显示状态。分组字段表头同时展示分组名和字段名,不改变查询或记录数据。
3. 详情继续使用当前 `RecordDetailDrawer`,列表页不复制或改写详情抽屉逻辑;可编辑页面
的字段维护统一从详情侧栏进入。
4. 新建、复制和软删除沿用工作台既有行为;列表行内不提供独立编辑按钮,业务字段写入
`metadata`。
4. 新建、复制和软删除沿用工作台既有行为;主对象列表行内不提供独立编辑按钮,业务字段写入
`metadata`。过程表展开后的部门子项行提供“编辑子项”或“查看子项”操作,不复用主对象详情编辑。
5. 归档页为只读,不提供新建、编辑、复制或删除。
6. 页面请求必须展示加载、成功和错误状态;删除与替换导入必须提供确认。删除确认弹窗
仅展示选中记录数量和软删除说明,不展示记录标题列表。
......@@ -50,13 +50,17 @@
`file_name_and_document_number` 多值引用 `internal_regulation`,
`subsidiary_system` 多值引用 `subsidiary_system`,写入时均只保存 `_id[]`。
`risk_level` 是公式字段,由 `probability * hazard_degree` 计算,页面不手工写入。
列表和详情请求必须显式使用 `calculated=true`,公式字段在表格、详情和新建/编辑弹窗中以只读结果展示;
弹窗中的概率或危害程度变更会即时刷新风险等级预览,提交时不会把公式结果写回 `metadata`。
12. 展示表 `risk_prevention_control_measures_table_show` 的真实定义已确认包含 18 个字段;
不包含填报/审批用户字段,也不包含引用字段。导入后的制度名称、子公司制度、责任单位
均保存展示文本或长文本,`risk_level` 同样为公式字段,页面不手工写入。
均保存展示文本或长文本,`risk_level` 同样为公式字段,页面不手工写入;列表和详情读取
公式结果时同样必须显式使用 `calculated=true`。
13. 措施库 `measure_description` 的真实定义已确认只包含 `description` 一个文本字段,
导入、导出、归档均以 `metadata.description` 作为措施正文。
14. 过程表归档对象 `risk_prevention_control_measures_table_archive` 的真实定义已确认包含
22 个字段;部分过程表多值字段在快照中保存为展示文本,制度引用和用户字段仍写 `_id[]`。
14. 过程表归档对象 `risk_prevention_control_measures_table_archive` 的主对象快照定义已确认包含
22 个字段;归档业务在此基础上同时保留对应的 `department_control_items[]` 子对象快照。
主对象部分的多值字段在快照中保存为展示文本,制度引用和用户字段仍写 `_id[]`。
15. 展示表归档对象 `risk_prevention_control_measures_table_show_archive` 的真实定义已确认包含
18 个字段;制度名称、子公司制度、责任单位和涉及范围均保存展示文本,不写引用或用户 ID。
16. 措施库历史对象 `measure_description_history` 的真实定义已确认只包含 `description`
......@@ -70,9 +74,16 @@
`reference_record_id` 与当前页面兼容写入的 `reference_id` 需保持注意。
19. 过程表列表不直接展示内部嵌套字段 `department_control_items`;部门展开行中的用户和引用
字段仍保存 ID,但列表批量解析并显示用户名称、记录标题,解析失败时才回退显示 ID。
用户点击父行左侧展开按钮后,每个责任单位显示为一条部门子项行。
20. 详情编辑中的用户字段会按已保存的用户 ID 补查用户信息,不受首屏用户列表范围限制;
展开下拉框时已选用户始终置顶并保持原选中顺序。补查失败时保留原 ID 选择值并显示不可用状态,
不因回显失败自动清空;保存时仍只写入 `_id` / `_id[]`。
21. 部门展开行的操作列按当前用户和子项流程阶段显示“编辑子项”或“查看子项”。编辑入口按
`item_id` 从父记录最新的 `department_control_items[]` 中定位真实子项,使用运行时加载的
`risk_prevention_control_measures_table_department_item` Subject 动态渲染字段;保存后立即
合并全部子项到主对象并刷新过程表。找不到对应 `item_id` 时终止操作并提示刷新,禁止误用
展开行包装数据、其他部门子项或主对象合并结果。
>>>>>>> eaef1ed (feat: support department item editing and risk workflow)
### 2.1 Subject View 授权
......@@ -117,27 +128,48 @@
### 3.1 推送风险防控表
1. 用户勾选一条或多条过程表记录,点击“推送风险防控表”。
2. 系统按每条记录的 `business_unit` 生成责任单位配置行;每行分别选择 1 名填报人和
1 名审批人,可删除无需参与本次推送的责任单位。
2. 系统按每条风险记录的 `business_unit` 拆分推送明细:1 个责任单位对应 1 条
`department_control_items[]` 子项和 1 条独立的填报/审批链路,3 个责任单位即生成 3 条
部门明细。一次操作可以用同一个 `message_reference_id` 流程主单关联多条部门明细,但
不合并部门的权限、填报数据或审批状态。
3. 每行分别选择 1 名填报人和 1 名审批人,可删除无需参与本次推送的责任单位。所有参与人
都可以查看本次推送涉及的其他部门明细;编辑和流程操作只对当前用户承担角色的子项开放,
不得越权修改其他人员负责的子项。
推送弹窗采用与问题整改推送一致的紧凑配置方式:责任单位列表在弹窗内独立滚动,用户
选择使用搜索下拉浮层,所选风险记录在下方摘要展示,避免用户列表或多条责任单位把弹窗
直接撑长。
3. 首次推送或历史子项全部终审通过时,从主表业务字段生成新的
`metadata.department_control_items[]` 快照;存在任意未终审子项时沿用当前子项数据,
只更新本次配置的人员、流程主单 ID 和待办。
4. 创建 `message_reference_id` 流程主单:`restriction_value` 保存父过程表 ID,
4. 推送数据来源按场景区分:
- 首次推送且不存在对应部门子项时,使用父过程表当前业务字段仅初始化该责任单位的新
部门快照,并将新子项追加到现有 `department_control_items[]`;不得因为创建新子项而替换、
清空或重置其他部门子项。
- 第二年重新推送、同一风险点再次推送,或只向某个人/某个责任单位单独推送时,必须先按
本次推送的责任单位匹配 `metadata.department_control_items[]` 中的对应部门子项,再使用
该部门子项保存的原始业务数据;不得跨部门取数,也不得从已经合并的主对象反向生成,
避免把其他部门的合并结果带入本次推送。
- 如果本次责任单位没有匹配到已有部门子项,不能误用其他部门子项或将其他部门数据带入;
应将该责任单位视为首次推送,使用父过程表当前业务字段创建新的部门子项,并保留父记录
中其他部门子项的全部业务数据、人员配置、审批状态、流程关联和时间字段。
- 只重新推送选定的子项;未选中的部门子项及其流程状态不受影响。
5. 创建 `message_reference_id` 流程主单:`restriction_value` 保存父过程表 ID,
`department_item_ids` 保存本次子项 `item_id[]`,参与人字段保存去重后的用户 ID。
5. 根据每条子项当前阶段创建填报、审批或最终审批 OA 待办,并将待办 ID 写入对应子项。
6. 主表 `filled_by`、`approved_by`、`approval_status`、`final_approval_status` 由全部子项汇总,
业务字段在全部子项最终审批完成前不修改。
7. 创建并发送站内通知,链接指向 `/control_measure_form?id=<流程主单>`。
8. 任一步失败时页面显示错误,不显示成功状态;已成功创建的外部 OA/通知不具备事务
6. 根据每条子项当前阶段创建填报、审批或最终审批 OA 待办,并将待办 ID 写入对应子项。
7. 主表 `filled_by`、`approved_by`、`approval_status`、`final_approval_status` 由全部子项汇总;
每次子项保存都会将全部子项当前已保存的业务字段合并到主对象,最终审批只负责完成本次流程
的合并标记。
8. 创建并发送站内通知,链接指向 `/control_measure_form?id=<流程主单>`。
9. 任一步失败时页面显示错误,不显示成功状态;已成功创建的外部 OA/通知不具备事务
回滚能力,需要管理员按错误信息核查。
### 3.2 导出
- “导出廉洁风险防控表(过程表)”查询全部过程表记录,按 `original_index` 升序导出;
空序号记录排在最后。
- 过程表工具栏提供“导出防控措施”,查询全部过程表记录,按与展示表相同的规则从“主要防控措施描述”
中按行拆分、去除行首 `数字.`、去空并去重,生成“廉洁风险防控措施.xlsx”。
- 导出数据以过程表主对象当前 `metadata` 为准。部门子项是填报和审批的原始来源,不能
直接替代主对象作为导出数据源。
- 导出不读取或展开 `metadata.department_control_items[]`;子对象中的分部门填报、审批和
流程控制字段不会单独生成导出行。
- 引用和多值字段导出为显示文本,不把对象写入 Excel。
### 3.3 归档
......@@ -149,7 +181,11 @@
`subsidiary_system`、`filled_by`、`approved_by` 保存 `_id[]`。
4. 创建 `integrity_risk_control_archive` 批次,通过 `metadata.type=廉洁风险点`
和 `metadata.reference_record` 关联快照。
5. 归档只创建快照,不删除过程表原记录。
5. 归档前,业务老师可以修改已经合并到主对象的内容。归档时同时保存两部分快照:
过程表主对象当前业务数据,以及该记录当时的
`metadata.department_control_items[]` 子对象完整数据(包括各部门原始业务字段、人员、
权限范围、审批状态、流程主单/待办 ID 和时间字段)。归档只创建快照,不删除过程表原记录,
也不把主对象的人工修改反写到部门子项。
## 4. 展示表操作
......@@ -196,46 +232,89 @@
```text
按责任单位发起推送
-> 各部门填报人分别送审
-> 审批人尚未处理时,填报人可撤回审批
-> 完成审批人 OA 待办,当前部门明细恢复为填报人可填写
-> 各部门审批人分别审批
-> 发起人分别完成各部门最终审批
-> 全部当前子项终审通过后自动合并主表
-> 任一子项保存时,将全部子项当前已保存内容同步合并到主表
-> 全部当前子项终审通过后完成本次流程的最终合并标记
-> 推送人(最终审批人)可修订子项业务字段,保存时再次自动合并主表
-> 业务老师修改主对象后归档:同时保存主对象和部门子对象快照
-> 导出时只读取主对象数据,不读取部门子对象
一级审核驳回 -> 填报人重新填写 -> 再次送审
最终审核驳回 -> 填报人重新填写 -> 再次送审 -> 再次一级审核
```
### 6.1 填报人送审
### 6.1 保存与主数据合并
- 每次保存部门子项时,先读取最新父记录,再仅替换当前 `item_id` 对应的子项,避免覆盖
其他部门刚刚保存的数据;保存完成后,系统基于父记录中全部部门子项的最新内容同步更新
主对象业务字段。
- 合并只处理业务字段;责任单位、填报/审批人员、状态、流程主单 ID、OA 待办 ID 和各类
时间字段仍由流程控制,不因业务字段合并而被覆盖。
- 文本、长文本和富文本按子项内容合并;引用字段合并 `_id[]` 并去重;多值普通字段合并
后去重;其他单值字段取最后一个有值子项。
- 同一父级风险记录下,如果多个部门子项提交的 `integrity_risk_points` 内容完全一致,
主对象只保留一份风险点内容,不重复拼接;去重不影响各部门子项本身的人员、权限和审批
状态。内容不一致的风险点仍按上述规则合并。
- 主对象是合并结果和业务老师后续维护的对象;业务老师直接修改主对象时不反写部门子项。
但推送人(最终审批人)对部门子项业务字段的修订,必须通过子项保存流程自动再次同步到
主对象。
### 6.2 填报人送审
- 仅子项 `editor` 可操作该行,填写范围为子 Subject 中的业务字段;责任单位、人员、
状态和待办字段不开放编辑。
- 保存时先读取最新父记录,再按 `item_id` 替换当前子项,避免覆盖其他部门数据。
- 送审后完成该子项填报待办,创建对应 `auditor` 的审批待办。
### 6.2 一级审核
### 6.3 填报人撤回审批
- 仅当前子项的 `editor` 可以撤回自己提交的审批;其他部门填报人、审批人和普通查看者
不得代为撤回。
- 只有在该子项已送审且审批人尚未作出审批决定时才允许撤回,即
`editor_status=已完成` 且 `auditor_status=未审批`。审批人已通过或已驳回后不能再撤回,
应按对应的审批结果继续后续流程。
- 撤回操作先将 `progress_id_auditor` 指向的审批人 OA 待办设置为已完成,再更新当前子项
状态:`editor_status` 恢复为 `未完成`,`auditor_status` 保持/重置为 `未审批`,表单重新
对填报人开放业务字段编辑。已保存的业务数据保留,不影响其他部门子项。
- 撤回不创建新的填报人 OA 待办,也不启动新的 OA 流程;只恢复填报状态并清空当前审批待办
引用。重新填写完成后,填报人可以再次送审,由送审操作创建新的审批待办。
- 若关闭审批待办或更新子项状态任一步失败,页面显示错误,不得提示撤回成功;管理员需核查
OA 待办和当前子项状态是否一致。
- 兼容的非部门流程提供同样的撤回入口:撤回成功后同步将关联主记录的一级/最终审批状态
恢复为 `未审批`,并恢复填报流程状态;同样不创建新的填报人 OA 待办。
### 6.4 一级审核
- 仅当前子项 `auditor` 可操作,且该子项填写状态必须为已完成。
- 通过只更新当前子项并创建 `sender` 的最终审批待办;驳回只把当前子项退回填报人,
其他部门状态不受影响。
### 6.3 最终审核
### 6.5 最终审核
- 仅当前子项 `sender` 可操作,且该子项一级审批必须通过。
- 单个部门终审通过时只更新当前子项;本次流程 `department_item_ids` 对应的全部子项
`sender_status=已审批` 后才合并主表,历史或未参与本次推送的子项不阻塞本次合并。
`sender_status=已审批` 后完成本次流程的最终合并检查和 `merged_at` 标记,历史或未参与
本次推送的子项不阻塞本次流程。
- 历史流程若已终审但未写入 `merged_at`,发起人可再次触发最终审批动作补偿合并;
已审批状态不会被重复改写。
- 合并按 `submitted_at` 升序应用子项业务字段:文本、长文本和富文本按子项值用换行符
`\n` 拼接;`reference` 字段拼接各子项 `_id[]` 并去重;多值普通字段拼接去重;
其他单值字段取最后一个有值子项。
- `business_unit` 按全部子项责任单位汇总,公式、人员、状态和待办字段不参与业务字段覆盖;
主表审批状态只由子项审批状态映射汇总,按最落后状态展示。
- 合并后为所有子项写入同一个 `merged_at`;主表后续修改不会回写子项。
- 完成本次终审后,推送人发现描述或其他业务字段不准确时,可以直接修改其负责的部门子项;
保存会重新执行“保存与主数据合并”,不需要从主对象反向拆分,也不允许修改其他部门子项。
- 合并完成后为本次流程涉及的子项写入同一个 `merged_at`;主表后续修改不会回写子项。
- 终审驳回只把当前子项退回填报人并重新经历一级审批。
### 6.4 编辑权限
### 6.6 编辑权限
- 发起人在当前子项终审前可编辑该子项,终审完成后只读。
- 填报人在当前子项送审前可编辑;送审后只读。
- 所有参与人都可以查看本次推送的全部部门明细,但只能编辑自己在该子项中承担角色范围内
的业务字段,不得越权修改其他人的子项或流程控制字段。
- 发起人/最终审批人在当前子项终审阶段及终审完成后的校正场景可编辑该子项业务字段,
保存自动触发主对象合并;责任单位、人员、状态和待办字段仍只读。
- 填报人在当前子项送审前可编辑;送审后,在审批人尚未处理的情况下仍可执行“撤回审批”,
撤回成功后恢复编辑;审批人已通过或已驳回后不能撤回。
- 审批人在当前子项送审后、审批通过前可编辑;审批通过后只读。
- 其他用户只读,且不显示该行流程操作按钮。
......@@ -243,6 +322,17 @@
- 外部流程链接必须包含流程主单 `id`;缺失、无权限或记录不存在时显示错误状态。
- 推送记录为空、填报人为空、审核人为空时不创建流程。
- 填报人撤回审批仅限当前子项审批人尚未处理的阶段;必须同步完成审批人的 OA 待办并恢复
`editor_status=未完成`,不得创建新的填报人 OA 待办,否则不得部分成功。
- 同一父级风险记录下完全相同的 `integrity_risk_points` 只在主对象保留一份;不得因多个
部门提交相同内容而重复拼接。
- 第二年重新推送、同一风险点再次推送或单独推送给某个人时,必须先按责任单位匹配对应的
部门子项,再以该子项保存的原始业务数据为推送来源;不得跨部门取数,也不得以主对象的
合并结果作为已有子项的新数据来源。没有对应部门子项时,将该责任单位视为首次推送,
使用父过程表当前业务字段创建新子项并追加保存;不得因此覆盖、清空或重置其他部门子项
的任何数据或流程状态。
- 归档同时保存主对象当前数据和部门子对象快照;导出只以主对象当前数据为准,不读取或
展开部门子对象。
- 所有用户、引用和归档关联字段只写 ID 字符串或 ID 数组。
- 选择字段的选项、必填规则和主键规则只使用 `loadSubject` 返回配置,不在页面硬编码。
- 过程表当前 Subject 定义中 `approval_status` 选项只有 `未审批`、`已审批`;一级审核驳回
......
......@@ -167,8 +167,9 @@ function changeSearchIndex(params) {
});
}
function loadRecord(name) {
return request(`/record/${name}`, {
function loadRecord(name, calculated = true) {
const query = calculated ? '?calculated=true' : '';
return request(`/record/${name}${query}`, {
method: 'get',
});
}
......@@ -219,6 +220,7 @@ function loadRecords(
keyword,
full,
select,
calculated: true,
},
});
}
......@@ -226,7 +228,10 @@ function loadRecords(
function queryRecords(query) {
return request('/record/query', {
method: 'post',
data: query,
data: {
...(query || {}),
calculated: query?.calculated ?? true,
},
});
}
......
......@@ -96,6 +96,7 @@ export async function queryAllSubjectRecords(subjectName, query = {}) {
page: 0,
page_size: QUERY_PAGE_SIZE,
mode: 'full',
calculated: true,
sort: '-updated_at -created_at',
...query,
});
......@@ -111,6 +112,7 @@ export async function queryAllSubjectRecords(subjectName, query = {}) {
page,
page_size: QUERY_PAGE_SIZE,
mode: 'full',
calculated: true,
sort: '-updated_at -created_at',
...query,
});
......
......@@ -185,6 +185,135 @@ export function getFieldValue(record, fieldPath) {
return fieldPath.split('.').reduce((current, key) => current?.[key], record);
}
function isFormulaEmpty(value) {
return value === undefined || value === null || value === '';
}
function splitFormulaArguments(value) {
const parts = [];
let current = '';
let depth = 0;
let quote = '';
for (const character of String(value || '')) {
if (quote) {
current += character;
if (character === quote) quote = '';
continue;
}
if (character === "'" || character === '"') {
quote = character;
current += character;
} else if (character === '(') {
depth += 1;
current += character;
} else if (character === ')') {
depth = Math.max(0, depth - 1);
current += character;
} else if (character === ',' && depth === 0) {
parts.push(current.trim());
current = '';
} else {
current += character;
}
}
if (current.trim() || parts.length > 0) parts.push(current.trim());
return parts;
}
function unwrapFormulaCall(expression, functionName) {
const match = String(expression || '').trim().match(
new RegExp(`^${functionName}\\s*\\((.*)\\)$`, 'i'),
);
return match ? match[1] : null;
}
function formulaOperandValue(record, expression) {
const source = String(expression || '').trim();
if (!source) return undefined;
const quoted = source.match(/^(['"])(.*)\1$/s);
if (quoted) return quoted[2];
if (/^null$/i.test(source)) return null;
if (/^true$/i.test(source)) return true;
if (/^false$/i.test(source)) return false;
if (/^-?(?:\d+\.?\d*|\.\d+)$/.test(source)) return Number(source);
const productArguments = unwrapFormulaCall(source, 'PRODUCT');
if (productArguments !== null) {
const values = splitFormulaArguments(productArguments).map((item) =>
formulaOperandValue(record, item),
);
if (values.some((value) => isFormulaEmpty(value))) return undefined;
const numbers = values.map(Number);
if (numbers.some((value) => !Number.isFinite(value))) return undefined;
return numbers.reduce((result, value) => result * value, 1);
}
const fieldReference = source.match(/^\[([^\]]+)\]$/);
const fieldName = fieldReference ? fieldReference[1].trim() : source;
const value = getFieldValue(record, fieldName) ?? getFieldValue(record, `metadata.${fieldName}`);
return value && typeof value === 'object' && !Array.isArray(value)
? value.value ?? value._id ?? value.name
: value;
}
/**
* Evaluate the small, declarative formula grammar used by Subject fields.
* It intentionally supports only function calls and literals, never arbitrary
* JavaScript, so formulas from the API cannot execute code in the browser.
*/
export function evaluateFormula(formula, record) {
const source = String(formula || '').trim();
if (!source) return undefined;
const switchArguments = unwrapFormulaCall(source, 'SWITCH');
if (switchArguments !== null) {
const parts = splitFormulaArguments(switchArguments);
if (parts.length < 3) return undefined;
const switchValue = formulaOperandValue(record, parts[0]);
for (let index = 1; index + 1 < parts.length; index += 2) {
const caseValue = formulaOperandValue(record, parts[index]);
if (String(caseValue) === String(switchValue)) {
return formulaOperandValue(record, parts[index + 1]);
}
}
return parts.length % 2 === 0
? formulaOperandValue(record, parts[parts.length - 1])
: undefined;
}
return formulaOperandValue(record, source);
}
export function isFormulaFieldType(fieldType) {
return normalizeText(fieldType).toLowerCase() === 'formula';
}
export function getFormulaExpression(fieldDef) {
return fieldDef?.settings?.formula || fieldDef?.formula || '';
}
export function resolveFieldValue(record, fieldPath, fieldDef) {
const currentValue = getFieldValue(record, fieldPath);
if (!isFormulaFieldType(fieldDef?.type)) return currentValue;
const calculatedValue = evaluateFormula(getFormulaExpression(fieldDef), record);
return calculatedValue === undefined ? currentValue : calculatedValue;
}
export function applyFormulaFields(record, fields = []) {
return (fields || []).reduce((nextRecord, field) => {
if (!isFormulaFieldType(field?.type)) return nextRecord;
const fieldPath = getFieldPath(field);
const value = evaluateFormula(getFormulaExpression(field), nextRecord);
return value === undefined ? nextRecord : setFieldValue(nextRecord, fieldPath, value);
}, JSON.parse(JSON.stringify(record || {})));
}
export function setFieldValue(record, fieldPath, value) {
const nextRecord = JSON.parse(JSON.stringify(record || {}));
const keys = fieldPath.split('.');
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment