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

feat: migrate CRM customer types

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

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

@@ -24,6 +24,7 @@
 - 旧 `ContentManage_Status`、`ContentManage_Del`、回收站恢复与 `ContentMove_Submit` 已迁为专用 `contentBatch`:支持每批 1–100 条内容、限定旧状态 `-3/-2/-1/0/99`、校验目标节点帐套并保留 addon 关联。`CommonModel` 的 `generalId/modelId/nodeId/itemId/tableName/status/sourceKey/orderId` 改为通用只读;新增按钮关闭,避免只写主表却遗漏模型附表。
 - 旧 `GetMDFile` 的固定空响应、`Content_API.duptitle` 与 `ContentManage_Refresh` 已由 `legacyContentUtility/contentBatch` 等价迁移;Angular 内容页增加重复标题检查、单条/批量时间刷新和八列 CSV 安全导出。旧 XLSX 格式暂为部分迁移;专题关系、定时发布、静态 HTML、采集和 Markdown 因物理数据或旧运行时缺失均返回明确 `migration_blocked`。
 - `Design/Counter` 与 `Extend/Counter` 共 14 个访问统计动作已完成数据审计:目标全部 Schema 不含旧 `ZL_Com_VisitCount` 或等价访问明细,无法还原 InfoID、日期、地域、浏览器、系统和来源聚合;全站统计代码还缺少 `SiteInfo.AllSiteJS` 注入运行时。对应云函数统一返回细分 `migration_blocked`,不会以 `SysLog` 或问卷 `SurveyLog` 伪造访问统计。
