Просмотр исходного кода

feat: migrate OA workflow types

彭峰 1 месяц назад
Родитель
Сommit
a87fcdb394

+ 2 - 0
docs/migration/admin-migration-status.md

@@ -32,6 +32,8 @@
 - `Index` 后台入口控制器的 Default/Main/WorkTable、Login/Login_AJAX、Logout 已由 Angular 后台壳、管理概览、Parse 登录与会话撤销等价替代;FuncList/SiteMap 由侧栏、资源目录和后台快捷入口部分覆盖。旧主题桌面、ASP.NET Session 锁屏、动态 ASCX/Razor 加载、全局动画开关及公开管理员申请缺少服务器配置或安全审批链,8 个动作由 `legacyIndexBlocker` 明确阻塞,绝不复刻随机密码加固定高权限角色的不安全流程。
 - `Extend/Log.SysLog` 已用线上 470 条 `SysLog` 实际记录迁为只读 `systemLogs` 云函数和 Angular“系统日志”页,支持帐套隔离、分页、一级/二级类型、关键词与日期筛选,并禁止通用写入。旧文本日志目录和 Hangfire 任务中心缺少文件系统、`Content_ScheTask` 数据及调度控制面;`SenTask` 为空且语义不等价。Design/Interactive、Office/iServer、Office/Licence 也分别缺少 `Design_Pub`、服务工单/回复、协议/申请/签约主表及 PDF 文件运行时,合计 32 个动作由专用阻塞契约逐项返回原因。
 - Manager 修改密码已迁为 Angular 专用页面和 `changeOwnPassword`:验证当前口令、限制新密码 8–72 位、拒绝复用原密码,保存后撤销该管理员全部 Parse 会话并强制重新登录。旧 MarkDown 五个动作依赖未迁入的 MarkDown 主表、`wwwroot/UploadFiles/MarkDown` 本地目录和 `/PreView/md` 渲染页,均由 `legacyMarkdownBlocker` 返回数据或运行时阻塞。
+- User/Message 的五个站内信动作缺少 Message 收发主表;`MailTemp` 只是邮件模板,不被误用。Mobile 两个空控制器动作只剩已丢失的 Razor 视图,无法恢复页面基线。栏目 `ZhiDing` 的旧实现会把所有有效栏目强制扁平为根节点,现有 `saveNode/nodeBatch` 已安全维护层级并拒绝该破坏性修复;`UnionNode_Merge` 同时依赖未迁入的 `ZL_Commodities`,不能只改内容表形成半合并。以上均由专用云函数返回细分原因。
+- Office/OA 中字段完全匹配的 `MisType` 已迁为 Angular“OA 流程类型”页与 `misTypes/saveMisType/misTypeBatch`,支持帐套隔离、搜索、防重、原子旧 ID 和 `MisProcedure.typeId` 引用保护删除;用户选择与邮件通讯录复用现有用户管理,模型选择为部分迁移。其余旧 `OA_AuditFlow/OA_AuditFlowItem/Mis_Model/OA_Sign/OA_Document` 主表及套红文件运行时未迁入;目标空的 `MisProcedure/MisProLevel/MisSign/DocModel` 是不同版本结构,不被强行冒充,21 个动作由 `legacyOABlocker` 逐项说明。
 - 旧 `NodeAdd_Submit`、`Node_API` 与 `UnionNode_Move` 的核心栏目生命周期已迁为专用 `saveNode/nodeBatch`:新增栏目以临时来源键完成 Parse 初始写入,再使用 PostgreSQL 事务锁原子分配 `nodeId/orderId` 并替换为旧 `sourceKey`;保存会校验同级名称/目录重复、父栏目帐套和回收状态,移动会阻断自身/下级循环并递归修正所有后代深度。回收、恢复和每批 1–100 个栏目迁移已接入 Angular;永久删除仅在没有内容、下级栏目、节点权限和模型模板引用时开放,通用保存与删除不能绕过。
 - 旧 `SpecialAdd_Save` 与 `UnionSpecial_Move` 已迁为专用 `saveSpecial/specialBatch`:新增专题使用临时来源键和 PostgreSQL 事务锁原子分配 `specId/orderId`,保存时校验全帐套名称/目录唯一、父专题归属与层级循环;删除会拒绝仍有下级专题的记录。旧 `UnionSpecial_Merge` 被明确列为数据阻塞:`CommonModel.specialId` 只存在于 Parse Schema、PostgreSQL 物理列缺失,商品主表也未迁移,无法安全改写内容/商品专题关系。
 - 旧 `ModelManage/Field/Order_Submit` 的安全部分已迁为 `saveModelMetadata/saveModelFieldMetadata/modelFieldOrder`:现有模型可编辑名称、条目文案、图标、说明和内容模板元数据,现有字段可编辑别名、提示、显示/搜索配置,并可在同模型内批量排序;`modelId/modelType/tableName/fieldId/modelId/fieldName/fieldType/sourceKey` 等结构字段通用只读。新增、复制、删除模型或字段会显式返回 501,因为旧实现同时执行 PostgreSQL 建表/改列、标签生成和模板文件写入,目标托管环境没有该 DDL/旧模板运行时。

+ 36 - 2
scripts/deploy-admin-functions.mjs