+- CRM 中仍有真实数据的 `CType/CTypeAdd/CType_Add/CType_Del` 已迁为 `crmClientTypes/saveCrmClientType/crmClientTypeBatch` 与 Angular“CRM 客户类型”页面:只管理 `CRMSAttr.ztype=ctype`,按帐套隔离、名称防重并通过 PostgreSQL 事务锁原子分配旧字符串编号。旧 `CRMS_Client`、`CRMS_Contact`、`IServer`、经销商申请和 Licence/PDF 链均未进入目标 Schema;`ContactInfo` 是企业微信联系人,未被错误当作 CRM 联系人复用。
 - 旧 `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/旧模板运行时。

Разница между файлами не показана из-за своего большого размера
+ 17 - 2
scripts/deploy-admin-functions.mjs


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

@@ -66,6 +66,33 @@ const explicit = new Map([
   ['Extend / Counter/Browser', ['blocked-data', '目标缺少访客浏览器标识与访问明细']],
   ['Extend / Counter/Os', ['blocked-data', '目标缺少访客操作系统标识与访问明细']],
   ['Extend / Counter/Channel', ['blocked-data', '目标缺少访问来源渠道与 Referer 明细']],
+  ['CRM/Index', ['blocked-data', '目标 Schema 缺少旧 CRMS_Client 客户主表,无法进入客户列表首页']],
+  ['CRM/ClientListDiag', ['partial', '现有用户资源页可按账号搜索并选择用户;旧 GroupID/UserID 多条件弹窗布局未逐项复刻']],
+  ['CRM/ClientList', ['blocked-data', '目标 Schema 缺少旧 CRMS_Client 客户主表与 Model 48 动态附表']],
+  ['CRM/ClientView', ['blocked-data', '目标缺少客户、联系人、服务记录和动态附表的联合详情数据']],
+  ['CRM/ClientAdd', ['blocked-data', '目标没有可编辑或新增的 CRMS_Client 客户记录']],
+  ['CRM/BiServer_Del', ['blocked-data', '目标 Schema 缺少旧 IServer 服务记录主表']],
+  ['CRM/Client_Add', ['blocked-data', '目标缺少 CRMS_Client 与 Model 48 动态附表原子写入链']],
+  ['CRM/ClientImport', ['blocked-infrastructure', '目标缺少客户主表,且旧 CRM_Client.xml 导入定义未迁入']],
+  ['CRM/Import_DownTlp', ['blocked-infrastructure', '交付包没有旧 CRM_Client.xml 字段映射,无法等价生成 xlsx 模板']],
+  ['CRM/Import_Client', ['blocked-infrastructure', '目标缺少 CRMS_Client/Model 48 写入结构及旧 XML+NPOI 导入流水线']],
+  ['CRM/AddServiceRecord', ['blocked-data', '目标 Schema 缺少旧 IServer 客户服务记录主表']],
+  ['CRM/AddServiceRecord_Submit', ['blocked-data', '目标无法保存 crm_ID、管理员、用户、优先级、类型、状态、附件和提醒等服务记录']],
+  ['CRM/ServiceRecord_List', ['blocked-data', '目标没有可查询的旧 IServer 服务记录']],
+  ['CRM/CType', ['implemented', '已由 crmClientTypes 提供帐套隔离、仅 ztype=ctype、名称搜索与分页列表']],
+  ['CRM/CTypeAdd', ['implemented', '已由 Angular CRM 客户类型新建/编辑抽屉替代']],
+  ['CRM/CType_Add', ['implemented', '已由 saveCrmClientType 覆盖名称防重、白名单保存和原子旧编号分配']],
+  ['CRM/CType_Del', ['implemented', '已由 crmClientTypeBatch 覆盖每批 1–100 条帐套隔离删除']],
+  ['CRM/Contact', ['blocked-data', '目标 Schema 缺少旧 CRMS_Contact 联系人主表;ContactInfo 是企业微信联系人,语义不等价']],
+  ['CRM/ContactAdd', ['blocked-data', '目标无可编辑或新增的 CRMS_Contact 客户联系人记录']],
+  ['CRM/Contact_Add', ['blocked-data', '不能把旧 CRM 联系人错写到企业微信 ContactInfo']],
+  ['CRM/ContactImport', ['blocked-infrastructure', '目标缺少 CRM 联系人主表,且旧 CRM_Contact.xml 导入定义未迁入']],
+  ['CRM/ContactImport_Down', ['blocked-infrastructure', '交付包没有旧 CRM_Contact.xml 字段映射,无法等价生成 xlsx 模板']],
+  ['CRM/ContactImport_Upload', ['blocked-infrastructure', '目标缺少 CRMS_Contact 写入结构及旧 XML+NPOI 导入流水线']],
+  ['CRM/BecomeCustomerList', ['blocked-data', '目标 Schema 缺少 user_becustomer 类型 Common_UserApply 经销商申请']],
+  ['CRM/BecomeCustomerDetail', ['blocked-data', '目标缺少经销商申请、申请用户和资质详情数据']],
+  ['CRM/BecomeCustomerDetailUpdate', ['blocked-data', '目标没有可更新的经销商申请 Info1/AdminRemind 记录']],
+  ['CRM/BecomeCustomer_API', ['blocked-infrastructure', '目标缺少申请、LicenceFiles/LicenceUserSign、CRM 客户及 PDF 文件运行时,无法执行审核链']],
   ['Content / Node/NodeManage', ['implemented', '已由 Node 资源列表、分页与专用结构操作替代']],
   ['Content / Node/NodeSearch', ['implemented', '已覆盖按栏目名称搜索并保持帐套隔离']],
   ['Content / Node/NodeAdd', ['implemented', '已由 Node 动态表单与父节点结构控件替代']],

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

@@ -117,6 +117,7 @@ let contentTagsRestore = null;
 let userLevelRestore = null;
 let temporaryUserLevelId = '';
 let baikeRestore = null;
+let temporaryCrmClientTypeId = '';
 const temporaryLessonIds = [];
 try {
   const sample = await jsonRequest(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
@@ -639,6 +640,13 @@ try {
     const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyCounterBlocker', action }, 501);
     if (!String(blocked.message).includes('migration_blocked')) throw new Error(`旧访问统计动作 ${action} 未返回明确迁移阻塞`);
   }
+  const crmTypeSuffix = Date.now().toString(36); const createdCrmType = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveCrmClientType', className: 'CRMSAttr', fields: { value: `冒烟客户类型-${crmTypeSuffix}`, remark: '临时 CRM 客户类型' } }); temporaryCrmClientTypeId = String(createdCrmType.objectId || ''); if (!temporaryCrmClientTypeId || !Number(createdCrmType.id) || createdCrmType.ztype !== 'ctype' || createdCrmType.sourceKey !== `[["ID",${Number(createdCrmType.id)}]]`) throw new Error(`CRM 客户类型原子编号创建异常:${JSON.stringify(createdCrmType)}`);
+  const crmTypeList = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'crmClientTypes', className: 'CRMSAttr', page: 1, pageSize: 20, search: crmTypeSuffix }); if (crmTypeList.total !== 1 || crmTypeList.results?.[0]?.objectId !== temporaryCrmClientTypeId) throw new Error('CRM 客户类型专用列表失败');
+  const updatedCrmType = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveCrmClientType', className: 'CRMSAttr', objectId: temporaryCrmClientTypeId, fields: { value: `更新客户类型-${crmTypeSuffix}`, remark: '已更新' } }); if (updatedCrmType.value !== `更新客户类型-${crmTypeSuffix}` || Number(updatedCrmType.id) !== Number(createdCrmType.id)) throw new Error('CRM 客户类型专用编辑失败');
+  const deletedCrmType = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'crmClientTypeBatch', className: 'CRMSAttr', action: 'delete', objectIds: [temporaryCrmClientTypeId] }); if (deletedCrmType.updated !== 1) throw new Error('CRM 客户类型专用删除失败'); temporaryCrmClientTypeId = '';
+  for (const action of ['Index','ClientList','ClientView','ClientAdd','BiServer_Del','Client_Add','ClientImport','Import_DownTlp','Import_Client','AddServiceRecord','AddServiceRecord_Submit','ServiceRecord_List','Contact','ContactAdd','Contact_Add','ContactImport','ContactImport_Down','ContactImport_Upload','BecomeCustomerList','BecomeCustomerDetail','BecomeCustomerDetailUpdate','BecomeCustomer_API']) {
+    const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyCrmBlocker', action }, 501); if (!String(blocked.message).includes('migration_blocked')) throw new Error(`旧 CRM 动作 ${action} 未返回明确迁移阻塞`);
+  }
   await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'contentBatch', className: 'CommonModel', action: 'status', objectIds: [temporaryAppointmentCommonObjectId], status: 99 });
 
   const page = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'list', className: 'CourseAppointment', page: 1, pageSize: 2 });
@@ -844,6 +852,7 @@ try {
   if (contentTagsRestore) await jsonRequest(`${PARSE_URL}/config`, { method: 'PUT', body: JSON.stringify({ params: { legacyContentTags: contentTagsRestore } }) }, true).catch((error) => { console.error(`内容标签冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
   if (userLevelRestore) await jsonRequest(`${PARSE_URL}/classes/UserLevel/${userLevelRestore.objectId}`, { method: 'PUT', body: JSON.stringify(userLevelRestore.fields) }, true).catch((error) => { console.error(`积分等级冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
   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 (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

@@ -48,6 +48,7 @@ export const ADMIN_NAVIGATION = [
   { label: '内容标签词库', icon: 'tags', route: '/admin/resources/ContentTagKey' },
   { label: '积分等级', icon: 'badge-cent', route: '/admin/resources/UserLevel' },
   { label: '资金积分流水', icon: 'wallet-cards', route: '/admin/resources/UserMoneyLog' },
+  { label: 'CRM 客户类型', icon: 'contact-round', route: '/admin/resources/CRMSAttr' },
   { label: '百科审核', icon: 'book-marked', route: '/admin/resources/Baike' },
   { label: '题库管理', icon: 'list-checks', route: '/admin/resources/ExamSysQuestions' },
   { label: '迁移状态', icon: 'route', route: '/admin/migration' },

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

@@ -1364,190 +1364,190 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "CRM",
     "action": "Index",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 CRMS_Client 客户主表,无法进入客户列表首页"
   },
   {
     "module": "CRM",
     "action": "ClientListDiag",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有用户资源页可按账号搜索并选择用户;旧 GroupID/UserID 多条件弹窗布局未逐项复刻"
   },
   {
     "module": "CRM",
     "action": "ClientList",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 CRMS_Client 客户主表与 Model 48 动态附表"
   },
   {
     "module": "CRM",
     "action": "ClientView",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少客户、联系人、服务记录和动态附表的联合详情数据"
   },
   {
     "module": "CRM",
     "action": "ClientAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可编辑或新增的 CRMS_Client 客户记录"
   },
   {
     "module": "CRM",
     "action": "BiServer_Del",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 IServer 服务记录主表"
   },
   {
     "module": "CRM",
     "action": "Client_Add",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少 CRMS_Client 与 Model 48 动态附表原子写入链"
   },
   {
     "module": "CRM",
     "action": "ClientImport",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标缺少客户主表,且旧 CRM_Client.xml 导入定义未迁入"
   },
   {
     "module": "CRM",
     "action": "Import_DownTlp",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "交付包没有旧 CRM_Client.xml 字段映射,无法等价生成 xlsx 模板"
   },
   {
     "module": "CRM",
     "action": "Import_Client",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标缺少 CRMS_Client/Model 48 写入结构及旧 XML+NPOI 导入流水线"
   },
   {
     "module": "CRM",
     "action": "AddServiceRecord",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 IServer 客户服务记录主表"
   },
   {
     "module": "CRM",
     "action": "AddServiceRecord_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标无法保存 crm_ID、管理员、用户、优先级、类型、状态、附件和提醒等服务记录"
   },
   {
     "module": "CRM",
     "action": "ServiceRecord_List",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可查询的旧 IServer 服务记录"
   },
   {
     "module": "CRM",
     "action": "CType",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 crmClientTypes 提供帐套隔离、仅 ztype=ctype、名称搜索与分页列表"
   },
   {
     "module": "CRM",
     "action": "CTypeAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 Angular CRM 客户类型新建/编辑抽屉替代"
   },
   {
     "module": "CRM",
     "action": "CType_Add",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 saveCrmClientType 覆盖名称防重、白名单保存和原子旧编号分配"
   },
   {
     "module": "CRM",
     "action": "CType_Del",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 crmClientTypeBatch 覆盖每批 1–100 条帐套隔离删除"
   },
   {
     "module": "CRM",
     "action": "Contact",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 CRMS_Contact 联系人主表;ContactInfo 是企业微信联系人,语义不等价"
   },
   {
     "module": "CRM",
     "action": "ContactAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标无可编辑或新增的 CRMS_Contact 客户联系人记录"
   },
   {
     "module": "CRM",
     "action": "Contact_Add",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "不能把旧 CRM 联系人错写到企业微信 ContactInfo"
   },
   {
     "module": "CRM",
     "action": "ContactImport",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标缺少 CRM 联系人主表,且旧 CRM_Contact.xml 导入定义未迁入"
   },
   {
     "module": "CRM",
     "action": "ContactImport_Down",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "交付包没有旧 CRM_Contact.xml 字段映射,无法等价生成 xlsx 模板"
   },
   {
     "module": "CRM",
     "action": "ContactImport_Upload",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标缺少 CRMS_Contact 写入结构及旧 XML+NPOI 导入流水线"
   },
   {
     "module": "CRM",
     "action": "BecomeCustomerList",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少 user_becustomer 类型 Common_UserApply 经销商申请"
   },
   {
     "module": "CRM",
     "action": "BecomeCustomerDetail",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少经销商申请、申请用户和资质详情数据"
   },
   {
     "module": "CRM",
     "action": "BecomeCustomerDetailUpdate",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可更新的经销商申请 Info1/AdminRemind 记录"
   },
   {
     "module": "CRM",
     "action": "BecomeCustomer_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标缺少申请、LicenceFiles/LicenceUserSign、CRM 客户及 PDF 文件运行时,无法执行审核链"
   },
   {
     "module": "Extend / DBTool",

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

@@ -85,6 +85,8 @@
         <button class="danger-action" type="button" [disabled]="updatingStore()" (click)="applyBulkStoreAction('delete')">删除样式</button>
       } @else if (className() === 'UserLevel') {
         <button class="danger-action" type="button" [disabled]="updatingUserLevel()" (click)="applyBulkUserLevelDelete()">删除等级</button>
+      } @else if (className() === 'CRMSAttr') {
+        <button class="danger-action" type="button" [disabled]="updatingCrmType()" (click)="applyBulkCrmClientTypeDelete()">删除客户类型</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 {
@@ -155,6 +157,9 @@
         @if (className() === 'UserLevel') {
           <section class="account-create-note"><strong>现有积分等级编辑</strong><span>仅维护旧页面的等级别名、积分阈值、图片和备注。UserLevel.id 与 Parse 保留字段冲突,因此新增请求会返回明确迁移阻塞。</span></section>
         }
+        @if (className() === 'CRMSAttr') {
+          <section class="account-create-note"><strong>CRM 客户类型</strong><span>仅展示旧 ztype=ctype 记录;编号由云函数在帐套内原子分配,名称防重。客户、联系人、服务记录和经销商申请主表未迁入,不会以其他数据类冒充。</span></section>
+        }
         @if (className() === 'UserMoneyLog') {
           <section class="account-create-note"><strong>资金积分流水只读</strong><span>旧单表已按余额、银币、经验、积分、虚拟币和信用分拆分为六个规范化账本;管理页只合并查看,不允许篡改历史流水。</span></section>
         }

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

@@ -962,4 +962,33 @@ describe('AdminResourceComponent', () => {
     expect(functions.admin.calls.first().args).toEqual(['baikeBatch', { className: 'Baike', action: 'elite', objectIds: ['baike-a', 'baike-b'] }]);
     expect(component.bulkMessage()).toContain('2 个百科词条');
   });
+
+  it('loads only CRM customer-type attributes through the dedicated operation', async () => {
+    component.className.set('CRMSAttr');
+    component.schema.set({ className: 'CRMSAttr', label: 'CRM 客户类型', writable: true, creatable: true, supportsSoftDelete: false, fields: [] });
+    component.search.set('公司'); functions.admin.and.resolveTo({ className: 'CRMSAttr', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.load(1);
+
+    expect(functions.admin).toHaveBeenCalledWith('crmClientTypes', { className: 'CRMSAttr', page: 1, pageSize: 20, search: '公司' });
+  });
+
+  it('saves CRM customer types through the protected lifecycle operation', async () => {
+    component.className.set('CRMSAttr'); component.draft.set({ value: '渠道客户', remark: '渠道来源' });
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveCrmClientType' ? { objectId: 'crm-type', id: '4', value: '渠道客户' } : { className: 'CRMSAttr', page: 1, pageSize: 20, total: 4, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveCrmClientType', { className: 'CRMSAttr', objectId: undefined, fields: { value: '渠道客户', remark: '渠道来源' } }]);
+  });
+
+  it('deletes selected CRM customer types through the dedicated batch operation', async () => {
+    spyOn(window, 'confirm').and.returnValue(true); component.className.set('CRMSAttr'); component.selectedIds.set(['crm-a', 'crm-b']);
+    functions.admin.and.callFake(async (operation: string) => operation === 'crmClientTypeBatch' ? { action: 'delete', updated: 2 } : { className: 'CRMSAttr', page: 1, pageSize: 20, total: 0, results: [] });
+
+    await component.applyBulkCrmClientTypeDelete();
+
+    expect(functions.admin.calls.first().args).toEqual(['crmClientTypeBatch', { className: 'CRMSAttr', action: 'delete', objectIds: ['crm-a', 'crm-b'] }]);
+    expect(component.bulkMessage()).toContain('2 个客户类型');
+  });
 });

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

@@ -141,18 +141,19 @@ export class AdminResourceComponent implements OnInit {
   readonly filterBaikeStatus = signal(-100);
   readonly filterBaikeElite = signal(-100);
   readonly updatingBaike = signal(false);
+  readonly updatingCrmType = 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', 'qtitle', 'sName', 'storeName', 'styleName', 'count', 'content', 'str1', 'categoryname', 'dicname', 'cateName', 'gradeName', 'cClassName', 'testPoint', 'kName', 'tname', 'username', 'userName', 'realName', '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', 'value', 'remark', 'qtitle', 'sName', 'storeName', 'styleName', 'count', 'content', 'str1', 'categoryname', 'dicname', 'cateName', 'gradeName', 'cClassName', 'testPoint', 'kName', 'tname', 'username', 'userName', 'realName', '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'].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'].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));
@@ -188,7 +189,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' : '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' : '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(); }
@@ -281,7 +282,8 @@ export class AdminResourceComponent implements OnInit {
                                                           : this.className() === 'StoreApplication' ? 'saveStoreApplication'
                                                             : this.className() === 'StoreStyle' ? 'saveStoreStyle'
                                                               : this.className() === 'ContentTagKey' ? 'saveContentTagKeys'
-                                                                : this.className() === 'UserLevel' ? 'saveUserLevel' : 'save';
+                                                                : this.className() === 'UserLevel' ? 'saveUserLevel'
+                                                                  : this.className() === 'CRMSAttr' ? 'saveCrmClientType' : '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();
@@ -731,6 +733,15 @@ export class AdminResourceComponent implements OnInit {
     catch (error) { this.bulkError.set(error instanceof Error ? error.message : '积分等级删除失败'); }
     finally { this.updatingUserLevel.set(false); }
   }
+  async applyBulkCrmClientTypeDelete(): Promise<void> {
+    const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (this.className() !== 'CRMSAttr' || !objectIds.length) { this.bulkError.set('请先选择 CRM 客户类型'); return; }
+    if (!confirm(`确认删除选中的 ${objectIds.length} 个客户类型?`)) return;
+    this.updatingCrmType.set(true);
+    try { const result = await this.functions.admin<{ updated: number }>('crmClientTypeBatch', { className: 'CRMSAttr', action: 'delete', objectIds }); await this.load(); this.bulkMessage.set(`已删除 ${result.updated} 个客户类型`); }
+    catch (error) { this.bulkError.set(error instanceof Error ? error.message : '客户类型删除失败'); }
+    finally { this.updatingCrmType.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; }

Некоторые файлы не были показаны из-за большого количества измененных файлов