@@ -15,7 +15,7 @@ const CLASS_LABELS = {
   GradeCate: '多级字典分类', Grade: '多级字典选项',
   Currency: '货币管理', SysHoliday: '节假日管理',
   Product: '商品', StoreProduct: '门店商品', StoreApplication: '门店申请', ShopFareTlp: '商城运费模板', ShopMoneyRegular: '金额规则', PayPlat: '支付平台',
-  GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科审核', Search: '后台快捷入口', AdZone: '广告位管理', AdInfo: '广告内容管理', FontPicShape: '字体图形素材', FontPicShapeType: '字体图形分类', DesignAsk: '问卷调查', DesignQuestion: '问卷题目', DesignAnswer: '问卷答卷', ServiceSeat: '客服席位', Temp: '客服欢迎语', StoreApplication: '店铺管理', StoreStyle: '店铺样式', ContentTagKey: '内容标签词库', UserLevel: '积分等级', UserMoneyLog: '资金积分流水', CRMSAttr: 'CRM 客户类型', SysLog: '系统日志', Agency: '代理机构', DeliveryCenter: '交付中心'
+  GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科审核', Search: '后台快捷入口', AdZone: '广告位管理', AdInfo: '广告内容管理', FontPicShape: '字体图形素材', FontPicShapeType: '字体图形分类', DesignAsk: '问卷调查', DesignQuestion: '问卷题目', DesignAnswer: '问卷答卷', ServiceSeat: '客服席位', Temp: '客服欢迎语', StoreApplication: '店铺管理', StoreStyle: '店铺样式', ContentTagKey: '内容标签词库', UserLevel: '积分等级', UserMoneyLog: '资金积分流水', CRMSAttr: 'CRM 客户类型', SysLog: '系统日志', MisType: 'OA 流程类型', Agency: '代理机构', DeliveryCenter: '交付中心'
 };
 const ALLOWED_CLASSES = new Set([
   'PageTemplate','PaperQuestions','PayPlat','Permission','PlatComp','App','DesignAnswer','DesignAsk','Attachment','DesignPage','DesignQuestion','DesignRes','Feedback','DesignScence','StudentAchieve','ContentArticle','DesignSiteInfo','Profile','AdInfo','AdZone','ARoleAuth','Baike','ExamSysQuestions','ContactInfo','VocabularyWord','AssessmentProfile','Agency','MemoryPracticeRecord','DeliveryCenter','CourseBinding','PracticeRecord','CourseAppointment','Account','SurveyItem','SurveyLog','LessonRecord','DailyStudyRecord','CommonModel','ContentPublish','Company','_Role','_User','CRMSAttr','Currency','Datadic','Datadiccategory','DesignTlp','DocModel','DocPermission','ExamClass','ExamSysPapers','ExamType','ExamPoint','ExTeacher','FontPicShape','FontPicShapeType','Grade','GradeCate','Group','GroupModel','GuestBar','Guestbook','Guestcate','MailTemp','Manager','MisProcedure','MisProLevel','MisSign','MisType','PageStyle','Model','ModelField','Node','NodeAuth','NodeModelTemplate','Product','PlatUserRole','Pub','PubTw','PubWTHD','PubZXDC','PublishNode','QuestionsKnowledge','Role','StoreProduct','SafeMobile','Search','SenTask','ServiceSeat','ShopFareTlp','ShopMoneyRegular','Special','StoreApplication','StoreStyle','SysCSSManage','SysHoliday','SysLog','Temp','ThirdPlatInfo','UserCredit','UserDummyPoint','UserFriendGroup','UserLevel','UserSIcon','UserUserPoint','UserExpDomP','UserExpHis'
@@ -57,7 +57,8 @@ const CLASS_SYSTEM_FIELDS = {
   StoreStyle: new Set(['id','cdate','zstatus','sourceKey']),
   UserLevel: new Set(['id','cdate','ztype','addon1','addon2','addon3','addon4','addon5','orderId','storeId','zstatus','sourceKey','discountRate']),
   CRMSAttr: new Set(['id','ztype','cdate','zstatus','value2','value3','sourceKey']),
-  ShopFareTlp: new Set(['id','cdate','userId','adminId','isFree','regionBan','ems','mail','sourceKey'])
+  ShopFareTlp: new Set(['id','cdate','userId','adminId','isFree','regionBan','ems','mail','sourceKey']),
+  MisType: new Set(['id','createTime','sourceKey'])
 };
 const USER_MONEY_LOG_CLASSES = { 1:'UserExpDomP', 2:'UserSIcon', 3:'UserExpHis', 4:'UserUserPoint', 5:'UserDummyPoint', 6:'UserCredit' };
 const USER_MONEY_LOG_LABELS = { 1:'余额', 2:'银币', 3:'经验', 4:'积分', 5:'虚拟币', 6:'信用分' };
@@ -1389,6 +1390,37 @@ async function handler(request, response) {
         Index: '目标 Schema 缺少旧 MarkDown 主表、MDName/MDPath/MDStatus/MDPosition 等历史记录', MD: '目标没有可新建或编辑的 MarkDown 记录', Index_submit: '目标缺少 MarkDown 持久层,当前 Parse 文件适配器也不能替代旧 wwwroot/UploadFiles/MarkDown 本地上传目录', Dels: '目标没有 Markdown 记录与对应服务器文件可同步删除', Preview: '目标缺少 MarkDown 文件、SiteInfo.SiteUrl 与旧 /PreView/md 渲染运行时'
       }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 Markdown 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
     }
+    if (operation === 'legacyMessageBlocker') {
+      const action = String(input.action || ''); const blockers = {
+        Message: '目标 Schema 缺少旧 Message 站内信主表与收发历史;MailTemp 仅是邮件模板,语义不等价', MessageSend: '目标没有可编辑的站内信草稿、收件人、抄送和附件记录', Message_Add: '目标缺少站内信发送与草稿持久层,不能保存 Sender/Incept/CCUser/Attachment', MessageRead: '目标没有站内信详情与已读状态记录', Message_API: '目标没有可删除的站内信记录'
+      }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧站内信动作'); fail(501, 'migration_blocked: ' + blockers[action]);
+    }
+    if (operation === 'legacyMobileBlocker') {
+      const action = String(input.action || ''); const blockers = {
+        Index: '旧动作只有已丢失的 Mobile Razor 视图,发布包没有页面结构、交互或资源基线可恢复', MobileBrower: '旧移动浏览器预览页只有已丢失的 Razor 视图,目标没有对应渲染运行时'
+      }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 Mobile 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
+    }
+    if (operation === 'legacyNodeRemainderBlocker') {
+      const action = String(input.action || ''); const blockers = {
+        ZhiDing: '旧动作会把帐套内全部 ZStatus=99 栏目强制设为 depth=1、parentId=0,破坏现有规范化栏目树;现有 saveNode/nodeBatch 已在每次移动时安全维护深度', UnionNode_Merge: '旧合并同时改写 CommonModel 与未迁入的 ZL_Commodities 商品节点,并可删除来源栏目;缺商品主表时不能执行非原子半合并'
+      }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧栏目剩余动作'); fail(501, 'migration_blocked: ' + blockers[action]);
+    }
+    if (operation === 'misTypes') {
+      const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const search = String(input.search || '').trim(); if (search.length > 100) fail(400, '流程类型搜索词不能超过 100 个字符'); const fields = await schemaFor('MisType'); const query = new Parse.Query('MisType'); applyTenant(query, fields, context, input.companyId); if (search) query.matches('typeName', escapeRegex(search), 'i'); query.ascending('id'); const total = await query.count({ useMasterKey: true }); query.skip((page - 1) * pageSize); query.limit(pageSize); const results = await query.find({ useMasterKey: true }); return response.json({ success: true, data: { className: 'MisType', page, pageSize, total, results: results.map(serializeObject) } });
+    }
+    if (operation === 'saveMisType') {
+      const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; const typeName = String(payload.typeName || '').trim(); const typeDescribe = String(payload.typeDescribe || '').trim(); if (!typeName || typeName.length > 100 || typeDescribe.length > 1000) fail(400, '流程类型名称为必填项且名称或说明过长'); let companyId = pointerId(context.company); if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId); if (!companyId) fail(400, '流程类型必须指定帐套'); const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true }); const fields = await schemaFor('MisType'); let target; let created = false; if (objectId) { const query = new Parse.Query('MisType'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true }); companyId = pointerId(target.get('company')) || companyId; } else { target = new Parse.Object('MisType'); target.set('sourceKey', 'cloud:mis-type:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10)); target.set('company', company); target.set('createTime', new Date()); created = true; }
+      const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "MisType" WHERE "company"=$1 AND "objectId"<>$2 AND LOWER(TRIM(COALESCE("typeName",\'\')))=LOWER($3) LIMIT 1', [companyId, objectId, typeName]); if (duplicate) fail(409, '同一帐套的流程类型名称不能重复'); target.set('typeName', typeName); target.set('typeDescribe', typeDescribe); try { await target.save(null, { useMasterKey: true }); if (created) { const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-mis-type-id:\'||$1))),next_id AS MATERIALIZED (SELECT COALESCE(MAX("id"),0)+1 AS id FROM "MisType",lock_row WHERE "company"=$1) UPDATE "MisType" SET "id"=next_id.id,"sourceKey"=\'[["ID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id]); if (!Number(rows[0] && rows[0].id)) throw new Error('无法分配流程类型编号'); } } catch (error) { if (created && target.id) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '流程类型保存失败: ' + String(error && error.message || error)); } await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || company }, objectId ? 'update-mis-type' : 'create-mis-type', 'MisType', target.id); return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'misTypeBatch') {
+      if (String(input.action || '') !== 'delete') fail(400, '不支持的流程类型批量操作'); const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个流程类型'); const fields = await schemaFor('MisType'); const query = new Parse.Query('MisType'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分流程类型不存在或不属于当前帐套'); for (const target of targets) { const refs = await Psql.one('SELECT COUNT(*)::int AS count FROM "MisProcedure" WHERE "company"=$1 AND COALESCE("typeId",0)=$2', [pointerId(target.get('company')), Number(target.get('id')) || 0]); if (Number(refs.count)) fail(409, '流程类型仍被 OA 流程引用,不能删除'); } await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'delete-mis-type', 'MisType', target.id); return response.json({ success: true, data: { action: 'delete', updated: targets.length } });
+    }
+    if (operation === 'legacyOABlocker') {
+      const action = String(input.action || ''); const blockers = {
+        Default: '目标缺少旧 OA_AuditFlow 审核流主表与历史数据', FlowInfo: '目标没有旧审核流名称、模型、模板、角色用户和会签配置记录', FlowInfo_Submit: '目标缺少 OA_AuditFlow 持久层,MisProcedure 是不同版本流程结构', Flow_API: '目标没有可删除的旧审核流记录', SelFlowModel: '目标缺少旧审核流可绑定的 Mis_Model 与套红模板关系', FlowItem: '目标缺少 OA_AuditFlowItem 审核步骤表', FlowItemInfo: '目标没有审核步骤、绑定用户、父步骤、会签、转交和退回记录', FlowItemInfo_Submit: '目标缺少审核步骤持久层', FlowItem_API: '目标没有可删除的审核步骤记录',
+        AddMisModel: '目标 DocModel 与旧 Mis_Model 的 ModelContent/BindNode/DocType/WordPath 套红结构不等价', AddMisModel_Submit: '目标缺少旧 Mis_Model 持久层和 FileGuid 套红模板文件运行时', AddSign: '目标 MisSign 是考勤时间表,不是 OA_Sign 签章模型', AddSign_Submit: '目标缺少 OA_Sign 图片、名称、口令、密钥、所属用户与创建人持久层,不能保存明文签章口令', AddSign_Delete: '目标没有可删除的 OA_Sign 签章记录', ApplyManage: '目标缺少 OA_Document 文档申请主表', ApplyManage_Delete: '目标没有可删除的 OA 文档申请记录', MisModelManage: '目标缺少旧 Mis_Model 套红文档模型数据', MisModelManage_Delete: '目标没有可删除的旧套红文档模型', OAConfig: '旧动作只有已丢失的 OAConfig Razor 视图,目标没有对应站点配置模型', SelModelFieds: '目标 Model 数据可只读查看,但旧按 ModelType 选择并回填字段的 OA Razor 交互未迁入', SignManage: '目标缺少 OA_Sign 签章列表;MisSign 是不等价的考勤时间表'
+      }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 OA 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
+    }
     if (operation === 'legacyDesignInteractiveBlocker') {
       const action = String(input.action || ''); const blockers = {
         InteractiveList: '目标 Schema 缺少旧 Design_Pub 交互提交表及 sceneid、uid、fname 数据;DesignAnswer 和 Pub 语义不等价',
@@ -1862,6 +1894,7 @@ async function handler(request, response) {
       if (className === 'UserLevel') fail(400, '积分等级必须走专用保存流程');
       if (className === 'CRMSAttr') fail(400, 'CRM 客户类型必须走专用保存流程');
       if (className === 'ShopFareTlp') fail(400, '商城运费模板必须走专用保存流程');
+      if (className === 'MisType') fail(400, 'OA 流程类型必须走专用保存流程');
       if (className === 'CommonModel' && !objectId) fail(400, '内容新增必须同时写入模型附表,不能走通用保存');
       let object;
       if (objectId) { const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); object = await query.get(objectId, { useMasterKey: true }); } else object = new Parse.Object(className);
@@ -1881,6 +1914,7 @@ async function handler(request, response) {
       if (className === 'StoreApplication' || className === 'StoreStyle') fail(400, '店铺与店铺样式必须走专用批量流程');
       if (className === 'CRMSAttr') fail(400, 'CRM 客户类型必须走专用批量流程');
       if (className === 'ShopFareTlp') fail(400, '商城运费模板必须走专用批量流程');
+      if (className === 'MisType') fail(400, 'OA 流程类型必须走专用批量流程');
       if (className === 'Guestcate') { const cateid = Number(object.get('cateid')) || 0; const companyId = pointerId(object.get('company')); const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "Guestcate" WHERE "company"=$1 AND "parentId"=$2) AS children,(SELECT COUNT(*)::int FROM "Guestbook" WHERE "company"=$1 AND "cateid"=$2) AS messages,(SELECT COUNT(*)::int FROM "GuestBar" WHERE "company"=$1 AND "cateId"=$2) AS posts', [companyId, cateid]); if (Number(refs.children) || Number(refs.messages) || Number(refs.posts)) fail(409, '分类仍被下级分类、留言或帖子引用,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-guest-category', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }
       if (className === 'ExamClass') { const classId = Number(object.get('cId')) || 0; const companyId = pointerId(object.get('company')); const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "ExamClass" WHERE "company"=$1 AND "cClassid"=$2) AS children,(SELECT COUNT(*)::int FROM "ExamSysQuestions" WHERE "company"=$1 AND "pClass"=$2) AS questions', [companyId, classId]); if (Number(refs.children) || Number(refs.questions)) fail(409, '试题分类仍被下级分类或试题引用,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-exam-class', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }
       if (className === 'ExamPoint') { const pointId = Number(object.get('id')) || 0; const companyId = pointerId(object.get('company')); const children = await Psql.one('SELECT COUNT(*)::int AS count FROM "ExamPoint" WHERE "company"=$1 AND "tid"=$2', [companyId, pointId]); if (Number(children.count)) fail(409, '考点仍有下级考点,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-exam-point', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }

+ 36 - 0
scripts/generate-admin-coverage.mjs

@@ -176,6 +176,42 @@ const explicit = new Map([
   ['Content / MarkDown/Index_submit', ['blocked-infrastructure', '目标缺少 MarkDown 持久层且 Parse 文件适配器不能替代旧本地上传目录']],
   ['Content / MarkDown/Dels', ['blocked-data', '目标没有 Markdown 记录与对应服务器文件可同步删除']],
   ['Content / MarkDown/Preview', ['blocked-infrastructure', '目标缺少 Markdown 文件、SiteInfo.SiteUrl 与旧 /PreView/md 渲染运行时']],
+  ['User / Message/Message', ['blocked-data', '目标缺少旧 Message 站内信主表与收发历史;MailTemp 仅是邮件模板']],
+  ['User / Message/MessageSend', ['blocked-data', '目标没有可编辑的站内信草稿、收件人、抄送和附件记录']],
+  ['User / Message/Message_Add', ['blocked-data', '目标缺少站内信发送与草稿持久层']],
+  ['User / Message/MessageRead', ['blocked-data', '目标没有站内信详情与已读状态记录']],
+  ['User / Message/Message_API', ['blocked-data', '目标没有可删除的站内信记录']],
+  ['Mobile/Index', ['blocked-infrastructure', '旧动作只有已丢失的 Mobile Razor 视图,没有页面结构、交互或资源基线']],
+  ['Mobile/MobileBrower', ['blocked-infrastructure', '旧移动浏览器预览页只有已丢失的 Razor 视图,目标没有对应渲染运行时']],
+  ['Content / Node/ZhiDing', ['partial', 'saveNode/nodeBatch 已在移动时安全维护层级;拒绝旧版将全部有效栏目强制扁平为根节点的破坏性修复']],
+  ['Content / Node/UnionNode_Merge', ['blocked-data', '旧合并同时改写 CommonModel 与未迁入的 ZL_Commodities,缺商品主表时不能执行半合并']],
+  ['Office / OA/Default', ['blocked-data', '目标缺少旧 OA_AuditFlow 审核流主表与历史数据']],
+  ['Office / OA/FlowInfo', ['blocked-data', '目标没有旧审核流名称、模型、模板、角色用户和会签配置']],
+  ['Office / OA/FlowInfo_Submit', ['blocked-data', '目标缺少 OA_AuditFlow 持久层;MisProcedure 是不同版本结构']],
+  ['Office / OA/Flow_API', ['blocked-data', '目标没有可删除的旧审核流记录']],
+  ['Office / OA/SelFlowModel', ['blocked-data', '目标缺少审核流可绑定的旧 Mis_Model 与套红模板关系']],
+  ['Office / OA/FlowItem', ['blocked-data', '目标缺少 OA_AuditFlowItem 审核步骤表']],
+  ['Office / OA/FlowItemInfo', ['blocked-data', '目标没有审核步骤、绑定用户、父步骤、会签、转交和退回记录']],
+  ['Office / OA/FlowItemInfo_Submit', ['blocked-data', '目标缺少审核步骤持久层']],
+  ['Office / OA/FlowItem_API', ['blocked-data', '目标没有可删除的审核步骤记录']],
+  ['Office / OA/SelUserModel', ['implemented', '已由帐套隔离的 Angular 用户管理列表和账号搜索替代']],
+  ['Office / OA/AddMisModel', ['blocked-data', 'DocModel 与旧 Mis_Model 的 ModelContent/BindNode/DocType/WordPath 不等价']],
+  ['Office / OA/AddMisModel_Submit', ['blocked-infrastructure', '目标缺少旧 Mis_Model 持久层和 FileGuid 套红模板文件运行时']],
+  ['Office / OA/AddSign', ['blocked-data', '目标 MisSign 是考勤时间表,不是 OA_Sign 签章模型']],
+  ['Office / OA/AddSign_Submit', ['blocked-data', '目标缺少 OA_Sign 图片、名称、口令、密钥、所属用户与创建人持久层']],
+  ['Office / OA/AddSign_Delete', ['blocked-data', '目标没有可删除的 OA_Sign 签章记录']],
+  ['Office / OA/AddWorkFlowType', ['implemented', '已由 Angular OA 流程类型新建/编辑抽屉替代']],
+  ['Office / OA/AddWorkFlowType_Submit', ['implemented', 'saveMisType 校验名称/说明、防重并原子分配旧数字 ID']],
+  ['Office / OA/AddWorkFlowType_Delete', ['implemented', 'misTypeBatch 提供帐套隔离和流程引用保护删除']],
+  ['Office / OA/ApplyManage', ['blocked-data', '目标缺少 OA_Document 文档申请主表']],
+  ['Office / OA/ApplyManage_Delete', ['blocked-data', '目标没有可删除的 OA 文档申请记录']],
+  ['Office / OA/FlowTypeList', ['implemented', 'misTypes 提供帐套隔离、名称搜索和分页列表']],
+  ['Office / OA/MailManage', ['implemented', '已由帐套隔离的 Angular 用户通讯录列表替代']],
+  ['Office / OA/MisModelManage', ['blocked-data', '目标缺少旧 Mis_Model 套红文档模型数据']],
+  ['Office / OA/MisModelManage_Delete', ['blocked-data', '目标没有可删除的旧套红文档模型']],
+  ['Office / OA/OAConfig', ['blocked-infrastructure', '旧动作只有已丢失的 OAConfig Razor 视图,目标没有对应配置模型']],
+  ['Office / OA/SelModelFieds', ['partial', '目标 Model 数据可只读查看;旧按 ModelType 回填字段的 OA Razor 交互未迁入']],
+  ['Office / OA/SignManage', ['blocked-data', '目标缺少 OA_Sign 签章列表;MisSign 是不等价的考勤时间表']],
   ['Design / Interactive/InteractiveList', ['blocked-data', '目标缺少旧 Design_Pub 交互提交表及 sceneid、uid、fname 数据;DesignAnswer/Pub 不等价']],
   ['Design / Interactive/Interactive_API', ['blocked-data', '目标没有可按旧 Design_Pub ID 删除的交互提交记录']],
   ['Office / iServer/Default', ['blocked-data', '目标 Schema 缺少旧 IServer 服务工单主表与回复表']],

+ 18 - 0
scripts/smoke-admin-functions.mjs

@@ -119,6 +119,7 @@ let temporaryUserLevelId = '';
 let baikeRestore = null;
 let temporaryCrmClientTypeId = '';
 let temporaryShopFareTemplateId = '';
+let temporaryMisTypeId = '';
 const temporaryLessonIds = [];
 try {
   const sample = await jsonRequest(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
@@ -666,6 +667,22 @@ try {
   for (const action of ['Index','MD','Index_submit','Dels','Preview']) {
     const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyMarkdownBlocker', action }, 501); if (!String(blocked.message).includes('migration_blocked')) throw new Error(`旧 Markdown 动作 ${action} 未返回明确迁移阻塞`);
   }
+  for (const action of ['Message','MessageSend','Message_Add','MessageRead','Message_API']) {
+    const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyMessageBlocker', action }, 501); if (!String(blocked.message).includes('migration_blocked')) throw new Error(`旧站内信动作 ${action} 未返回明确迁移阻塞`);
+  }
+  for (const action of ['Index','MobileBrower']) {
+    const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyMobileBlocker', action }, 501); if (!String(blocked.message).includes('migration_blocked')) throw new Error(`旧 Mobile 动作 ${action} 未返回明确迁移阻塞`);
+  }
+  for (const action of ['ZhiDing','UnionNode_Merge']) {
+    const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyNodeRemainderBlocker', action }, 501); if (!String(blocked.message).includes('migration_blocked')) throw new Error(`旧栏目剩余动作 ${action} 未返回明确迁移阻塞`);
+  }
+  const misTypeSuffix = Date.now().toString(36); const createdMisType = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveMisType', className: 'MisType', fields: { typeName: `冒烟流程类型-${misTypeSuffix}`, typeDescribe: '临时 OA 流程类型' } }); temporaryMisTypeId = String(createdMisType.objectId || ''); if (!temporaryMisTypeId || !Number(createdMisType.id) || createdMisType.sourceKey !== `[["ID",${Number(createdMisType.id)}]]`) throw new Error(`OA 流程类型原子编号创建异常:${JSON.stringify(createdMisType)}`);
+  const misTypeList = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'misTypes', className: 'MisType', page: 1, pageSize: 20, search: misTypeSuffix }); if (misTypeList.total !== 1 || misTypeList.results?.[0]?.objectId !== temporaryMisTypeId) throw new Error('OA 流程类型专用列表失败');
+  const updatedMisType = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveMisType', className: 'MisType', objectId: temporaryMisTypeId, fields: { typeName: `更新流程类型-${misTypeSuffix}`, typeDescribe: '已更新' } }); if (updatedMisType.typeName !== `更新流程类型-${misTypeSuffix}` || Number(updatedMisType.id) !== Number(createdMisType.id)) throw new Error('OA 流程类型专用编辑失败');
+  const deletedMisType = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'misTypeBatch', className: 'MisType', action: 'delete', objectIds: [temporaryMisTypeId] }); if (deletedMisType.updated !== 1) throw new Error('OA 流程类型专用删除失败'); temporaryMisTypeId = '';
+  for (const action of ['Default','FlowInfo','FlowInfo_Submit','Flow_API','SelFlowModel','FlowItem','FlowItemInfo','FlowItemInfo_Submit','FlowItem_API','AddMisModel','AddMisModel_Submit','AddSign','AddSign_Submit','AddSign_Delete','ApplyManage','ApplyManage_Delete','MisModelManage','MisModelManage_Delete','OAConfig','SelModelFieds','SignManage']) {
+    const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyOABlocker', action }, 501); if (!String(blocked.message).includes('migration_blocked')) throw new Error(`旧 OA 动作 ${action} 未返回明确迁移阻塞`);
+  }
   for (const action of ['InteractiveList','Interactive_API']) {
     const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyDesignInteractiveBlocker', action }, 501); if (!String(blocked.message).includes('migration_blocked')) throw new Error(`旧互动提交动作 ${action} 未返回明确迁移阻塞`);
   }
@@ -900,6 +917,7 @@ try {
   if (temporaryUserLevelId) await jsonRequest(`${PARSE_URL}/classes/UserLevel/${temporaryUserLevelId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时积分等级清理失败:${error.message}`); process.exitCode = 1; });
   if (temporaryCrmClientTypeId) await jsonRequest(`${PARSE_URL}/classes/CRMSAttr/${temporaryCrmClientTypeId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时 CRM 客户类型清理失败:${error.message}`); process.exitCode = 1; });
   if (temporaryShopFareTemplateId) await jsonRequest(`${PARSE_URL}/classes/ShopFareTlp/${temporaryShopFareTemplateId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时商城运费模板清理失败:${error.message}`); process.exitCode = 1; });
+  if (temporaryMisTypeId) await jsonRequest(`${PARSE_URL}/classes/MisType/${temporaryMisTypeId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时 OA 流程类型清理失败:${error.message}`); process.exitCode = 1; });
   if (baikeRestore) await jsonRequest(`${PARSE_URL}/classes/Baike/${baikeRestore.objectId}`, { method: 'PUT', body: JSON.stringify(baikeRestore.fields) }, true).catch((error) => { console.error(`百科冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
   if (contentHitObjectId) await jsonRequest(`${PARSE_URL}/classes/CommonModel/${contentHitObjectId}`, { method: 'PUT', body: JSON.stringify({ hits: contentHitOriginal }) }, true).catch((error) => {
     console.error(`公开内容浏览量恢复失败:${error.message}`);

+ 1 - 0
src/app/admin/admin.constants.ts

@@ -52,6 +52,7 @@ export const ADMIN_NAVIGATION = [
   { label: 'CRM 客户类型', icon: 'contact-round', route: '/admin/resources/CRMSAttr' },
   { label: '商城运费模板', icon: 'truck', route: '/admin/resources/ShopFareTlp' },
   { label: '系统日志', icon: 'scroll-text', route: '/admin/resources/SysLog' },
+  { label: 'OA 流程类型', icon: 'workflow', route: '/admin/resources/MisType' },
   { label: '百科审核', icon: 'book-marked', route: '/admin/resources/Baike' },
   { label: '题库管理', icon: 'list-checks', route: '/admin/resources/ExamSysQuestions' },
   { label: '考试试卷', icon: 'file-check-2', route: '/admin/resources/ExamSysPapers' },

+ 2 - 1
src/app/admin/layout/admin-shell.component.ts

@@ -39,6 +39,7 @@ import {
   Tags,
   Users,
   WalletCards,
+  Workflow,
 } from 'lucide-angular';
 import { ADMIN_NAVIGATION } from '../admin.constants';
 import { AdminSessionService } from '../admin-session.service';
@@ -58,7 +59,7 @@ export class AdminShellComponent {
   readonly icons = {
     BadgeCent, Bell, BookMarked, BookOpen, Braces, CalendarDays, ChartBarBig, ChartNoAxesCombined, ChevronLeft, ChevronRight,
     ClipboardCheck, ClipboardList, Files, GalleryHorizontalEnd, GraduationCap, Headphones, KeyRound, Languages, LayoutDashboard, ListChecks, ListTodo, ScrollText,
-    LayoutTemplate, LogOut, Megaphone, Menu, MessageSquareText, Palette, Route, Search, Shapes, Store, TableProperties, Tags, Users, WalletCards,
+    LayoutTemplate, LogOut, Megaphone, Menu, MessageSquareText, Palette, Route, Search, Shapes, Store, TableProperties, Tags, Users, WalletCards, Workflow,
   };
 
   constructor(readonly sessions: AdminSessionService) {}

+ 72 - 72
src/app/admin/legacy-admin-coverage.generated.ts

@@ -3338,50 +3338,50 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "User / Message",
     "action": "Message",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少旧 Message 站内信主表与收发历史;MailTemp 仅是邮件模板"
   },
   {
     "module": "User / Message",
     "action": "MessageSend",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可编辑的站内信草稿、收件人、抄送和附件记录"
   },
   {
     "module": "User / Message",
     "action": "Message_Add",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少站内信发送与草稿持久层"
   },
   {
     "module": "User / Message",
     "action": "MessageRead",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有站内信详情与已读状态记录"
   },
   {
     "module": "User / Message",
     "action": "Message_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可删除的站内信记录"
   },
   {
     "module": "Mobile",
     "action": "Index",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "旧动作只有已丢失的 Mobile Razor 视图,没有页面结构、交互或资源基线"
   },
   {
     "module": "Mobile",
     "action": "MobileBrower",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "旧移动浏览器预览页只有已丢失的 Razor 视图,目标没有对应渲染运行时"
   },
   {
     "module": "Model",
@@ -3513,8 +3513,8 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Content / Node",
     "action": "ZhiDing",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "saveNode/nodeBatch 已在移动时安全维护层级;拒绝旧版将全部有效栏目强制扁平为根节点的破坏性修复"
   },
   {
     "module": "Content / Node",
@@ -3597,8 +3597,8 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Content / Node",
     "action": "UnionNode_Merge",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "旧合并同时改写 CommonModel 与未迁入的 ZL_Commodities,缺商品主表时不能执行半合并"
   },
   {
     "module": "Content / Node",
@@ -3660,190 +3660,190 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Office / OA",
     "action": "Default",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少旧 OA_AuditFlow 审核流主表与历史数据"
   },
   {
     "module": "Office / OA",
     "action": "FlowInfo",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有旧审核流名称、模型、模板、角色用户和会签配置"
   },
   {
     "module": "Office / OA",
     "action": "FlowInfo_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少 OA_AuditFlow 持久层;MisProcedure 是不同版本结构"
   },
   {
     "module": "Office / OA",
     "action": "Flow_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可删除的旧审核流记录"
   },
   {
     "module": "Office / OA",
     "action": "SelFlowModel",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少审核流可绑定的旧 Mis_Model 与套红模板关系"
   },
   {
     "module": "Office / OA",
     "action": "FlowItem",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少 OA_AuditFlowItem 审核步骤表"
   },
   {
     "module": "Office / OA",
     "action": "FlowItemInfo",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有审核步骤、绑定用户、父步骤、会签、转交和退回记录"
   },
   {
     "module": "Office / OA",
     "action": "FlowItemInfo_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少审核步骤持久层"
   },
   {
     "module": "Office / OA",
     "action": "FlowItem_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可删除的审核步骤记录"
   },
   {
     "module": "Office / OA",
     "action": "SelUserModel",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由帐套隔离的 Angular 用户管理列表和账号搜索替代"
   },
   {
     "module": "Office / OA",
     "action": "AddMisModel",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "DocModel 与旧 Mis_Model 的 ModelContent/BindNode/DocType/WordPath 不等价"
   },
   {
     "module": "Office / OA",
     "action": "AddMisModel_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标缺少旧 Mis_Model 持久层和 FileGuid 套红模板文件运行时"
   },
   {
     "module": "Office / OA",
     "action": "AddSign",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 MisSign 是考勤时间表,不是 OA_Sign 签章模型"
   },
   {
     "module": "Office / OA",
     "action": "AddSign_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少 OA_Sign 图片、名称、口令、密钥、所属用户与创建人持久层"
   },
   {
     "module": "Office / OA",
     "action": "AddSign_Delete",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可删除的 OA_Sign 签章记录"
   },
   {
     "module": "Office / OA",
     "action": "AddWorkFlowType",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 Angular OA 流程类型新建/编辑抽屉替代"
   },
   {
     "module": "Office / OA",
     "action": "AddWorkFlowType_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "saveMisType 校验名称/说明、防重并原子分配旧数字 ID"
   },
   {
     "module": "Office / OA",
     "action": "AddWorkFlowType_Delete",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "misTypeBatch 提供帐套隔离和流程引用保护删除"
   },
   {
     "module": "Office / OA",
     "action": "ApplyManage",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少 OA_Document 文档申请主表"
   },
   {
     "module": "Office / OA",
     "action": "ApplyManage_Delete",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可删除的 OA 文档申请记录"
   },
   {
     "module": "Office / OA",
     "action": "FlowTypeList",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "misTypes 提供帐套隔离、名称搜索和分页列表"
   },
   {
     "module": "Office / OA",
     "action": "MailManage",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由帐套隔离的 Angular 用户通讯录列表替代"
   },
   {
     "module": "Office / OA",
     "action": "MisModelManage",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少旧 Mis_Model 套红文档模型数据"
   },
   {
     "module": "Office / OA",
     "action": "MisModelManage_Delete",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可删除的旧套红文档模型"
   },
   {
     "module": "Office / OA",
     "action": "OAConfig",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "旧动作只有已丢失的 OAConfig Razor 视图,目标没有对应配置模型"
   },
   {
     "module": "Office / OA",
     "action": "SelModelFieds",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "目标 Model 数据可只读查看;旧按 ModelType 回填字段的 OA Razor 交互未迁入"
   },
   {
     "module": "Office / OA",
     "action": "SignManage",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少 OA_Sign 签章列表;MisSign 是不等价的考勤时间表"
   },
   {
     "module": "Order",

+ 5 - 0
src/app/admin/pages/admin-resource.component.html

@@ -92,6 +92,8 @@
         <button class="danger-action" type="button" [disabled]="updatingCrmType()" (click)="applyBulkCrmClientTypeDelete()">删除客户类型</button>
       } @else if (className() === 'ShopFareTlp') {
         <button class="danger-action" type="button" [disabled]="updatingShopFare()" (click)="applyBulkShopFareDelete()">删除运费模板</button>
+      } @else if (className() === 'MisType') {
+        <button class="danger-action" type="button" [disabled]="updatingMisType()" (click)="applyBulkMisTypeDelete()">删除流程类型</button>
       } @else if (className() === 'Baike') {
         <button type="button" [disabled]="updatingBaike()" (click)="applyBulkBaikeAction('audit')">审核</button><button type="button" [disabled]="updatingBaike()" (click)="applyBulkBaikeAction('unaudit')">取消审核</button><button type="button" [disabled]="updatingBaike()" (click)="applyBulkBaikeAction('elite')">推荐</button><button type="button" [disabled]="updatingBaike()" (click)="applyBulkBaikeAction('unelite')">取消推荐</button>
       } @else {
@@ -168,6 +170,9 @@
         @if (className() === 'ShopFareTlp') {
           <section class="account-create-note"><strong>商城运费模板</strong><span>模板名称、计价方式、运费规则 JSON 与两项备注由专用云函数校验;旧数字 ID 在帐套内原子分配。DeliveryCenter 是不同业务,不参与本页。</span></section>
         }
+        @if (className() === 'MisType') {
+          <section class="account-create-note"><strong>OA 流程类型</strong><span>名称和说明由专用云函数校验,旧数字 ID 在帐套内原子分配;仍被流程引用的类型不能删除。</span></section>
+        }
         @if (['ExamSysPapers', 'ExamSysQuestions', 'PaperQuestions', 'ExamType', 'ContentPublish', 'PublishNode'].includes(className())) {
           <section class="account-create-note"><strong>考试与报刊历史数据只读</strong><span>已保留帐套隔离列表、搜索和详情;试卷题目关系、答案解析、发布父子结构与旧数字 ID 不能通过通用表单修改,等待对应专用写入链完成。</span></section>
         }

+ 14 - 0
src/app/admin/pages/admin-resource.component.spec.ts

@@ -1024,4 +1024,18 @@ describe('AdminResourceComponent', () => {
 
     expect(functions.admin).toHaveBeenCalledWith('systemLogs', { className: 'SysLog', page: 1, pageSize: 20, search: '管理员', type1: '后台', type2: '登录', startDate: '2026-08-01', endDate: '2026-08-19' });
   });
+
+  it('loads and saves OA workflow types through dedicated operations', async () => {
+    component.className.set('MisType'); component.schema.set({ className: 'MisType', label: 'OA 流程类型', writable: true, creatable: true, supportsSoftDelete: false, fields: [] }); component.search.set('公文'); functions.admin.and.callFake(async (operation: string) => operation === 'saveMisType' ? { objectId: 'mis-type', id: 1 } : { className: 'MisType', page: 1, pageSize: 20, total: 1, results: [] });
+    await component.load(1);
+    expect(functions.admin.calls.first().args).toEqual(['misTypes', { className: 'MisType', page: 1, pageSize: 20, search: '公文' }]);
+    functions.admin.calls.reset(); component.draft.set({ typeName: '公文流程', typeDescribe: '正式公文' }); await component.save();
+    expect(functions.admin.calls.first().args).toEqual(['saveMisType', { className: 'MisType', objectId: undefined, fields: { typeName: '公文流程', typeDescribe: '正式公文' } }]);
+  });
+
+  it('deletes selected OA workflow types through the protected batch operation', async () => {
+    spyOn(window, 'confirm').and.returnValue(true); component.className.set('MisType'); component.selectedIds.set(['type-a', 'type-b']); functions.admin.and.callFake(async (operation: string) => operation === 'misTypeBatch' ? { action: 'delete', updated: 2 } : { className: 'MisType', page: 1, pageSize: 20, total: 0, results: [] });
+    await component.applyBulkMisTypeDelete();
+    expect(functions.admin.calls.first().args).toEqual(['misTypeBatch', { className: 'MisType', action: 'delete', objectIds: ['type-a', 'type-b'] }]); expect(component.bulkMessage()).toContain('2 个 OA 流程类型');
+  });
 });

+ 18 - 4
src/app/admin/pages/admin-resource.component.ts

@@ -147,18 +147,19 @@ export class AdminResourceComponent implements OnInit {
   readonly filterSystemLogType2 = signal('');
   readonly filterSystemLogStartDate = signal('');
   readonly filterSystemLogEndDate = signal('');
+  readonly updatingMisType = signal(false);
   readonly selectedIds = signal<string[]>([]);
   readonly bulkError = signal('');
   readonly bulkMessage = signal('');
   readonly icons = { ChevronLeft, ChevronRight, Eye, KeyRound, LoaderCircle, Pencil, Plus, RefreshCw, Search, Trash2, X };
   readonly columns = computed(() => {
     const fields = this.schema()?.fields ?? [];
-    const preferred = ['objectId', 'name', 'title', 'tlpName', 'pName', 'pTitle', 'questionTitle', 'tName', 'newsName', 'nodeName', 'value', 'remark', 'qtitle', 'sName', 'storeName', 'styleName', 'count', 'content', 'str1', 'categoryname', 'dicname', 'cateName', 'gradeName', 'cClassName', 'testPoint', 'kName', 'tname', 'username', 'userName', 'realName', 'cname', 'type1', 'type2', 'detail', 'cdate', 'sRemrk', 'askId', 'zoneId', 'typeId', 'shape', 'qtype', 'required', 'ztype', 'priority', 'zstatus', 'storeState', 'storeCommendState', 'storeStyleId', 'sDefault', 'sIndex', 'str4', 'sdate', 'edate', 'fileUrl', 'type', 'state', 'eliteLevel', 'orderId', 'mobile', 'sourceKey', 'status', 'createdAt', 'updatedAt'];
+    const preferred = ['objectId', 'name', 'title', 'typeName', 'tlpName', 'pName', 'pTitle', 'questionTitle', 'tName', 'newsName', 'nodeName', 'value', 'remark', 'qtitle', 'sName', 'storeName', 'styleName', 'count', 'content', 'str1', 'categoryname', 'dicname', 'cateName', 'gradeName', 'cClassName', 'testPoint', 'kName', 'tname', 'username', 'userName', 'realName', 'cname', 'type1', 'type2', 'detail', 'cdate', 'sRemrk', 'askId', 'zoneId', 'typeId', 'shape', 'qtype', 'required', 'ztype', 'priority', 'zstatus', 'storeState', 'storeCommendState', 'storeStyleId', 'sDefault', 'sIndex', 'str4', 'sdate', 'edate', 'fileUrl', 'type', 'state', 'eliteLevel', 'orderId', 'mobile', 'sourceKey', 'status', 'createdAt', 'updatedAt'];
     const rank = (name: string): number => { const index = preferred.indexOf(name); return index < 0 ? preferred.length : index; };
     return fields.map((field, index) => ({ field, index })).sort((a, b) => rank(a.field.name) - rank(b.field.name) || a.index - b.index).map(({ field }) => field).slice(0, 7);
   });
   readonly pageCount = computed(() => Math.max(1, Math.ceil((this.page()?.total ?? 0) / (this.page()?.pageSize || 20))));
-  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency', 'SysHoliday', 'Search', 'AdZone', 'AdInfo', 'FontPicShape', 'FontPicShapeType', 'DesignAsk', 'DesignQuestion', 'DesignAnswer', 'ServiceSeat', 'Temp', 'StoreApplication', 'StoreStyle', 'UserLevel', 'Baike', 'CRMSAttr', 'ShopFareTlp'].includes(this.className()));
+  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency', 'SysHoliday', 'Search', 'AdZone', 'AdInfo', 'FontPicShape', 'FontPicShapeType', 'DesignAsk', 'DesignQuestion', 'DesignAnswer', 'ServiceSeat', 'Temp', 'StoreApplication', 'StoreStyle', 'UserLevel', 'Baike', 'CRMSAttr', 'ShopFareTlp', 'MisType'].includes(this.className()));
   readonly allPageSelected = computed(() => {
     const ids = (this.page()?.results || []).map((row) => String(row['objectId'] || '')).filter(Boolean);
     return Boolean(ids.length) && ids.every((id) => this.selectedIds().includes(id));
@@ -195,7 +196,7 @@ export class AdminResourceComponent implements OnInit {
       if (this.className() === 'FontPicShape' && reloadSchema) await this.loadFontShapeTypes();
       if (['DesignQuestion', 'DesignAnswer'].includes(this.className()) && reloadSchema) { await this.loadSurveys(); if (!this.filterSurveyId() && this.surveys().length) this.filterSurveyId.set(this.surveyIdOf(this.surveys()[0])); }
       if (this.className() === 'StoreApplication' && reloadSchema) await this.loadStoreStyles();
-      const pageOperation = this.className() === 'Grade' && this.filterGradeCategoryId() > 0 ? 'gradeOptions' : this.className() === 'Search' ? 'searchNavigations' : this.className() === 'AdZone' ? 'adZones' : this.className() === 'AdInfo' ? 'adInfos' : this.className() === 'FontPicShape' ? 'fontShapes' : this.className() === 'FontPicShapeType' ? 'fontShapeTypes' : this.className() === 'DesignAsk' ? 'surveys' : this.className() === 'DesignQuestion' ? 'surveyQuestions' : this.className() === 'DesignAnswer' ? 'surveyResults' : this.className() === 'ServiceSeat' ? 'serviceSeats' : this.className() === 'Temp' ? 'serviceCodes' : this.className() === 'StoreApplication' ? 'storeApplications' : this.className() === 'StoreStyle' ? 'storeStyles' : this.className() === 'ContentTagKey' ? 'contentTagKeys' : this.className() === 'UserLevel' ? 'userLevels' : this.className() === 'UserMoneyLog' ? 'userMoneyLogs' : this.className() === 'Baike' ? 'baikeEntries' : this.className() === 'CRMSAttr' ? 'crmClientTypes' : this.className() === 'ShopFareTlp' ? 'shopFareTemplates' : this.className() === 'SysLog' ? 'systemLogs' : 'list';
+      const pageOperation = this.className() === 'Grade' && this.filterGradeCategoryId() > 0 ? 'gradeOptions' : this.className() === 'Search' ? 'searchNavigations' : this.className() === 'AdZone' ? 'adZones' : this.className() === 'AdInfo' ? 'adInfos' : this.className() === 'FontPicShape' ? 'fontShapes' : this.className() === 'FontPicShapeType' ? 'fontShapeTypes' : this.className() === 'DesignAsk' ? 'surveys' : this.className() === 'DesignQuestion' ? 'surveyQuestions' : this.className() === 'DesignAnswer' ? 'surveyResults' : this.className() === 'ServiceSeat' ? 'serviceSeats' : this.className() === 'Temp' ? 'serviceCodes' : this.className() === 'StoreApplication' ? 'storeApplications' : this.className() === 'StoreStyle' ? 'storeStyles' : this.className() === 'ContentTagKey' ? 'contentTagKeys' : this.className() === 'UserLevel' ? 'userLevels' : this.className() === 'UserMoneyLog' ? 'userMoneyLogs' : this.className() === 'Baike' ? 'baikeEntries' : this.className() === 'CRMSAttr' ? 'crmClientTypes' : this.className() === 'ShopFareTlp' ? 'shopFareTemplates' : this.className() === 'SysLog' ? 'systemLogs' : this.className() === 'MisType' ? 'misTypes' : 'list';
       const pagePayload: Record<string, unknown> = { className: this.className(), page: pageNumber, pageSize: 20, search: this.search().trim() };
       if (pageOperation === 'gradeOptions') { pagePayload['categoryId'] = this.filterGradeCategoryId(); pagePayload['parentId'] = this.filterGradeParentId(); }
       if (pageOperation === 'searchNavigations') { pagePayload['type'] = this.filterSearchNavigationType(); pagePayload['state'] = this.filterSearchNavigationState(); pagePayload['elite'] = this.filterSearchNavigationElite(); }
@@ -291,7 +292,8 @@ export class AdminResourceComponent implements OnInit {
                                                               : this.className() === 'ContentTagKey' ? 'saveContentTagKeys'
                                                                 : this.className() === 'UserLevel' ? 'saveUserLevel'
                                                                   : this.className() === 'CRMSAttr' ? 'saveCrmClientType'
-                                                                    : this.className() === 'ShopFareTlp' ? 'saveShopFareTemplate' : 'save';
+                                                                    : this.className() === 'ShopFareTlp' ? 'saveShopFareTemplate'
+                                                                      : this.className() === 'MisType' ? 'saveMisType' : 'save';
       const payload: Record<string, unknown> = { className: this.className(), objectId: this.draftId() || undefined, fields: this.draft() };
       if (this.className() === 'Node') payload['parentId'] = this.targetNodeParentId();
       if (this.className() === 'Special') payload['pid'] = this.targetSpecialParentId();
@@ -759,6 +761,15 @@ export class AdminResourceComponent implements OnInit {
     catch (error) { this.bulkError.set(error instanceof Error ? error.message : '运费模板删除失败'); }
     finally { this.updatingShopFare.set(false); }
   }
+  async applyBulkMisTypeDelete(): Promise<void> {
+    const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (this.className() !== 'MisType' || !objectIds.length) { this.bulkError.set('请先选择 OA 流程类型'); return; }
+    if (!confirm(`确认删除选中的 ${objectIds.length} 个 OA 流程类型?`)) return;
+    this.updatingMisType.set(true);
+    try { const result = await this.functions.admin<{ updated: number }>('misTypeBatch', { className: 'MisType', action: 'delete', objectIds }); await this.load(); this.bulkMessage.set(`已删除 ${result.updated} 个 OA 流程类型`); }
+    catch (error) { this.bulkError.set(error instanceof Error ? error.message : 'OA 流程类型删除失败'); }
+    finally { this.updatingMisType.set(false); }
+  }
   async applyBulkBaikeAction(action: 'audit' | 'unaudit' | 'elite' | 'unelite'): Promise<void> {
     const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
     if (this.className() !== 'Baike' || !objectIds.length) { this.bulkError.set('请先选择百科词条'); return; }
@@ -781,6 +792,9 @@ export class AdminResourceComponent implements OnInit {
       else if (['ServiceSeat', 'Temp'].includes(this.className())) await this.functions.admin('serviceBatch', { className: this.className(), action: 'delete', objectIds: [objectId] });
       else if (['StoreApplication', 'StoreStyle'].includes(this.className())) await this.functions.admin('storeBatch', { className: this.className(), action: 'delete', objectIds: [objectId] });
       else if (this.className() === 'UserLevel') await this.functions.admin('userLevelBatch', { className: 'UserLevel', action: 'delete', objectIds: [objectId] });
+      else if (this.className() === 'CRMSAttr') await this.functions.admin('crmClientTypeBatch', { className: 'CRMSAttr', action: 'delete', objectIds: [objectId] });
+      else if (this.className() === 'ShopFareTlp') await this.functions.admin('shopFareTemplateBatch', { className: 'ShopFareTlp', action: 'delete', objectIds: [objectId] });
+      else if (this.className() === 'MisType') await this.functions.admin('misTypeBatch', { className: 'MisType', action: 'delete', objectIds: [objectId] });
       else await this.functions.admin('delete', { className: this.className(), objectId });
       await this.load();
     }