Browse Source

commit

master
luoweijian 3 days ago
parent
commit
fb6044f443
  1. 6
      .lithe/maven/config.json
  2. 180
      .lithe/run/generated.json
  3. BIN
      .scratch/customer-e2e/__pycache__/defs_a4.cpython-313.pyc
  4. 21
      .scratch/customer-e2e/answer06.md
  5. 28
      .scratch/customer-e2e/clean_a4_leftover.py
  6. 55
      .scratch/customer-e2e/clean_pollution.py
  7. 24
      .scratch/customer-e2e/close06.py
  8. 162
      .scratch/customer-e2e/demo-selftest-results.json
  9. 61
      .scratch/customer-e2e/demo/index.html
  10. 416
      .scratch/customer-e2e/demo_selftest.py
  11. 361
      .scratch/customer-e2e/demo_showcase.py
  12. 178
      .scratch/customer-e2e/e2e-report.md
  13. 10
      .scratch/customer-e2e/gen_a4_bru.py
  14. 24
      .scratch/customer-e2e/issues/06-bruno-docs.md
  15. 57
      .scratch/customer-e2e/issues/08-webapp-testing.md
  16. 13
      .scratch/customer-e2e/issues/09-report-aggregation.md
  17. 9
      .scratch/customer-e2e/map.md
  18. 47
      .scratch/customer-e2e/probe_test_env.py
  19. 46
      .scratch/customer-e2e/readme_a4.md
  20. 37
      .scratch/customer-e2e/scan_demo_api.py
  21. BIN
      .scratch/customer-e2e/shots08/shot-01-init.png
  22. BIN
      .scratch/customer-e2e/shots08/shot-02-login-mine-list.png
  23. BIN
      .scratch/customer-e2e/shots08/shot-03-overview.png
  24. BIN
      .scratch/customer-e2e/shots08/shot-04-pool.png
  25. BIN
      .scratch/customer-e2e/shots08/shot-05-search.png
  26. BIN
      .scratch/customer-e2e/shots08/shot-06-batch-focus.png
  27. BIN
      .scratch/customer-e2e/shots08/shot-07-detail-info.png
  28. BIN
      .scratch/customer-e2e/shots08/shot-08-detail-info.png
  29. BIN
      .scratch/customer-e2e/shots08/shot-09-detail-contacts.png
  30. BIN
      .scratch/customer-e2e/shots08/shot-10-detail-follow.png
  31. BIN
      .scratch/customer-e2e/shots08/shot-11-detail-opps.png
  32. BIN
      .scratch/customer-e2e/shots08/shot-12-detail-members.png
  33. BIN
      .scratch/customer-e2e/shots08/shot-13-detail-oplog.png
  34. BIN
      .scratch/customer-e2e/shots08/shot-14-follow-written.png
  35. BIN
      .scratch/customer-e2e/shots08/shot-15-contact-created.png
  36. BIN
      .scratch/customer-e2e/shots08/shot-16-contact-edited.png
  37. BIN
      .scratch/customer-e2e/shots08/shot-17-contact-deleted.png
  38. BIN
      .scratch/customer-e2e/shots08/shot-18-create-needconfirm.png
  39. BIN
      .scratch/customer-e2e/shots08/shot-19-create-credit-block.png
  40. BIN
      .scratch/customer-e2e/shots08/shot-19-focus-star.png
  41. BIN
      .scratch/customer-e2e/shots08/shot-20-focus-star.png
  42. BIN
      .scratch/customer-e2e/shots08/shot-20-transfer-list.png
  43. BIN
      .scratch/customer-e2e/shots08/shot-21-transfer-list.png
  44. BIN
      .scratch/customer-e2e/shots08/shot-21-transfer-preview.png
  45. BIN
      .scratch/customer-e2e/shots08/shot-22-transfer-preview.png
  46. BIN
      .scratch/customer-e2e/shots08/shot-23-import-list.png
  47. BIN
      .scratch/customer-e2e/shots08/shot-23-transfer-detail.png
  48. BIN
      .scratch/customer-e2e/shots08/shot-24-import-list.png
  49. BIN
      .scratch/customer-e2e/shots08/shot-24-import-upload.png
  50. BIN
      .scratch/customer-e2e/shots08/shot-25-import-result.png
  51. BIN
      .scratch/customer-e2e/shots08/shot-25-import-upload.png
  52. BIN
      .scratch/customer-e2e/shots08/shot-26-import-failures.png
  53. BIN
      .scratch/customer-e2e/shots08/shot-26-import-result.png
  54. BIN
      .scratch/customer-e2e/shots08/shot-27-import-failures.png
  55. BIN
      .scratch/customer-e2e/shots08/shot-27-reminder-saved.png
  56. BIN
      .scratch/customer-e2e/shots08/shot-28-reminder-64023.png
  57. BIN
      .scratch/customer-e2e/shots08/shot-28-reminder-saved.png
  58. BIN
      .scratch/customer-e2e/shots08/shot-29-checks.png
  59. BIN
      .scratch/customer-e2e/shots08/shot-29-reminder-64023.png
  60. BIN
      .scratch/customer-e2e/shots08/shot-30-checks.png
  61. 23
      .scratch/customer-e2e/showcase-ids.json
  62. 30
      .scratch/customer-e2e/smoke08.py
  63. 71
      .scratch/customer-e2e/verify_a4.py
  64. 6
      .scratch/customer-module/map.md
  65. 53
      .scratch/customer-rework/HANDOFF-2026-09-04.md
  66. BIN
      .scratch/customer-rework/a4-1-1_客户公海列表.png
  67. BIN
      .scratch/customer-rework/a4-2-1_客户总览列表_卡片视图_.png
  68. 48
      .scratch/customer-rework/issues/01-ag-checklist.md
  69. 64
      .scratch/customer-rework/issues/02-style-unify.md
  70. 38
      .scratch/customer-rework/issues/03-board-pool-close.md
  71. 26
      .scratch/customer-rework/issues/04-gap-fixes.md
  72. 34
      .scratch/customer-rework/issues/05-view-form-platform.md
  73. 25
      .scratch/customer-rework/issues/06-column-pref-scopes.md
  74. 38
      .scratch/customer-rework/issues/07-tags-bruno.md
  75. 45
      .scratch/customer-rework/issues/08-audit-prototype.md
  76. 19
      .scratch/customer-rework/issues/09-audit-fixes.md
  77. 17
      .scratch/customer-rework/issues/10-regression-retest.md
  78. 21
      .scratch/customer-rework/issues/11-docs-sync.md
  79. 67
      .scratch/customer-rework/map.md
  80. 11
      AGENTS.md
  81. 2
      crm-app/.factorypath
  82. 1
      crm-app/.settings/org.eclipse.jdt.core.prefs
  83. 2
      crm-auth/.factorypath
  84. 1
      crm-auth/.settings/org.eclipse.jdt.core.prefs
  85. 2
      crm-base/.factorypath
  86. 1
      crm-base/.settings/org.eclipse.jdt.core.prefs
  87. 57
      crm-customer/.classpath
  88. 157
      crm-customer/.factorypath
  89. 34
      crm-customer/.project
  90. 4
      crm-customer/.settings/org.eclipse.core.resources.prefs
  91. 4
      crm-customer/.settings/org.eclipse.jdt.apt.core.prefs
  92. 20
      crm-customer/.settings/org.eclipse.jdt.core.prefs
  93. 4
      crm-customer/.settings/org.eclipse.m2e.core.prefs
  94. 17
      crm-customer/src/main/java/com/crm/customer/controller/CustomerContactController.java
  95. 18
      crm-customer/src/main/java/com/crm/customer/controller/CustomerController.java
  96. 37
      crm-customer/src/main/java/com/crm/customer/controller/CustomerDetailController.java
  97. 19
      crm-customer/src/main/java/com/crm/customer/controller/CustomerFocusController.java
  98. 13
      crm-customer/src/main/java/com/crm/customer/controller/CustomerImportController.java
  99. 13
      crm-customer/src/main/java/com/crm/customer/controller/CustomerTransferController.java
  100. 24
      crm-customer/src/main/java/com/crm/customer/controller/CustomerWorkspaceController.java

6
.lithe/maven/config.json

@ -0,0 +1,6 @@
{
"version": 1,
"selectedProfiles": [],
"customProfiles": [],
"skipTests": false
}

180
.lithe/run/generated.json

@ -453,9 +453,9 @@
}
],
"generator": {
"fingerprint": "sha256:c3a4b1a72f53a9fd91457473dc3f94bfcfd59320ce7532f81fc2772967924660",
"fingerprint": "sha256:580a75953642084f1374c11d651b48b6b0fe957777a17d706c06548d412dc222",
"inputs": {
"crm-app/pom.xml": "sha256:5dab7c63bd1ebac88806b775f7e8e920d81eb500d3c0040ef93486b7df8f20fe",
"crm-app/pom.xml": "sha256:9d69b254709886a11282bd35af554c549824ee75f3730ee0aa5e67e57d6d0e7f",
"crm-app/src/main/java/com/crm/app/CrmAppApplication.java": "sha256:5eecad74f14776e5f2f51eef0d0925dde9874d93da3fb76dfd8d7c3cebe2c809",
"crm-auth/pom.xml": "sha256:7ce7c34655abce46bfabad8f78a78aa3e229d667246debd50d93f0d66b37dcd1",
"crm-auth/src/main/java/com/crm/auth/AuthApplication.java": "sha256:b89cd3423ee3a8d9d2f29cb4bb0b0487c3b5bfad3690f53b23fa2578165b537a",
@ -636,9 +636,140 @@
"crm-base/src/test/java/com/crm/base/domain/result/BatchRunnerTest.java": "sha256:9924e321f2872b71cb32151b9022cd1b9ef6a23028c5ffdc9aa2ac76c7e214d8",
"crm-base/src/test/java/com/crm/base/security/DataVisibilityTest.java": "sha256:4cfc9f0e3b9fcecadc2cf03eea47681c9aaac8463150ebd39114bcb05a2a8d31",
"crm-base/src/test/java/com/crm/base/utils/PageConverterTest.java": "sha256:3733b99795643d748dbcf95eb58c48e1763830514091ffce18d4e0f5b83f23c6",
"crm-customer/pom.xml": "sha256:18e771632b09756d2819030013b0b01f62e7f7af26b07f7b9da952d16491f70e",
"crm-customer/src/main/java/com/crm/customer/config/CustomerFulltextIndexInitializer.java": "sha256:15baaff3572ff904ce807bb0345e03c9d1e0c36fbe0c7656bc6cb91f33c5cf6d",
"crm-customer/src/main/java/com/crm/customer/constant/CustomerConstants.java": "sha256:a114511d731a67e869bec8c17669b4018e8e697624714773fd0bce3435111e39",
"crm-customer/src/main/java/com/crm/customer/controller/CustomerContactController.java": "sha256:abe5c5c6662dab8ffb8db565a4b23a59ffa19142e51d42c286f6b2fb26cb7eb7",
"crm-customer/src/main/java/com/crm/customer/controller/CustomerController.java": "sha256:b92e68b3ec3364d1528c275a83afa4421c4de0267138a6913d0cfe3165ea5902",
"crm-customer/src/main/java/com/crm/customer/controller/CustomerDetailController.java": "sha256:c785223fcb1e40b9519bb2e439ca4b3c8d4c539cd8ab4d44b9cfaafae7b46c3e",
"crm-customer/src/main/java/com/crm/customer/controller/CustomerFocusController.java": "sha256:bad083080d874d9e009533e11badcadba2c42c87b3ce1a4bd0ef66b86e510f1a",
"crm-customer/src/main/java/com/crm/customer/controller/CustomerImportController.java": "sha256:e12324cd16dae48dd3d1d5830cfd6d8ff9f13dd46eea2f356a9751adeef1f2a8",
"crm-customer/src/main/java/com/crm/customer/controller/CustomerOwnershipController.java": "sha256:9cf038cd21857ddcd33f49f85def9862d7c3aceb4f33714c710dfe921002f4c0",
"crm-customer/src/main/java/com/crm/customer/controller/CustomerPingController.java": "sha256:33cc32a500e578cd358242e0a3c36420ff9565105f30eedfa00f479da82db96e",
"crm-customer/src/main/java/com/crm/customer/controller/CustomerTransferController.java": "sha256:1891744da5fd4a12898da631808922699b2aa4b63e115d3b89a617ef3afc9560",
"crm-customer/src/main/java/com/crm/customer/controller/CustomerWorkspaceController.java": "sha256:0f3298a3ca3c5f138ac2bca7b8c14bb5edc44a8f43f401209fe5b6e207237dc1",
"crm-customer/src/main/java/com/crm/customer/domain/dto/AssignableUserDTO.java": "sha256:ce174ba6396201b8351d6746539253b5f63f2881d964d04f71fe2f419ca27b73",
"crm-customer/src/main/java/com/crm/customer/domain/dto/ContactDTO.java": "sha256:a3f364db3b421cea8c5bea86fb516e7e82733bbc08515f60fa0f80daa6d3d08e",
"crm-customer/src/main/java/com/crm/customer/domain/dto/ContactPhoneHitDTO.java": "sha256:046c7e184c6576dce9840d8312632ba8c3e01ee6d84d81449e9638eb9003e1b7",
"crm-customer/src/main/java/com/crm/customer/domain/dto/CreditCodeCheckDTO.java": "sha256:a35725ab26cf10bdb86e488efea7296cc14a3abb9a550d15ec846c1c5d3c07d7",
"crm-customer/src/main/java/com/crm/customer/domain/dto/CustomerBatchFailItem.java": "sha256:53ca14ea71df9335ccf964edb82036d722e768766a1a9752e2bb27c62b56b76b",
"crm-customer/src/main/java/com/crm/customer/domain/dto/CustomerBoardSummaryDTO.java": "sha256:f5defe731e20bd3bc5e88cb83257d039200be32da263c432e17427cfc5cd86d5",
"crm-customer/src/main/java/com/crm/customer/domain/dto/CustomerCreateDTO.java": "sha256:bdb08458bf6cf764ae77c5067f05e4edf38aac0dc3eee80fd326ad29a262b6a8",
"crm-customer/src/main/java/com/crm/customer/domain/dto/CustomerDetailDTO.java": "sha256:dd744ac92d7e87a61791a1229fb12bb6e68c64e4aa7f474b81cb5edca529e718",
"crm-customer/src/main/java/com/crm/customer/domain/dto/CustomerDetailHeadDTO.java": "sha256:8366cc9fb61cecca8a543d320a6721bf57536c8c007814c02b3fedae6f1f60fe",
"crm-customer/src/main/java/com/crm/customer/domain/dto/CustomerListRowDTO.java": "sha256:7067c2709a59a8fe7df9b4542b7b7126011f723574e550f1ae637621658d97cc",
"crm-customer/src/main/java/com/crm/customer/domain/dto/CustomerMemberDTO.java": "sha256:44aed8794226db9e821cd76f942cf1291f6b35d3fbc3514533501233c4d0e002",
"crm-customer/src/main/java/com/crm/customer/domain/dto/CustomerQuickCreateDTO.java": "sha256:1b0ce979a3cc2af2f8fbf7d025d0173899cb4c0b94a2b19fa2f7fba51c3f0fae",
"crm-customer/src/main/java/com/crm/customer/domain/dto/CustomerSaveResultDTO.java": "sha256:b5125f3d496b8635d0aa37d56d0cd7391a84203b22d9f20ac7c0a337842fc664",
"crm-customer/src/main/java/com/crm/customer/domain/dto/CustomerSearchItemDTO.java": "sha256:025e4a831f1da18db174eac059b2477c90d184bc0769e39c343c61eb1db9a611",
"crm-customer/src/main/java/com/crm/customer/domain/dto/CustomerSimilarHitDTO.java": "sha256:70948dae76db27bad3432230453efe6a5281c1d66972e7138f48544acece65d9",
"crm-customer/src/main/java/com/crm/customer/domain/dto/CustomerUpdateDTO.java": "sha256:3ff4bad2d8f386be922a6a9ea0c9f5086e93220bd4015c55a454a2d374f4db85",
"crm-customer/src/main/java/com/crm/customer/domain/dto/FollowCreateDTO.java": "sha256:887c5e3855d57b9103866ef566d17c637d1bf155e6313e44d7219256eac6f989",
"crm-customer/src/main/java/com/crm/customer/domain/dto/ImportFailDTO.java": "sha256:56d5c34e4ef888591312bc19738d10dd2fa46f4df95291e67569cfe8a946ff80",
"crm-customer/src/main/java/com/crm/customer/domain/dto/ImportPreviewDTO.java": "sha256:aa6d696e7f0657cfb77b0f99265d4a27d08571757026c12320dc733a07287b54",
"crm-customer/src/main/java/com/crm/customer/domain/dto/ImportResultDTO.java": "sha256:43cb9c083d7ea783c7a65ab9c7bdcf0ff81bc41b104ad1b5f4ca86220a79302c",
"crm-customer/src/main/java/com/crm/customer/domain/dto/MemberAddDTO.java": "sha256:6a8c3e3da5b6b557140d519f0f6b2aa5bb21de6765c59b6367f09211255849e0",
"crm-customer/src/main/java/com/crm/customer/domain/dto/TransferAssignDTO.java": "sha256:acc9151db4618b7f218aec8cd03dfbf569399b9fc4c2edebf964d9f8a918ac6a",
"crm-customer/src/main/java/com/crm/customer/domain/dto/TransferDetailDTO.java": "sha256:623fbe7ccf8e8faa2457c76dc00e13da1cb7dec5df526093f5a1124bc458b7f3",
"crm-customer/src/main/java/com/crm/customer/domain/dto/TransferInitiateDTO.java": "sha256:eb80f690369f63ec30ac06ac41b6ff78f1733f9ab11873b4190907e9e6908d47",
"crm-customer/src/main/java/com/crm/customer/domain/dto/TransferPreviewDTO.java": "sha256:6783fb1a3ac00a59b3d131a852ae30ec090677c6e1a7759f842819942b4dc559",
"crm-customer/src/main/java/com/crm/customer/domain/entity/Customer.java": "sha256:f1ff4b608dbc5978f1a7a5e359c6bb2d372620e5f3fae4d7a93d5f2bcb0d2dbc",
"crm-customer/src/main/java/com/crm/customer/domain/entity/CustomerContact.java": "sha256:7105d621555ed73cf813f68cbc313bde3ad1acb483e5dd4fe8f568251c3c9215",
"crm-customer/src/main/java/com/crm/customer/domain/entity/CustomerFocus.java": "sha256:7b579517d0a022b0d63cf654216c670c1ec0dafe19b8145d42697d8770df55da",
"crm-customer/src/main/java/com/crm/customer/domain/entity/CustomerFollow.java": "sha256:5bd117f51a288f2190e5930016e0ed5a2ebc171512ed316f1818148f7c2991ab",
"crm-customer/src/main/java/com/crm/customer/domain/entity/CustomerImportFail.java": "sha256:87ffe63a6d86fc716abe21a5726c7a9ef510c919d5cbc8b194cdb23a1e1121a3",
"crm-customer/src/main/java/com/crm/customer/domain/entity/CustomerImportTask.java": "sha256:7e38a73b7578dc960bb67e2a3ac873a444411eb230aea5ad7cedf61600f85d51",
"crm-customer/src/main/java/com/crm/customer/domain/entity/CustomerOplog.java": "sha256:9e13e1018fddf81a37479515473dca9d327cc74e761f18b4d5320ac87086ae22",
"crm-customer/src/main/java/com/crm/customer/domain/entity/CustomerPendingNotice.java": "sha256:97311be1735d72f90d49a0b1dd2b9d6c2085e1dd5f0fbf837467e4afd5031a84",
"crm-customer/src/main/java/com/crm/customer/domain/entity/CustomerTeamMember.java": "sha256:26a1c93ffe5cb9025fc4f61ed23b6daf658feb9dba7a3f3b353444cc6afbc497",
"crm-customer/src/main/java/com/crm/customer/domain/entity/CustomerTransfer.java": "sha256:4eaa82002bc8a7fd03d9cea0abee2659fe1a2402ba174c1c26f8e9a987e193cb",
"crm-customer/src/main/java/com/crm/customer/domain/entity/CustomerTransferDetail.java": "sha256:5b011b46305f0db435b84163dd0b8399bc9cede680bb18c788d4fbe16ec7e488",
"crm-customer/src/main/java/com/crm/customer/domain/entity/CustomerViewLog.java": "sha256:95e967a9ee0b4cceb910ffc86adfa649460a0d421ede117878fe56ae1b635dea",
"crm-customer/src/main/java/com/crm/customer/domain/enums/CustomerBatchFailReason.java": "sha256:298b4e5e13380536ec66ffd5f98dff907b6240bddb98a5c789d400388cfb5eb7",
"crm-customer/src/main/java/com/crm/customer/domain/enums/CustomerSavedViewOperator.java": "sha256:ce429c333ef7484ef276d04f05e37d0bccc13e4e6d0c837955a075ae10aa620f",
"crm-customer/src/main/java/com/crm/customer/domain/enums/CustomerStageEnum.java": "sha256:50537e234762606dab121034caab4b9d630270c940795c598954dca28774dc22",
"crm-customer/src/main/java/com/crm/customer/domain/enums/CustomerWorkspaceViewType.java": "sha256:485e202cf10007c66cebf5679d92d92f7318cec76660aa45c3cae7f7f6c62895",
"crm-customer/src/main/java/com/crm/customer/domain/excel/ContactImportRow.java": "sha256:ef6a6b395e42d25bdfed8b572f0bf2cee36a2fb8a9ae7c2af5949ae19eba874a",
"crm-customer/src/main/java/com/crm/customer/domain/excel/CustomerImportRow.java": "sha256:675f1ff804687d95af95ad8377f14ee56123d8abb3b15585789405eaf4829a7d",
"crm-customer/src/main/java/com/crm/customer/domain/param/ContactPageParam.java": "sha256:432f21de1cd4892270c4767afd0c371705619ac6a94c71fbfd899f1392008db7",
"crm-customer/src/main/java/com/crm/customer/domain/param/CustomerMinePageParam.java": "sha256:376bf8ce65c1cc3f3cf10ade6d189ca3c623f40fa28baaa51e9bdfbf67efdc57",
"crm-customer/src/main/java/com/crm/customer/domain/param/CustomerOverviewPageParam.java": "sha256:ea0c31f34b8eda3f9ea03e9806da41ea36907a2c74a8e66c68293706a67b6cab",
"crm-customer/src/main/java/com/crm/customer/domain/param/CustomerPageParam.java": "sha256:ea194614d0d5431f1c7a2d55aa37ea80fa7b37a7999d286fa03ad920b01d90db",
"crm-customer/src/main/java/com/crm/customer/domain/param/CustomerPoolPageParam.java": "sha256:f60077e68e50a167d29a6600c98a767be187aecab0d4a3bc445862a095c7328a",
"crm-customer/src/main/java/com/crm/customer/domain/param/CustomerSearchParam.java": "sha256:75f610a0dff057246c339345743617a65f274109ff1f8b2bcde8b28f591fcc77",
"crm-customer/src/main/java/com/crm/customer/domain/param/CustomerWorkspacePageParam.java": "sha256:9abf6a5b4dde0ec244ebce1c642c911705b2cd01a24c52fbcf46d66db5ac163e",
"crm-customer/src/main/java/com/crm/customer/domain/param/CustomerWorkspaceQuery.java": "sha256:fb27a6470abb19f5372bf70f94e7190ae8da9a002d15e039c9a4aef21e675c32",
"crm-customer/src/main/java/com/crm/customer/domain/param/FollowPageParam.java": "sha256:781b1c0735c4bccc41587202d7039484eea90d3be8724e757789084d7cb6f3e3",
"crm-customer/src/main/java/com/crm/customer/domain/param/ImportPageParam.java": "sha256:ea84b774b45ccdff0b430f730b6861871a41dd3693b5774b3d53dda470178979",
"crm-customer/src/main/java/com/crm/customer/domain/param/OplogPageParam.java": "sha256:126a35402615b4d607b70f27959784d77807985151b59af03a2dfd1ee0eac00c",
"crm-customer/src/main/java/com/crm/customer/domain/param/TransferPageParam.java": "sha256:c7b498514193222ed55c7c3ab461fbbb402af3b39a5e8ae107c4bf81ad52a8d7",
"crm-customer/src/main/java/com/crm/customer/job/CustomerReminderJob.java": "sha256:eb2e61c4ebbdde96bef356836e51eb2cb48f015f82be4293f1588463656ca57a",
"crm-customer/src/main/java/com/crm/customer/mapper/CustomerContactMapper.java": "sha256:c52e2e8ebdd9175e53b7a3d0f38ec202a17d93112bf6ebed45d10e616991c12e",
"crm-customer/src/main/java/com/crm/customer/mapper/CustomerFocusMapper.java": "sha256:0335724baa2542a2735fc3df692c967c11092acea1392c128af6f38e8718013e",
"crm-customer/src/main/java/com/crm/customer/mapper/CustomerFollowMapper.java": "sha256:292f208befdedba4024be0adac3e37688aa72ff1182fa70462f446fff3f00c5f",
"crm-customer/src/main/java/com/crm/customer/mapper/CustomerImportFailMapper.java": "sha256:a0cf4e390531b0e54a393b0abb0d9621fbc2ca9e2d1438e95baba5ef5fa2c919",
"crm-customer/src/main/java/com/crm/customer/mapper/CustomerImportTaskMapper.java": "sha256:b615dc5230cd8d0e31f66ce11c5a681247cb70049bd079655fb44ac51465426b",
"crm-customer/src/main/java/com/crm/customer/mapper/CustomerMapper.java": "sha256:12691dd627032c6aea09347628e7a61b36f3eb8cb153d5a0219c9ff007f4dcda",
"crm-customer/src/main/java/com/crm/customer/mapper/CustomerOplogMapper.java": "sha256:512032955b52089830409a5221ee4d5ccd49ff03e90076a20f51ff9b7dbd0219",
"crm-customer/src/main/java/com/crm/customer/mapper/CustomerPendingNoticeMapper.java": "sha256:eeb6349e880998ff72e8877c92600a30386086d1711201932df8a151ff86a5f9",
"crm-customer/src/main/java/com/crm/customer/mapper/CustomerTeamMemberMapper.java": "sha256:46a7b849967cf6f562f8f4dc6a02627d8e4e8a0d530a3ab476e00c4576351e44",
"crm-customer/src/main/java/com/crm/customer/mapper/CustomerTransferDetailMapper.java": "sha256:85f46758fe5f9eadff2dd78bfccbc4601ceaaad5d7975135f806598d19e320f3",
"crm-customer/src/main/java/com/crm/customer/mapper/CustomerTransferMapper.java": "sha256:88e104ca22c1e0230f564953a6806deb3d0c667852dfbeea12365a9fa6e40b0f",
"crm-customer/src/main/java/com/crm/customer/mapper/CustomerViewLogMapper.java": "sha256:974d28921e41ee85b794016df99587ef1848d28d2f9fe4bd703a8b7772bc587d",
"crm-customer/src/main/java/com/crm/customer/port/CompanyLookupPort.java": "sha256:4f8ff3ac00b79360954794ac96ca3ec0b6606b20a47cab64595d3d8073cc084d",
"crm-customer/src/main/java/com/crm/customer/port/CustomerNameSimilarityPort.java": "sha256:8023eef00662851063dd0815045faa1466dde791bd7f7df36cc32f86e0d89523",
"crm-customer/src/main/java/com/crm/customer/port/CustomerOpportunityQueryPort.java": "sha256:51d1d70c0ef41fc47d2a88447aa9d273eb1c02d8c1f54242b0310025eb8e2a21",
"crm-customer/src/main/java/com/crm/customer/port/CustomerStagePort.java": "sha256:f4624533f2583f377d434a7d3db70ef96955c11160514c89d682d59a3e9d3705",
"crm-customer/src/main/java/com/crm/customer/port/OpportunityOwnerSyncPort.java": "sha256:7aaa226cbeb1733d342254e12e212034c4a0631e535871a9fbfcca753ac1d921",
"crm-customer/src/main/java/com/crm/customer/port/ProjectOwnerSyncPort.java": "sha256:32ec0639d9701ce4a6ac1da2958071ea1243739216d3bea54e0372e447002ffb",
"crm-customer/src/main/java/com/crm/customer/port/impl/CustomerStagePortImpl.java": "sha256:b6ea8174682638844872954c247ac9b454e23fb701631b623956c509d5feb2c6",
"crm-customer/src/main/java/com/crm/customer/port/impl/NgramNameSimilarityPortImpl.java": "sha256:f6184370c6dd05ae65d34f083169b6195bf604a02f626ce589f3f2980d6b3f57",
"crm-customer/src/main/java/com/crm/customer/port/impl/NoopCustomerOpportunityQueryPortImpl.java": "sha256:1e73d1b8c3e48671decb5c1b0c6dcb3883c367fee35e2216029e46789c9111d4",
"crm-customer/src/main/java/com/crm/customer/port/impl/NoopOpportunityOwnerSyncPortImpl.java": "sha256:fa98ca5d48ff27aa7671d184dc3cb753191952bbdf475d75760984f4ff7d4dcf",
"crm-customer/src/main/java/com/crm/customer/port/impl/NoopProjectOwnerSyncPortImpl.java": "sha256:de6f7b491ef80b83d1fc05f7932d033686ab78d846076a5bd414fc5e94f4d007",
"crm-customer/src/main/java/com/crm/customer/port/impl/StubCompanyLookupPortImpl.java": "sha256:771a05e20f87ef10bc76facb922fd00e09d8211d006b5e7bf4db47877a55087d",
"crm-customer/src/main/java/com/crm/customer/query/CustomerSavedViewFilter.java": "sha256:c4425d0f420cc8dce92fdf70a5a0bf582ce162f8b7f3dbe418df91a530f39d16",
"crm-customer/src/main/java/com/crm/customer/query/CustomerViewCondition.java": "sha256:9056ba8125d73f3c4a4a47a648d4b99a8613fca715a1f249480a6f22bfe6f09e",
"crm-customer/src/main/java/com/crm/customer/service/CustomerDedupService.java": "sha256:86163e8c633780a67893b84505fee40df98157bc8ed3ba647c21f8dfa386e43b",
"crm-customer/src/main/java/com/crm/customer/service/CustomerDedupSettingProvider.java": "sha256:890ae3cfe5c2eb7f593da76a599f40978d3718857ba85ed8920ef3519ed20368",
"crm-customer/src/main/java/com/crm/customer/service/CustomerOplogWriter.java": "sha256:3965945f37953e6aa9371b48419a045cf8fd5c79a04dbc5ef14a602e686bbaa7",
"crm-customer/src/main/java/com/crm/customer/service/CustomerScopeEvaluator.java": "sha256:db0206657363c3d495e5c74ef17d5d5b700c424b7d423ad568d91fa19e6f54cb",
"crm-customer/src/main/java/com/crm/customer/service/ICustomerContactService.java": "sha256:0ccbb6f91c3e1dcc4ca47c29d532431a9cac36a6c4e87863d3ce49d092a255f9",
"crm-customer/src/main/java/com/crm/customer/service/ICustomerDetailService.java": "sha256:4831d36194836f6de02173cddd29a288eca460bb9d4beb972d569d557cd92496",
"crm-customer/src/main/java/com/crm/customer/service/ICustomerFocusService.java": "sha256:0838137e1751c17cf1bfe9ecbbd4ea7577e39ae5163c28d1207806b68e40c575",
"crm-customer/src/main/java/com/crm/customer/service/ICustomerFollowService.java": "sha256:65f10fcd4e466fd29fc951f509e799d288a5bbf87e19a6024cea71afd4983a23",
"crm-customer/src/main/java/com/crm/customer/service/ICustomerImportService.java": "sha256:e7be290b72746d2641832391a319f0aba788d1cc9b76b2d9393f37ad046bec6d",
"crm-customer/src/main/java/com/crm/customer/service/ICustomerOwnershipService.java": "sha256:de81269ab9fbf7fc2b23635fd132bd68475b88b4fb63a4d2499a2fa8cde77135",
"crm-customer/src/main/java/com/crm/customer/service/ICustomerService.java": "sha256:5e052091b8949992194167f8ef25b70c52dc2b0b07b4e0b750566bed9dadd12f",
"crm-customer/src/main/java/com/crm/customer/service/ICustomerTransferService.java": "sha256:cb36b2cc709e58341bd2c914d116fda7188225aef0f6fc72f0112c27bb652c0b",
"crm-customer/src/main/java/com/crm/customer/service/ICustomerWorkspaceService.java": "sha256:48bd73367b8828c5d2452f0415ca5da8d74616273a4913bfc6741f30d88305c3",
"crm-customer/src/main/java/com/crm/customer/service/impl/CustomerContactServiceImpl.java": "sha256:7a3ea5e053e9a73aaf2136236fa582e7198973c287ece38c80875c3281e13112",
"crm-customer/src/main/java/com/crm/customer/service/impl/CustomerDetailServiceImpl.java": "sha256:cd28469cd67166abd9b3444998c4250bd253080b78899f55242a4badde359fd5",
"crm-customer/src/main/java/com/crm/customer/service/impl/CustomerFocusServiceImpl.java": "sha256:9857c918646a676af3a42acc139673f549509dfecf22e42d227b09a4a2a5cc41",
"crm-customer/src/main/java/com/crm/customer/service/impl/CustomerFollowServiceImpl.java": "sha256:2173783150ddc819f2c2bf31012e7955d3ad745395354920c39dd0db1e4bfae3",
"crm-customer/src/main/java/com/crm/customer/service/impl/CustomerImportAnalyzer.java": "sha256:e959aae57463573958f8abc3d5dea6448b31d21fcb6b5ef3e286c1543590cd04",
"crm-customer/src/main/java/com/crm/customer/service/impl/CustomerImportServiceImpl.java": "sha256:0462c0b1c20a7f2359982b0d38e26c028f3deba60235babbdef20b9d8daeb755",
"crm-customer/src/main/java/com/crm/customer/service/impl/CustomerOwnershipServiceImpl.java": "sha256:a40286854712751fc750e1b4261e6020ac8f6a5e41a2564788026a126c2a0bb4",
"crm-customer/src/main/java/com/crm/customer/service/impl/CustomerServiceImpl.java": "sha256:e3a98ca831c09859cb0ca8c4e9f44440cea40dcbce6f19f99e71e05fccebd098",
"crm-customer/src/main/java/com/crm/customer/service/impl/CustomerTransferServiceImpl.java": "sha256:06a79017009704d2310be3926362d3e9024d4b358ef8d73438d715860a1a47d9",
"crm-customer/src/main/java/com/crm/customer/service/impl/CustomerWorkspaceServiceImpl.java": "sha256:ba0923ecbea25f1bc086726fdc74736e60c4ca7ee749b367950f0ec66e3784b6",
"crm-customer/src/main/java/com/crm/customer/service/impl/DefaultCustomerDedupSettingProvider.java": "sha256:e9f5cc6d4af36f6212044d50a38c1e5efb80db10e1cbe5c02715fe7543820407",
"crm-customer/src/main/java/com/crm/customer/task/CustomerImportExecutor.java": "sha256:a5c8662aa84670b049f8d09be8482da07ad32ebb09c5528c55296e33ec8c7b4f",
"crm-customer/src/test/java/com/crm/customer/job/CustomerReminderJobIntegrationTest.java": "sha256:e7f0e26a5b61411ccf471ca1dfb04a5e0d0e059028427686ebdddc5b2cc78ae0",
"crm-customer/src/test/java/com/crm/customer/port/impl/CustomerStagePortIntegrationTest.java": "sha256:266522b882930dc3e1b5da6c32ec0671c54f71610a465688644613b1bbb07daa",
"crm-customer/src/test/java/com/crm/customer/query/CustomerSavedViewFilterTest.java": "sha256:da0c2a79e039f53cc57db8bf997a30f41d6024d6a9613f6282f503c6d2395ee0",
"crm-customer/src/test/java/com/crm/customer/service/impl/CustomerContactServiceIntegrationTest.java": "sha256:5b8c53edbd6f179d31736970100f121b9d53e2987444646238462d808f42e4c1",
"crm-customer/src/test/java/com/crm/customer/service/impl/CustomerDetailMembersIntegrationTest.java": "sha256:b958bcfa91571445654fbc7ab671efc6ec7414df34303d74b009d5bf1be41700",
"crm-customer/src/test/java/com/crm/customer/service/impl/CustomerFocusIntegrationTest.java": "sha256:b9fc7a68a9c8e4708ddba10c459f3e07823138a1de650a1e5bef17ca8f6176b1",
"crm-customer/src/test/java/com/crm/customer/service/impl/CustomerFollowDetailIntegrationTest.java": "sha256:b930a1a49814d9bcb2151fc9720646f9e9f6262f1bb823a915c0ac2ce1d6baa7",
"crm-customer/src/test/java/com/crm/customer/service/impl/CustomerImportIntegrationTest.java": "sha256:565e75c64e50850f170c52ee70c43915c4304f4c8e9f89b57ed34d7ddd01f606",
"crm-customer/src/test/java/com/crm/customer/service/impl/CustomerServiceImplIntegrationTest.java": "sha256:faaa69e23da17bfe8138c1f1c4207730f943910527bcb19c3823bab0712fd5d9",
"crm-customer/src/test/java/com/crm/customer/service/impl/CustomerTransferIntegrationTest.java": "sha256:6e8559e2b7dbbdf46cc8dcdeb1c5a5844c827bb8239c137f51d2bf771771a452",
"crm-customer/src/test/java/com/crm/customer/service/impl/CustomerWorkspaceOwnershipIntegrationTest.java": "sha256:706163ba36ce87f14981706f34809da422ba7cf59630d751dca68fb85c01eb3d",
"crm-customer/src/test/java/com/crm/customer/service/impl/CustomerWorkspaceSavedViewIntegrationTest.java": "sha256:8bd806e818cb300ea9cabeffdbf7cb62191937824f3afbc416d51ec7f93badab",
"crm-dict/pom.xml": "sha256:4c8ad74e1e9be1f91ef1ce2a05dbd9381bd0e40235aaf3dcf4639b75a927d82d",
"crm-dict/src/main/java/com/crm/dict/DictApplication.java": "sha256:69963ebcce9cfaf21679200c9b0319e92f4347b1d06e1c3b2204794a6126c29b",
"crm-dict/src/main/java/com/crm/dict/config/DictDataInitializer.java": "sha256:317f5b19cc2bb9f5fc443780cdd06adfdb5c81c0e42207dc237890e40d83f3d2",
"crm-dict/src/main/java/com/crm/dict/config/DictDataInitializer.java": "sha256:7d970f8e08bc0f2f9fb492c2753162d9bf03933843b883ad86c293b2d5c54751",
"crm-dict/src/main/java/com/crm/dict/config/DictPermissionInitializer.java": "sha256:fa74373639b654059e22f99af801b485a870788e7ed650bc9025ba2e5ddc8930",
"crm-dict/src/main/java/com/crm/dict/constant/DictConstants.java": "sha256:33a93f306ced75f960b68c218512839d6a3c114969b9d2ac58e2984103c38bd6",
"crm-dict/src/main/java/com/crm/dict/controller/DictGroupController.java": "sha256:56bd69f480df26f5092feaa8ed4b1d106a15824fd5318176b43f22f2c643f6fc",
@ -664,7 +795,7 @@
"crm-dict/src/main/java/com/crm/dict/service/impl/DictQueryServiceImpl.java": "sha256:ac5b1ef1f8eae837b91f4ea8a6d72bbf93bcd66336e7f63567a8c65475e5c55c",
"crm-dict/src/main/java/com/crm/dict/service/impl/DictReferenceServiceImpl.java": "sha256:98be664504a4121d1dfc586133aa403951ec404b900c9dfcd58d71fe3c5137f1",
"crm-dict/src/test/java/com/crm/dict/AbstractDictH2Test.java": "sha256:38f0b4398faf536f92a37b967636243ec6f0897cbb7f47881ae44a5c018d24d1",
"crm-dict/src/test/java/com/crm/dict/config/DictDataInitializerTest.java": "sha256:727f27a52c44ee58ee529dbe37a7f12ab241ca739207e97a5e9730be61ea1f4d",
"crm-dict/src/test/java/com/crm/dict/config/DictDataInitializerTest.java": "sha256:f0f716c5173cebc17065f542117bf8e17cee363e002782b69641068f7e9ad4b0",
"crm-dict/src/test/java/com/crm/dict/config/DictPermissionInitializerTest.java": "sha256:7af9b8ba95b58f954a53a21aefacb8ad6cebca62e8c87be07728c3a526299533",
"crm-dict/src/test/java/com/crm/dict/service/impl/DictGroupServiceTest.java": "sha256:7ca49c6bdc51333e3cfced34a759ca1444b673aa6cba84a6a20b57f965682531",
"crm-dict/src/test/java/com/crm/dict/service/impl/DictItemServiceTest.java": "sha256:412baa47082985f99b9c624d63ad3a4ca5a0559ec38df16bbe9693bc681f904d",
@ -776,7 +907,7 @@
"crm-lead/src/test/java/com/crm/lead/service/impl/LeadBatchServiceTest.java": "sha256:b9debc03539a006946ef0a2bb182d47ecd8a4f45d4b0cce189e57c08058b6fe1",
"crm-lead/src/test/java/com/crm/lead/service/impl/LeadServiceImplTest.java": "sha256:5547bae863b3c6934a70d205f7ca735bcba6d800d03054932abd39f6c8cfe8b6",
"crm-lead/src/test/java/com/crm/lead/state/impl/LeadTransitionImplTest.java": "sha256:1a353fd2c98cb7109c75cf5c16171e9e20cce1efed8cb18fc2dccb9053f160d0",
"crm-opportunity/pom.xml": "sha256:b6f7b5b226f8e2ec62fef6e9087449434dd26b23d3d56b7a91ec150af3bf9509",
"crm-opportunity/pom.xml": "sha256:cb71f6b0b7ab0b7823ec2cf64f066fa45b53f625e059df3faa96db89c7e3c629",
"crm-opportunity/src/main/java/com/crm/opportunity/constant/OpportunityConstants.java": "sha256:f06edf0b0a068722e1bbc891e4a4a922f61a97f8fcd4164c7f91cf22a3beda0e",
"crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCollabController.java": "sha256:a927ec6906cbc3a4929d596a0a57a9f39616306c039f93086a77b11e70fb6377",
"crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCreateController.java": "sha256:7fa82aeeef22b76ff4c32183bef6c877ff02a2ef47d48d6160ae08c5b6e5728e",
@ -784,12 +915,12 @@
"crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityQueryController.java": "sha256:22caeda46b12cc1de52f11d32367b07c78961c7524db20cadd233c9b217fff31",
"crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunitySchemeCardController.java": "sha256:08348345a4e3c91f7a90ab3e6f2b4893c9d5f99e0a42dec6a6f6f5208d6aeba3",
"crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityStageController.java": "sha256:dbebedf8201c0e1e1573292343aa4902806ab00b2e43c3eab49c0b37e2c542e3",
"crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunitySubController.java": "sha256:d50175070ef47372ff584d2cf976dd91b16fa1aac5cd8645b83909ebd9547fd7",
"crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunitySubController.java": "sha256:fdc96e6b0bfc0d21c9f7daef9fe343ee1a14f005abc9ff74883d9137175fce1e",
"crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityTransitionController.java": "sha256:93942b5a4355c9c1ee56392e8295bef2d6a13529be98f64d42bb229ae9396b13",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/CreateOpportunityRequest.java": "sha256:16eb516b2e2035fab58f392f1246877242a9d35669b277750adfa79eedbb56a5",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/CustomerContactDTO.java": "sha256:e347fe00e097eb03e32c684c48c00ab18bc4793aa1cc7a07baa9c80aa80eab01",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/OpportunityCustomerAddDTO.java": "sha256:8dc51c16719732fe4762f514f226a6793a6c0fa7c0ffbe6cab0b7f70786fdb15",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/OpportunityCustomerCandidateDTO.java": "sha256:491c98feebab0ef02ab40008598dca789fe32903ff1a47b293d12bd27054f42b",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/CustomerContactDTO.java": "sha256:c18c8f70777a87063d57573653f77ebcc81843e8d4ad5bcfdb1557952d1c85f8",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/OpportunityCustomerAddDTO.java": "sha256:febec9f2b50c8cc5e0d07a63d27563b6fedda3a762e295c839c48345765f796a",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/OpportunityCustomerCandidateDTO.java": "sha256:b700d397fde0ef7de1f84bc4ef297f49f49afe1aa7e94cea216054a658aa0ad9",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/OpportunityDTO.java": "sha256:5a24072d72cef014803ad13ea3421fc46809370e930ff17adbdc78b22a771eae",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/OpportunityTeamAddDTO.java": "sha256:747e1191f8b4ccd3483961197e6f397f5490d9ba686aa7280029c15dc471330b",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/OpportunityTeamUpdateDTO.java": "sha256:90a7b2d05b1baccfd941c934aec8405524681926ed9f397f66f644233c0bc597",
@ -800,9 +931,9 @@
"crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/StageHistoryDTO.java": "sha256:b421c7b0d6fc3013171005d9e2567d761685f8c18a67cf14a451a4084ee69b2e",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/StageProgressDTO.java": "sha256:d378be9270833c0b4cb502988278b60bd0a4cccdc7f1b01d105a0de9e70e9ec3",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/SwitchStageDTO.java": "sha256:0c9397c8f89ff303f6d1ebe5215bd57c4597fa18685bb10ca2ad53238debf949",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/Opportunity.java": "sha256:8954fcbb4332d13d7070b413a5ea241402151f3c916cfbac909094dffb1fb8e2",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/Opportunity.java": "sha256:5df13f1c96637e283ed3e7203a0d9615b5e700fd443ec687a675b87647079641",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityAttachment.java": "sha256:75ceee391dfd83a38c34064ced1861562cbd6b9a29bd491bc7b66a258f886fff",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityCustomer.java": "sha256:d81c9d890a66f483cc2b6e47609ae4ef2c985291587874338f19083fc470aec8",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityCustomer.java": "sha256:8be45688fc419fe0bc90d99dfb62fbbbc06939365c9785046433ae13c1d9c2ec",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityFocus.java": "sha256:dbbf7b88ebc7dd83aee37b8a75bae3d496d27a48b90f22560a5445f4dc842cad",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityFollow.java": "sha256:0349b346f5c01d51f79132ae3f5898c40fc18cbcc58c8c1ed4896d357c989380",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityOplog.java": "sha256:e1c62c2e9a2de753cbc73c9a4d8ee19dcd285103448955adbaf939185ae1b2ab",
@ -823,7 +954,7 @@
"crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/OpportunityViewType.java": "sha256:bdb28239bdb29917dd31d01f84340e5b9f126e4a24bf5931cacf12fb17685e79",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/SavedViewOperator.java": "sha256:15e0e7b6a79159a57c674041850084243699a8ace12b8e5e58f1efb1a6f51056",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/TeamMemberPermission.java": "sha256:b6ac87d76fbe8a4f7fc89041e8cdc9a69a825f2f5d3dae77c7d5d4e659020d57",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/param/OpportunityCustomerSearchParam.java": "sha256:917c1d6312d3d4e9a0012f2db632ce557639cde4599983eea0826d8cb50f4dfa",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/param/OpportunityCustomerSearchParam.java": "sha256:9cb975810665322c33d6ececde17eae2d0ac8f1572ee749bcc7db7a72452d467",
"crm-opportunity/src/main/java/com/crm/opportunity/domain/param/OpportunityPageParam.java": "sha256:3d38a9d820a1064695b6db037465e3a16a26bebe22e1177490d9362407b4fa93",
"crm-opportunity/src/main/java/com/crm/opportunity/history/OpportunityStatusHistoryRecorder.java": "sha256:00342f0f10a54f74b0b791819fe449ff135c3ef9ae12680706b071a35c4b58c6",
"crm-opportunity/src/main/java/com/crm/opportunity/history/impl/OpportunityStatusHistoryRecorderImpl.java": "sha256:2389c5c1c621f3680d05a5e8a574df0d6daa3ae38005184a083c67f64336a76e",
@ -837,7 +968,7 @@
"crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityCustomerMapper.java": "sha256:fd14e2bc6e65fb13958dbf7049a375b5487d516f351e803389fda974276ab425",
"crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityFocusMapper.java": "sha256:565f6b41511b21bcb627b7ee12fda84128539d218cc4ae811d49b5abd03f87c3",
"crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityFollowMapper.java": "sha256:f279209d497e03ac8029073feb4a4b49ebd32ec1b5f1a7c7cf00dcfa456efa8b",
"crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityMapper.java": "sha256:7cb855d030bf8cc9f6f6e956da9f8138916707172705d3e57fbfd6d346319efd",
"crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityMapper.java": "sha256:38dc1bc8c492ae6da6233a8c26a6ec6f06fd7ba8390fa84b054bb453f57d61eb",
"crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityOplogMapper.java": "sha256:957ed81e64f8ec89054127f63a3cc87238b652920277bf335caf5d07bcb1cbee",
"crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityPendingNoticeMapper.java": "sha256:2f1032a9127cad0a736c99cb0e71cd9fa3cd6164da4c8b0651ed6de78b6edde0",
"crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunitySchemeCardMapper.java": "sha256:20f7ef3a752f591aa2404c86d12e3fc6c5e580a6880ad92d28989edd94cbdb76",
@ -854,8 +985,10 @@
"crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/ConvertToProjectCmd.java": "sha256:8a96f9ffa9cf4e352c246cc07c6e2d941cbf7743b60ccf2ad4e5fe2b0153df57",
"crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/OpportunityConvertException.java": "sha256:9ed284f46fe8eb094a524c2e2f61e7b648eb854e7a230157704c12f22c70133b",
"crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/OpportunityConvertPort.java": "sha256:8eb2c82fa809d0ed63bdbefabddca6db8668b9e26271e6ed42853108ea7b8732",
"crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/CustomerOpportunityQueryPortImpl.java": "sha256:1cd38d8480b97ea2e4f55210032b773e3b7fb8fd2f6a60c0e6de9ff663431cf9",
"crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityConvertPortImpl.java": "sha256:47d639378d3387b6ca5c00a34875b9ec3b7036f454095e52a8204239bf01baaa",
"crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityCreationPortImpl.java": "sha256:584ace59d9377d70b3197217daae95463c185e62954f1b3b2714a9c1bb38414e",
"crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityOwnerSyncPortImpl.java": "sha256:8e1dd1f3df5419ebde7941719464bb6c23708392627d6249ffd4ebc868d3fe55",
"crm-opportunity/src/main/java/com/crm/opportunity/query/OpportunityViewFilter.java": "sha256:a3c310a9760cab7383ef2ed0f3fc599f9441111bf348edc7e398fb4903f3a9e8",
"crm-opportunity/src/main/java/com/crm/opportunity/query/OpportunityViewQuery.java": "sha256:86b6427c54b841d1e1db30f14b877d1ff546a28c27bf3a5a4b96cc5065bf1317",
"crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunitySavedViewFilter.java": "sha256:86786192745913c2b2815900a1971dc0267a3ad5516004745cb08c83cdb70500",
@ -865,11 +998,11 @@
"crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityCollabService.java": "sha256:436f58699273cd655022db1964a54f097fb1a22a423f53bb29dfaf4d8377b5a1",
"crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityCreateService.java": "sha256:cfdeffecd9fb28a9e5a05685e4ebaefb3c933013f308ac3e696a67dc735dc0d0",
"crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityDetailService.java": "sha256:3c31b70d893bf7ba0e0b417568bf7a55a68bc50db572879dd4e450e9eb182d5a",
"crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunitySubService.java": "sha256:68d13a07655610191f922b34a02170218367040e59c24cc0515f9fee107fe5d3",
"crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunitySubService.java": "sha256:f6128cbab256dd39439f76ef26f315a74c6d937faaddea5353bc6ae5feb68ca9",
"crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCollabServiceImpl.java": "sha256:55cd5df655eaff70a4bca05a9acdb8a873316989e94b10bb7a51340dec59f22c",
"crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java": "sha256:aae65debdec9d0d13e690aa0f339d9b1b6bbd6eba397ce5c01572706e99bbe76",
"crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityDetailServiceImpl.java": "sha256:073a4d7f396cbd8d4915eec6ca4fb037c2b9a4d53a1f9bf6170cc7eff121084a",
"crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunitySubServiceImpl.java": "sha256:d8b65d12b26c0d3d01446457f08e88f52a4aeebf25a299828780c9a3b0b4802b",
"crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunitySubServiceImpl.java": "sha256:81eff1847a466ff27abd9737a7071843de491553813ec2aca8923a5ff640af2e",
"crm-opportunity/src/main/java/com/crm/opportunity/stage/OpportunityStageService.java": "sha256:6082b3f6f357b533ed135e1204ed3c30f5f709c7fc81c584d6f400767fa182b6",
"crm-opportunity/src/main/java/com/crm/opportunity/stage/SwitchStageCmd.java": "sha256:9f5593c1ffa321f0d2d7df887beaf10e508734526881a008a14cd1b6c4466669",
"crm-opportunity/src/main/java/com/crm/opportunity/stage/impl/OpportunityStageServiceImpl.java": "sha256:36737fb345242bdf5da50817f9f3240c53c9c5dc7997b38722d2aa0113c74a57",
@ -891,6 +1024,8 @@
"crm-opportunity/src/test/java/com/crm/opportunity/mapper/OpportunityViewSourceMapperTest.java": "sha256:d7582832310641c5f67d27626cb72e546ba6fd2c8316e2a92f04514a66faf0db",
"crm-opportunity/src/test/java/com/crm/opportunity/port/inbound/OpportunityConvertPortImplTest.java": "sha256:4889ed80915bac530b9bde0cbfe9bc23461002727523c8366d1cfa59018b8e15",
"crm-opportunity/src/test/java/com/crm/opportunity/port/inbound/OpportunityCreationPortImplTest.java": "sha256:e5df57a383c75531ae1c88e26cb83728d97967091c892cf69dd424860b04ba0a",
"crm-opportunity/src/test/java/com/crm/opportunity/port/inbound/impl/CustomerOpportunityQueryPortImplTest.java": "sha256:ec3c2cdeaec8aeb72f36ce6684bfdf674bc9e473b5ab75638abb48e574e3749c",
"crm-opportunity/src/test/java/com/crm/opportunity/port/inbound/impl/OpportunityOwnerSyncPortImplTest.java": "sha256:29e2fc6ee355531ee34c8815bd961b3250ab551a358b37a92d7b11940e186b24",
"crm-opportunity/src/test/java/com/crm/opportunity/query/OpportunityViewQuerySkeletonTest.java": "sha256:3ad766650904a1fd38324794e3967758e15e8458d51c19d67a6f17b6a22fd8d3",
"crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunitySavedViewFilterTest.java": "sha256:c6b8966c81f5a07bd3fda2af30ca4ab06cc59bb5c7eeff0be25faac6b4b65ba0",
"crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunityViewQueryImplTest.java": "sha256:17cef74bfb1a10f12d9c316c2e83b6ad1962aff5bbf55e0aed87578e67cd6dfc",
@ -899,7 +1034,7 @@
"crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCollabServiceImplTest.java": "sha256:c2c2afe007462b080b22e53b33b4e34c4f06af8cd9441b825303f764dafec5e3",
"crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImplTest.java": "sha256:c6c49825cc2b5eb405dcca81fd34cc73104dc24e6ef5ef9ab132ab73eae3eb8c",
"crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityDetailServiceImplTest.java": "sha256:812ac4dbeaa4325ad2986856d8bb04f242bdcc24dc4bf9102a0566189d8fefac",
"crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunitySubServiceImplTest.java": "sha256:68c6811da5b4543999594468f4e86ea5af45c507c0c8f5f7c6eba78430e66aef",
"crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunitySubServiceImplTest.java": "sha256:82def92d7138b134e895dc1a2732eed2873c918546848bb85b38f248faa287b4",
"crm-opportunity/src/test/java/com/crm/opportunity/stage/impl/OpportunityStageServiceImplTest.java": "sha256:9bf60f8d0e11a80007a18eb097443911390b945ac5f84a6053bcf0b0bb49df3d",
"crm-opportunity/src/test/java/com/crm/opportunity/state/OpportunityActionGuardTest.java": "sha256:37cf8f014f5f21a95e4950490350f0899c11eb557b4c8468a21ecdb30ffaccf0",
"crm-opportunity/src/test/java/com/crm/opportunity/state/OpportunityTransitionCmdTest.java": "sha256:1c324adeee25b46d15da0e6e50dda0a9618486d902e98ea14bf1265bebf5db7f",
@ -921,6 +1056,8 @@
"crm-preference/src/test/java/com/crm/preference/service/impl/ColumnPreferenceServiceImplTest.java": "sha256:3f7b43975ae0be929ba66bf08dcfd5bb47f37fb187b665a28d4eb100bae0acb7",
"crm-preference/src/test/java/com/crm/preference/service/impl/SavedViewServiceImplTest.java": "sha256:ff10069e1c695b9114f92d472e7556ae8ad8004295ef1a4329173350de129e71",
"crm-rule/pom.xml": "sha256:73501580daca184cea7e126ba82355c70f0161fef26a16c60d68e811ff7c4ef6",
"crm-rule/src/main/java/com/crm/rule/config/CustomerReminderRuleInitializer.java": "sha256:cf0917a9684de0470725ec33141f7f8012e69ee50fb2fd16f7eb9b992abfcde3",
"crm-rule/src/main/java/com/crm/rule/config/CustomerReminderRulePermissionInitializer.java": "sha256:8f2b22c229a0d2fd518ab2d073116cff6b638fd243d658e2d8b817eda356cf0e",
"crm-rule/src/main/java/com/crm/rule/config/OpportunityRulePermissionInitializer.java": "sha256:1178e6234a7cbbdd9cc446473452bfb9e5665b8217caf09f09aa2f76bac59834",
"crm-rule/src/main/java/com/crm/rule/config/OpportunitySchemeCardTemplatePermissionInitializer.java": "sha256:7ae7c7f89a913bc9f7370a2c73bcbfda1d1f8be1ae5153c573e318ea50f7406e",
"crm-rule/src/main/java/com/crm/rule/config/OpportunityStageTemplatePermissionInitializer.java": "sha256:711e33ca215e0f571d8024ee44238a5ccba64d4911494bb8a515829969a21abd",
@ -928,13 +1065,16 @@
"crm-rule/src/main/java/com/crm/rule/config/RulePermissionInitializer.java": "sha256:f1eb40bbf0a70a467bb45a6023ad032e230c07cc709a51c5f1d1b90e2fe358be",
"crm-rule/src/main/java/com/crm/rule/config/SchemeFieldDefInitializer.java": "sha256:f6060f84c3fb4379e1a20e41771ae5db1b53dc50455dcb801129a8d05ac52201",
"crm-rule/src/main/java/com/crm/rule/config/StageTemplateInitializer.java": "sha256:d979d5d499f029cc10f752d80a4f93416a3d1664e93f1f304720018f577571bf",
"crm-rule/src/main/java/com/crm/rule/constant/CustomerRuleConstants.java": "sha256:76081a1bd23ba455ee7986b95a7419f40a39840f54c99cfa6b041db4f0079cac",
"crm-rule/src/main/java/com/crm/rule/constant/OpportunityRuleConstants.java": "sha256:60bca9f230f985c6e8e8982351b8c84c211b767bce237d7de031eca05d8c9b33",
"crm-rule/src/main/java/com/crm/rule/constant/RuleConstants.java": "sha256:a6c6cd8db948c97603dbf0767c3ca70205a388ea386f6c2f3e3c3fe40b03de60",
"crm-rule/src/main/java/com/crm/rule/controller/CustomerReminderRuleController.java": "sha256:1d8d1393af3258627b95da2bb27783bb4ef654c0d44bf2272ff40326a4d98c83",
"crm-rule/src/main/java/com/crm/rule/controller/LeadPoolController.java": "sha256:bee1d3e441fc4001533fcb640b606ce3d1c673099da625371d524c55a1d3c73f",
"crm-rule/src/main/java/com/crm/rule/controller/OpportunityPoolRuleController.java": "sha256:f7bd644b8c1d667c6f6580cf52d2f87bd31cc0dba0e8f4968ea5a89a1dc0c4ed",
"crm-rule/src/main/java/com/crm/rule/controller/OpportunitySchemeCardTemplateController.java": "sha256:32c8320d78d141f63532e7ad7bad46489d8c0e071156d1316927da034d8cf894",
"crm-rule/src/main/java/com/crm/rule/controller/OpportunityStageTemplateController.java": "sha256:d9799efccb9e83f2ca1432dce83495f9e0c28305fbfdb037730db53b38d4a85b",
"crm-rule/src/main/java/com/crm/rule/controller/RegionController.java": "sha256:f714716c5ee9e800dbef84294c52b6484adfdf49d84684f171e4ff41c6e7dfc6",
"crm-rule/src/main/java/com/crm/rule/domain/dto/CustomerReminderRuleDTO.java": "sha256:ced0445e9c56f52af55556b2db201a7b6a945379873834eee55e1fdf7959e2b5",
"crm-rule/src/main/java/com/crm/rule/domain/dto/LeadPoolDTO.java": "sha256:d28081fa062762a440248004cec417512dd95964e12fd8ca68f23b718104dcb1",
"crm-rule/src/main/java/com/crm/rule/domain/dto/OpportunityPoolRuleDTO.java": "sha256:abeaa2e6912dc107442a0d1364be2651d323c12f1fdaa66baf1789be6eeb61a2",
"crm-rule/src/main/java/com/crm/rule/domain/dto/OpportunityPoolRuleVersionDTO.java": "sha256:39c3c5bbf5dba97b7067443e15c67e45aede3ec61c997ae85ebbb595b8ccd2e2",
@ -946,6 +1086,7 @@
"crm-rule/src/main/java/com/crm/rule/domain/dto/PoolBatchFailItem.java": "sha256:510ad672eb50f51bcb863e9ffbb7b92f9256c0838d5d78aa2450881ab877e523",
"crm-rule/src/main/java/com/crm/rule/domain/dto/StageDisplay.java": "sha256:8bc572b10f16ec5d43f6339992f001b3d3e5a5d0eb9782455d64ca7ddd627c30",
"crm-rule/src/main/java/com/crm/rule/domain/dto/SysRegionDTO.java": "sha256:f9d23c44cf69d9e338dc978534278761fdec5c4d3d6cdd87d513e21173696bf1",
"crm-rule/src/main/java/com/crm/rule/domain/entity/CustomerReminderRule.java": "sha256:080b982688f7d0078bcb9d933b3c0fcb97f82af70e6a4def6d60b9d197e07c2f",
"crm-rule/src/main/java/com/crm/rule/domain/entity/LeadPool.java": "sha256:b9d6080a20921b8d8bf75c990461f404aaa4f46c47adc00b16d25cd6805d939e",
"crm-rule/src/main/java/com/crm/rule/domain/entity/LeadPoolMember.java": "sha256:3607a9cddab1bb2d6ceca3474f9615895d6b9aa3ff7b3915c50ed2cb0fe42160",
"crm-rule/src/main/java/com/crm/rule/domain/entity/LeadPoolProvince.java": "sha256:a0a0ac3d9ee18b0db28ae092fee408303f3d30078ec3e9695b6b3350eda1e25b",
@ -966,6 +1107,7 @@
"crm-rule/src/main/java/com/crm/rule/domain/param/OpportunitySchemeCardTemplatePageParam.java": "sha256:3399d25d53400653fa300ab6e1e8c1691bedbcc6908e9c67511721a63e3b7303",
"crm-rule/src/main/java/com/crm/rule/domain/param/OpportunityStageTemplatePageParam.java": "sha256:1ce3d461c02dccb1cabf89f622092ed02c69760ba737c41ecb28424a2e4b0479",
"crm-rule/src/main/java/com/crm/rule/event/PoolChangedEvent.java": "sha256:2de9939be11fff335c9df6c0f732478f77603dc21c58e7147a8a47c9986caeb8",
"crm-rule/src/main/java/com/crm/rule/mapper/CustomerReminderRuleMapper.java": "sha256:0c8afcd7a3b95614b9b315b2670bf652d0a9631e013ff14b930db23ccbd07dd8",
"crm-rule/src/main/java/com/crm/rule/mapper/LeadPoolMapper.java": "sha256:9745e2f22c2c83b77d2412ebe7a1372aad3470d14da0f03285859941a8424ad3",
"crm-rule/src/main/java/com/crm/rule/mapper/LeadPoolMemberMapper.java": "sha256:091606d23adb43c53b677ca096821fb252fde367ee184f274f9243159f6898e2",
"crm-rule/src/main/java/com/crm/rule/mapper/LeadPoolProvinceMapper.java": "sha256:925ac43341bdda92a5d4244d6bf352ec30cdecf08d4083d535b17f5e2612df9e",
@ -981,6 +1123,7 @@
"crm-rule/src/main/java/com/crm/rule/mapper/OpportunityStageTemplateMapper.java": "sha256:98deed4a4be18a5f916cc4e7b27cf56624e3c33a837ad2f563f82f8220167f63",
"crm-rule/src/main/java/com/crm/rule/mapper/SysRegionMapper.java": "sha256:dcac2b33a2df44568a6771f2db9ee98714d877f08efeb0dfd5216d716fe83991",
"crm-rule/src/main/java/com/crm/rule/port/outbound/PoolLeadOccupationPort.java": "sha256:2fb3c98ea713846d887fd492476004dceff0672577ce3f21c2010e5b947b4931",
"crm-rule/src/main/java/com/crm/rule/service/ICustomerReminderRuleService.java": "sha256:aee6a1d08dd8232cf377a848ce352d1203c48ef2af139076bc8c01e71acaa233",
"crm-rule/src/main/java/com/crm/rule/service/ILeadPoolService.java": "sha256:6e8b03b5f2e58395dea4257ca87e26a5f939e6bbc959721e6facd2983ea85cb4",
"crm-rule/src/main/java/com/crm/rule/service/IOpportunityPoolRuleService.java": "sha256:b3348bdd0519e2aea1e6c55ba4237dd9a92b4be1e40b770d95838d57d230f0d0",
"crm-rule/src/main/java/com/crm/rule/service/IOpportunitySchemeCardTemplateService.java": "sha256:67446206742eb35d6c2a21c355c109ce9f40ca85b20829969346e78cd1ee0b6c",
@ -989,6 +1132,7 @@
"crm-rule/src/main/java/com/crm/rule/service/OpportunityPoolRuleMatcher.java": "sha256:a6596fa5cfdd60c7f1083b2cee6aa2f62e0d16dfeb9c646bdf0d1ce652a203b8",
"crm-rule/src/main/java/com/crm/rule/service/PoolGeographyStore.java": "sha256:0f7fcaaa7f8e342d4d03ab9f6a30d202c492e7b4b1729f54ba04710393d75308",
"crm-rule/src/main/java/com/crm/rule/service/PoolMemberStore.java": "sha256:a82c6b3b61203848793cad8ddb9202ec4c9c998f29e98756c7b587f43cabb234",
"crm-rule/src/main/java/com/crm/rule/service/impl/CustomerReminderRuleServiceImpl.java": "sha256:318a26ab5692b4b67fda4c29cde79908d185cd110b7868f840d7b8927d85e948",
"crm-rule/src/main/java/com/crm/rule/service/impl/LeadPoolServiceImpl.java": "sha256:d98b4abd562c8fef4dc118394b1500e46f7ced9d6f9e171b057b4628f673aa2b",
"crm-rule/src/main/java/com/crm/rule/service/impl/OpportunityPoolRuleMatcherImpl.java": "sha256:f3ffe1282381e5aa59f1e5114ab47bcdcbe6345a320b1cd06c844a258405674d",
"crm-rule/src/main/java/com/crm/rule/service/impl/OpportunityPoolRuleServiceImpl.java": "sha256:46a1ed1ea8093876219544cf5987be6f23c3a92db9d1bc9072fa2d65701b180a",
@ -998,9 +1142,11 @@
"crm-rule/src/main/java/com/crm/rule/service/impl/PoolMemberStoreImpl.java": "sha256:88aedc80813f19e47648f17bed1a3b4e7ec2ba6ad3ed50edc1b03cca4f7e1714",
"crm-rule/src/main/java/com/crm/rule/service/impl/SysRegionServiceImpl.java": "sha256:4cac672fd48073b935fa40034e7476523917ac93387f9f147ebf76dedcd98e91",
"crm-rule/src/main/java/com/crm/rule/service/impl/VersionedConfigSupport.java": "sha256:69a54997f744fd4b5a964f635b5a8f14df6790c86970af019f1afcd8dbd505b9",
"crm-rule/src/test/java/com/crm/rule/config/CustomerReminderRuleInitializerTest.java": "sha256:9af8d8f6810fa64255775047aa5c2ea7dd4a1dd4eb119a25947a59b1c8bb27a6",
"crm-rule/src/test/java/com/crm/rule/config/RegionDataInitializerTest.java": "sha256:78c3254185aec09d8879027a352fa2d01dc890a63bb151988ffa3eee3a0fe1f6",
"crm-rule/src/test/java/com/crm/rule/config/SchemeFieldDefInitializerTest.java": "sha256:26987fbd248c596267abdd2e448617c917839af1b86c65d34cdb9ef97e311be3",
"crm-rule/src/test/java/com/crm/rule/config/StageTemplateInitializerTest.java": "sha256:9acd14075c909851c8451b6b0e0758aadda57ae2ecd52468d8e2d3a4ad24f2b5",
"crm-rule/src/test/java/com/crm/rule/service/impl/CustomerReminderRuleServiceImplTest.java": "sha256:dc86d3336af6b30ecebce9cb276d8de3a32a1b5ebe6dedbb8254cae844ed4b1a",
"crm-rule/src/test/java/com/crm/rule/service/impl/LeadPoolBatchDeleteTest.java": "sha256:bbaadc0de1f0ddf50b33743ebf00b603a90bd46e8a79c8ebc565ed6dd1a8c0cd",
"crm-rule/src/test/java/com/crm/rule/service/impl/LeadPoolServiceImplTest.java": "sha256:8ebcfd7e8e1c481e8524a1d230cd8a7c8071b3693759787ebed952f0ac04542a",
"crm-rule/src/test/java/com/crm/rule/service/impl/OpportunityPoolRuleMatcherImplTest.java": "sha256:570b50da3715b3e663dc9d1f4f07fd243cfb21d9006d5ffa98cdf1b1c47dab44",
@ -1008,7 +1154,7 @@
"crm-rule/src/test/java/com/crm/rule/service/impl/OpportunitySchemeCardTemplateServiceImplTest.java": "sha256:ad7e8ac4e5188ecd491a6a6404aa66ed8b3128c24109f9012e684b056e4a0083",
"crm-rule/src/test/java/com/crm/rule/service/impl/OpportunityStageTemplateServiceImplTest.java": "sha256:5791337fe64fc8762c9c63a0264cf66dca9977c2d6bd235315ead6505a05cde9",
"docker-compose.yml": "sha256:6e5ea6a3d2442cddf87a8dae62c97ff43585f5c4f6f8daab14a673a94a85913c",
"pom.xml": "sha256:abe417c4b369d6a649f52b15948497c086cb2cd05371cf0b6bdb9a2a9d5c4b2c",
"pom.xml": "sha256:ea10f26be3bc9c6bac114a0b9695c9cfd143f2a18087fcdca6fdfbc2d9ebc013",
"tmp/china-division/package/package.json": "sha256:a8b0c3d1f5e480c51b6726a5456f4b62188b5f4a54d719075d6166f24cae9fbd"
}
},

BIN
.scratch/customer-e2e/__pycache__/defs_a4.cpython-313.pyc

Binary file not shown.

21
.scratch/customer-e2e/answer06.md

@ -0,0 +1,21 @@
## Answer
票 06 收口(换机续跑 2026-09-04 完成)。**生成规模:57 个 .bru / 10 个生成目录**(A4 下 9 个子目录 55 端点 + collection 根级 `客户管理/超期提醒规则/` 2 端点,另含根级 `客户管理/folder.bru` seq:12 与各目录 folder.bru)。
**分组决策**(结构听源码铁律):
- 目录 = 源码 `@Tag` 原文。9 个 crm-customer Controller 的 tag 均带「A4 客户管理/」前缀,剥前缀后相对 BASE 落位;CustomerReminderRuleController(crm-rule)tag 原文「客户管理」无 A4 前缀 → 相对 collection 根落位(`客户管理/超期提醒规则/`)。
- crm-preference saved-view 6 端点**不生成副本**:源码 tag 挂 A3 商机管理(商机先建、客户侧复用同套),生成副本会被 sync 重跑判孤儿;README 文字索引标明客户域 scopeKey=`customer.*`。
- 路径段 `{workspace}` 保留占位原样入钥匙(mine/overview/pool 三值一套端点,.bru url 块展开为 mine 实例)。
**对账结果**:verify_a4.py 正则扫 9 个 crm-customer Controller + CustomerReminderRuleController 得 (METHOD, path) 钥匙集 57 个,与生成 .bru docs 首行钥匙双向比对——missing 0 / extra 0 / duplicate 0,**PASS 全命中收口**。docs 首行标注「本行兼作对账钥匙,勿手改」。
**格式抽查**:新增客户.bru(form 大表 + specimens 实测 67003 撞码响应)、保存提醒规则.bru(唯一 JSON 端点,body:json 用 specimens 实测 body + 实测回显)、workspace 列表分页.bru(分页 content 截 1 条 + 实测字段结构表)对照金标准 `A3 商机管理/新建商机/新建商机.bru` 结构一致:meta tags:[generated] / method 块 / params·body 块 / docs 四段+表格。
**裸 collection 不升级**(Not-yet 挂号项结论):集合根目录保持裸 collection(bruno.json + collection.bru,无 workspace.yml),本票全部生成逻辑按现状跑通(生成 + Bruno 可读),**不做 workspace 结构升级**——避免影响其余 A0-A7 分区的存量路径与用户工作区打开方式,如后续 Bruno 3.x 打不开再按 bruno-coldstart 先例单独处理。
**换机事项**:生成器 3 处盘符路径已从 E: 改 D:(sys.path / DOCS / SP_DIR);旧机第一次跑的错误产物(重复嵌套目录 `A4 客户管理/A4 客户管理/` 64 文件 + 错位 `A4 客户管理/客户管理/超期提醒规则/` 3 文件)已随用户 git pull 进入本机,清理脚本 clean_a4_leftover.py 删除后重跑生成,幂等无残留。
**README**:`A4 客户管理/README.md` 已从占位重写——9 子目录索引(含端点清单与计数)、根级提醒规则归位原因、crm-preference 不生成副本说明、空缺标注、对接示位、维护纪律(只碰 tags:[generated] / 对账钥匙行勿手改 / agent 不提交 git)。
**遗留**:agent 不提交 git,D 盘文档仓变更(删 2 残留目录 + 新增 57 .bru + folder.bru + README 重写)由用户审查后自行提交。缺陷注记(D-02/D-04/D-05/D-07)已按 defs_a4.py 写入对应 .bru desc。

28
.scratch/customer-e2e/clean_a4_leftover.py

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
"""票 06 续跑步骤 1:清文档仓第一次跑的错误残留(HANDOFF-2026-09-04 §3.1)。
- D:\code\crm-api-docs\A4 客户管理\A4 客户管理\ 重复嵌套目录9 子目录
- D:\code\crm-api-docs\A4 客户管理\客户管理\ 错位目录壳内含超期提醒规则 3 文件
保留A4 客户管理\folder.bruREADME.md
幂等目录不存在则跳过"""
import io, sys, os, shutil
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
DOCS = r'D:\code\crm-api-docs'
A4 = os.path.join(DOCS, 'A4 客户管理')
LEFTOVERS = [
os.path.join(A4, 'A4 客户管理'),
os.path.join(A4, '客户管理'),
]
for d in LEFTOVERS:
if os.path.isdir(d):
n = sum(len(fn) for _, _, fn in os.walk(d))
shutil.rmtree(d)
print(f'removed: {os.path.relpath(d, DOCS)} ({n} files)')
else:
print(f'skip (absent): {os.path.relpath(d, DOCS)}')
print('--- after cleanup, A4 tree ---')
for dp, dn, fn in os.walk(A4):
for f in fn:
print(' ', os.path.relpath(os.path.join(dp, f), DOCS))

55
.scratch/customer-e2e/clean_pollution.py

@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
"""票 08 重跑前置:归档上一轮意外落库的 demo-selftest-撞码客户。
原因第一轮自测用假信用代码 MA9ABC1234L3 未命中 67003 客户真实落库
若不清理本轮同名重试会先命中 L2 needConfirm看不到 67003
走正门 APIworkspace page 搜名称 archive?id=新客户无商机67006 不会阻断
"""
import io, sys
import requests
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
BASE = 'http://localhost:8080'
ADMIN = '739564171091247104'
TARGET = 'demo-selftest-撞码客户'
r = requests.get(f'{BASE}/api/auth/debug/token', params={'userId': ADMIN}, timeout=10)
tok = r.json().get('data')
tok = tok.get('token') if isinstance(tok, dict) else tok
H = {'Authorization': f'Bearer {tok}'}
found = []
for ws in ('pool', 'mine', 'overview'):
r = requests.post(f'{BASE}/api/customer/workspace/{ws}/page',
data={'current': 1, 'size': 50, 'keyword': TARGET}, headers=H, timeout=15)
rows = ((r.json().get('data') or {}).get('content')) or []
for c in rows:
if TARGET in (c.get('customerName') or ''):
found.append((ws, c.get('id'), c.get('customerName'), c.get('customerNo'), c.get('archiveStatus')))
if found:
break
if not found:
print('未发现脏客户,无需清理')
sys.exit(0)
for ws, cid, name, no, arc in found:
print(f'发现: ws={ws} id={cid} {name}({no}) archiveStatus={arc}')
if arc == 2:
print(' 已是归档态,跳过')
continue
r = requests.post(f'{BASE}/api/customer/archive', params={'id': cid}, headers=H, timeout=15)
if r.json().get('code') == 67004:
# 公海客户无 owner,archive 仅销售负责人可执行 → 先 claim 领取成为 owner,再归档
r2 = requests.post(f'{BASE}/api/customer/claim', params={'id': cid}, headers=H, timeout=15)
print(' claim:', r2.status_code, 'code=', r2.json().get('code'), r2.json().get('message'))
r = requests.post(f'{BASE}/api/customer/archive', params={'id': cid}, headers=H, timeout=15)
print(' archive:', r.status_code, 'code=', r.json().get('code'), r.json().get('message'))
# 复核
ws = found[0][0]
r = requests.post(f'{BASE}/api/customer/workspace/{ws}/page',
data={'current': 1, 'size': 50, 'keyword': TARGET}, headers=H, timeout=15)
rows = ((r.json().get('data') or {}).get('content')) or []
for c in rows:
if TARGET in (c.get('customerName') or ''):
print('复核:', c.get('customerNo'), 'archiveStatus=', c.get('archiveStatus'))

24
.scratch/customer-e2e/close06.py

@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
"""票 06 关票:Status claimed→resolved + answer06.md 追加为 Answer 段(沿用票 07 模式)。"""
import io, sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
TICKET = r'd:\code\crm-backend-matt\.scratch\customer-e2e\issues\06-bruno-docs.md'
ANSWER = r'd:\code\crm-backend-matt\.scratch\customer-e2e\answer06.md'
txt = open(TICKET, encoding='utf-8').read()
if 'Status: claimed' in txt:
txt = txt.replace('Status: claimed', 'Status: resolved', 1)
print('status: claimed -> resolved')
else:
print('WARN: status line not in expected state:', [l for l in txt.splitlines() if 'Status' in l])
answer = open(ANSWER, encoding='utf-8').read().rstrip()
if '## Answer' in txt:
print('WARN: Answer already present, skip append')
else:
txt = txt.rstrip() + '\n\n' + answer + '\n'
print('answer appended')
open(TICKET, 'w', encoding='utf-8', newline='').write(txt)
print('ticket saved.')

162
.scratch/customer-e2e/demo-selftest-results.json

@ -0,0 +1,162 @@
{
"results": [
{
"step": "01 打开 file:// 页面",
"status": "PASS",
"note": "tabs=5"
},
{
"step": "02 debug token 登录 + 我的客户列表",
"status": "PASS",
"note": "who=已登录: () mine rows=10"
},
{
"step": "03 三 workspace 切换",
"status": "PASS",
"note": "overview rows=10 pool rows=1 info=第 1 页 / 共 0 条"
},
{
"step": "04 列表关键字检索",
"status": "PASS",
"note": "keyword=恒信达 命中"
},
{
"step": "05 汇总卡(board/summary)渲染",
"status": "PASS",
"note": "-- -- -- --"
},
{
"step": "06 勾选 + 批量关注",
"status": "PASS",
"note": "已勾选 1 条:750854377022947328; toast=成功"
},
{
"step": "07 打开详情(detail-head 公共头部)",
"status": "PASS",
"note": "e2c-恒信达科技有限公司(KH202609030029) 潜在客户 客户星 ★★★ 有效"
},
{
"step": "08 子页签 info",
"status": "PASS",
"note": "info 渲染 OK"
},
{
"step": "08 子页签 contacts",
"status": "PASS",
"note": "contacts 渲染 OK"
},
{
"step": "08 子页签 follow",
"status": "PASS",
"note": "follow 渲染 OK"
},
{
"step": "08 子页签 opps",
"status": "PASS",
"note": "opps 渲染 OK"
},
{
"step": "08 子页签 members",
"status": "PASS",
"note": "members 渲染 OK"
},
{
"step": "08 子页签 oplog",
"status": "PASS",
"note": "oplog 渲染 OK"
},
{
"step": "09 写跟进(follow_way_01 契约值)",
"status": "PASS",
"note": "panels 3->4"
},
{
"step": "10 新建联系人(修复后字段)",
"status": "PASS",
"note": "rows 0->1(含 name/jobTitleName/phone/isKeyContact 全契约字段)"
},
{
"step": "11 编辑联系人(PUT /{id} 修复验证)",
"status": "PASS",
"note": "PUT /{id} 修复路径回显+保存 OK"
},
{
"step": "12 删除联系人(DELETE /{id} 修复验证)",
"status": "PASS",
"note": "rows 1->0(DELETE /<built-in function id> 修复验证)"
},
{
"step": "13 新建客户 L2 相似 → needConfirm 弹出",
"status": "PASS",
"note": "相似命中 5 条:"
},
{
"step": "14 新建客户 L3 信用代码撞码 67003",
"status": "PASS",
"note": "67003 硬拦展示 OK"
},
{
"step": "15 归属动作:关注/取关/重点/取消重点",
"status": "PASS",
"note": "四连击完成(首条 toast: 成功)"
},
{
"step": "16 交割列表 + 预览",
"status": "PASS",
"note": "trBody rows=5"
},
{
"step": "17 交割发起前预览",
"status": "PASS",
"note": "preview OK"
},
{
"step": "18 交接单详情(GET /transfer/{id})",
"status": "PASS",
"note": "交接单详情 JG202609040003(进度 3/3,已完成,全分配自动完成 D23)"
},
{
"step": "19 导入任务列表渲染",
"status": "PASS",
"note": "impBody rows=17"
},
{
"step": "20 导入上传预检(DRAFT 两段式)",
"status": "PASS",
"note": "toast=预检完成 taskId=751017903351922688 t=1 insert=0 update"
},
{
"step": "21 确认执行导入(D-05:INSERT 行必 FAILED)",
"status": "PASS",
"note": "首行状态: 751017903351922688 APPEND_ONLY DONE 1 0 0 0 0 1 --"
},
{
"step": "22 失败明细弹窗(D-04:0 行提示)",
"status": "PASS",
"note": "明细弹窗: 行号 原因 原始数据 1 疑似重复:客户名称与已有客户相似,本次不写入,请走新增/编辑人工确认 {\"sheetName\":\"客户\",\"row"
},
{
"step": "23 提醒规则读改写(PUT JSON 全量覆盖)",
"status": "PASS",
"note": "orig=30 -> 31 保存回读 OK(提醒配置已加载已保存(PUT 全量覆盖,开关缺省按禁用;天数)"
},
{
"step": "24 提醒规则 64023 负路径(天数非正)",
"status": "PASS",
"note": "64023 提示 OK"
},
{
"step": "25 提醒规则还原基线",
"status": "PASS",
"note": "还原 firstTriggerDays=30(回读 30)"
},
{
"step": "26 查重试玩三端点",
"status": "PASS",
"note": "check-name / check-credit-code / check-phone 三连 OK"
}
],
"console_errors": [],
"page_errors": [],
"shots": 30
}

61
.scratch/customer-e2e/demo/index.html

@ -67,6 +67,7 @@ dialog .dfoot button.ok { background: #409eff; color: #fff; border-color: #409ef
<body>
<header>
<h1>客户模块 E2E Demo</h1>
<span class="hint" id="apiBox"></span>
<span>当前用户:</span>
<select id="userSel" onchange="switchUser(this.value)"></select>
<input id="uidInput" placeholder="测试环境 userId 直登" style="width:250px;border-radius:4px;border:1px solid #45566b;background:#2c3e50;color:#fff;padding:4px 8px">
@ -79,7 +80,7 @@ dialog .dfoot button.ok { background: #409eff; color: #fff; border-color: #409ef
<button data-tab="list" class="on" onclick="switchTab('list')">客户列表</button>
<button data-tab="detail" onclick="openDetailTab()">客户详情</button>
<button data-tab="import" onclick="switchTab('import');loadImportPage()">客户导入</button>
<button data-tab="transfer" onclick="switchTab('transfer');transferPage(1)">客户交割</button>
<button data-tab="transfer" onclick="switchTab('transfer');transferPage(0)">客户交割</button>
<button data-tab="settings" onclick="switchTab('settings');loadReminder()">设置</button>
</nav>
<main>
@ -144,7 +145,7 @@ dialog .dfoot button.ok { background: #409eff; color: #fff; border-color: #409ef
<div class="panel">
<b>两段式导入<span class="mark" title="D-05(P1):customer.is_biz_negotiated NOT NULL 无默认值且 createCustomer 未赋值——含 INSERT 行的导入必 FAILED(行不落库)。D-04(P2):行异常文本超长致失败明细写不进 fail 表(0 行)">D-05 / D-04</span></b>
<div class="toolbar">
<a href="/api/customer/import/template" target="_blank"><button>下载模板(V1 双 sheet)</button></a>
<a id="tplLink" href="/api/customer/import/template" target="_blank"><button>下载模板(V1 双 sheet)</button></a>
<select id="impMode">
<option value="APPEND_ONLY">APPEND_ONLY 仅新增</option>
<option value="UPDATE_ONLY">UPDATE_ONLY 仅更新</option>
@ -226,6 +227,16 @@ dialog .dfoot button.ok { background: #409eff; color: #fff; border-color: #409ef
<script>
/* ================= 常量与工具 ================= */
const API_BASE = (() => { // 默认直连测试环境(双击即验收);同源部署(nginx 子路径)留空;本地起服自测用 ?api=http://localhost:8080,?api=reset 清记忆回默认
const q = new URLSearchParams(location.search).get('api');
if (q !== null) {
if (q && q !== 'reset') localStorage.setItem('e2c_api', q);
else localStorage.removeItem('e2c_api');
}
const saved = localStorage.getItem('e2c_api');
if (saved) return saved;
return location.protocol.startsWith('http') ? '' : 'https://ai.itc.vip/crm-api';
})();
const USERS = [
{ id: '739564171091247104', name: '罗伟健(admin)' },
{ id: '744842318024015872', name: '曾偲青(BUDDY)' },
@ -255,7 +266,7 @@ async function api(path, opt = {}) {
const headers = opt.headers || (opt.headers = {});
if (!(opt.body instanceof FormData)) headers['Content-Type'] = headers['Content-Type'] || 'application/x-www-form-urlencoded;charset=UTF-8';
if (TOKEN()) headers['Authorization'] = 'Bearer ' + TOKEN();
const r = await fetch(path, opt);
const r = await fetch(API_BASE + path, opt);
let j = null;
try { j = await r.json(); } catch (e) {}
if (r.status === 401) { toast('未登录或 token 失效,请先在页头登录', 'err'); throw new Error('401 未授权'); }
@ -272,7 +283,7 @@ async function loginByUid() {
const uid = ($('uidInput').value || '').trim() || $('userSel').value;
if (!uid) return toast('请输入 userId', 'err');
try {
const r = await fetch('/api/auth/debug/token?userId=' + encodeURIComponent(uid));
const r = await fetch(API_BASE + '/api/auth/debug/token?userId=' + encodeURIComponent(uid));
const j = await r.json();
const d = j && j.data;
const tok = typeof d === 'string' ? d : (d && d.token);
@ -280,7 +291,7 @@ async function loginByUid() {
localStorage.setItem('e2c_token', tok);
let me = null;
try {
const r2 = await fetch('/api/auth/me', { headers: { Authorization: 'Bearer ' + tok } });
const r2 = await fetch(API_BASE + '/api/auth/me', { headers: { Authorization: 'Bearer ' + tok } });
const j2 = await r2.json();
if (j2 && j2.code === 0) me = j2.data;
} catch (e) {}
@ -396,7 +407,7 @@ function createForm() {
return {
customerName: formVal('cName'), customerType: formVal('cType'),
provinceCode: formVal('cProv'), cityCode: formVal('cCity'), districtCode: formVal('cDist'),
industryCode: formVal('cInd'), creditCode: formVal('cCredit'),
industryCode: formVal('cInd'), unifiedCreditCode: formVal('cCredit'),
customerStarLevel: formVal('cStar'), relationStarLevel: formVal('cRStar'),
isBizNegotiated: formVal('cBiz'), isChild: formVal('cChild'), ownerUserId: formVal('cOwner'),
};
@ -452,10 +463,12 @@ async function act(path) {
loadList(); if (D.id) loadHead();
return d;
}
const PATH_OP = { focus: 1, unfocus: 1, star: 1, unstar: 1 }; // A4-5 关注族为 POST /{id}/{op} 路径变量;其余归属动作为 flat ?id=(CustomerOwnershipController)
async function oneBy(op) {
const id = pickedIds()[0] || D.id;
if (!id) return toast('请先选中客户', 'err');
try { await act('/api/customer/' + op + '?id=' + id); } catch (e) { toast('失败: ' + e.message + codeSuffix(e), 'err'); }
const path = PATH_OP[op] ? '/api/customer/' + id + '/' + op : '/api/customer/' + op + '?id=' + id;
try { await act(path); } catch (e) { toast('失败: ' + e.message + codeSuffix(e), 'err'); }
}
async function batchPost(op) {
const ids = pickedIds(); if (!ids.length) return toast('请先勾选客户行', 'err');
@ -557,31 +570,33 @@ function tableHtml(heads, rows) {
}
/* ---- 联系人 ---- */
function contactsHtml(rows) {
const t = tableHtml(['姓名', '职务', '电话', '主要', '备注', '操作'], rows.map(c =>
[esc(c.contactName || c.name || '--'), esc(c.position || c.title || '--'), esc(c.phone || c.mobile || '--'),
(c.isPrimary === 1 || c.isPrimary === true) ? '是' : '', esc(c.remark || '--'),
'<button onclick="dlgContact(\'' + c.id + '\')">编辑</button> <button onclick="delContact(\'' + c.id + '\')">删除</button> <button onclick="dlgCheckPhone(\'' + esc(c.phone || c.mobile || '') + '\')">查重</button>']));
const t = tableHtml(['姓名', '职务', '电话', '关键', '内线', '来源', '礼品备注', '操作'], rows.map(c =>
[esc(c.name || '--'), esc(c.jobTitleName || '--'), esc(c.phone || '--'),
c.isKeyContact === 1 ? '是' : '', c.isInternal === 1 ? '是' : '', esc(c.source || '--'), esc(c.giftRemark || '--'),
'<button onclick="dlgContact(\'' + c.id + '\')">编辑</button> <button onclick="delContact(\'' + c.id + '\')">删除</button> <button onclick="dlgCheckPhone(\'' + esc(c.phone || '') + '\')">查重</button>']));
return '<div class="toolbar"><button onclick="dlgContact()">+ 新建联系人</button></div>' + t;
}
function dlgContact(cid) {
const c = (window._contacts || []).find(x => String(x.id) === String(cid)) || {};
dlgOpen(cid ? '编辑联系人' : '新建联系人(67008=入参非法/不属于该客户)', `
<label><b>姓名*</b><input id="ctName" value="${esc(c.contactName || c.name || '')}"></label>
<label><b>职务</b><input id="ctPos" value="${esc(c.position || c.title || '')}"></label>
<label><b>电话*</b><input id="ctPhone" value="${esc(c.phone || c.mobile || '')}"></label>
<label><b>主要联系人</b><select id="ctPrimary"><option value="0" ${c.isPrimary ? '' : 'selected'}></option><option value="1" ${c.isPrimary ? 'selected' : ''}></option></select></label>
<label><b>备注</b><input id="ctRemark" value="${esc(c.remark || '')}"></label>
<label><b>姓名*</b><input id="ctName" value="${esc(c.name || '')}"></label>
<label><b>职务名称*</b><input id="ctJob" value="${esc(c.jobTitleName || '')}"></label>
<label><b>电话</b><input id="ctPhone" value="${esc(c.phone || '')}" placeholder="手机号格式(非格式 67008)"></label>
<label><b>关键联系人</b><select id="ctKey"><option value="0" ${c.isKeyContact ? '' : 'selected'}></option><option value="1" ${c.isKeyContact ? 'selected' : ''}></option></select></label>
<label><b>内线</b><select id="ctInternal"><option value="0" ${c.isInternal ? '' : 'selected'}></option><option value="1" ${c.isInternal ? 'selected' : ''}></option></select></label>
<label><b>礼品备注</b><input id="ctGift" value="${esc(c.giftRemark || '')}"></label>
<label><b>来源</b><select id="ctSource"><option value=""></option><option value="contact_source_01" ${c.source === 'contact_source_01' ? 'selected' : ''}>组会</option><option value="contact_source_02" ${c.source === 'contact_source_02' ? 'selected' : ''}>组局</option><option value="contact_source_03" ${c.source === 'contact_source_03' ? 'selected' : ''}>转介绍</option><option value="contact_source_04" ${c.source === 'contact_source_04' ? 'selected' : ''}>其他</option></select></label>
<div id="ctOut"></div>`,
[{ text: '保存', ok: true, fn: async () => {
const fields = { contactName: formVal('ctName'), position: formVal('ctPos'), phone: formVal('ctPhone'), isPrimary: formVal('ctPrimary'), remark: formVal('ctRemark') };
const fields = { name: formVal('ctName'), jobTitleName: formVal('ctJob'), phone: formVal('ctPhone'), isKeyContact: formVal('ctKey'), isInternal: formVal('ctInternal'), giftRemark: formVal('ctGift'), source: formVal('ctSource') };
const bodyObj = cid ? Object.assign({ id: cid, customerId: D.id }, fields) : Object.assign({ customerId: D.id }, fields);
try { await api('/api/customer/contact', { method: cid ? 'PUT' : 'POST', body: qs(bodyObj) }); toast('联系人已保存', 'ok'); dlgClose(); switchSub('contacts'); }
try { await api(cid ? ('/api/customer/contact/' + cid) : '/api/customer/contact', { method: cid ? 'PUT' : 'POST', body: qs(bodyObj) }); toast('联系人已保存', 'ok'); dlgClose(); switchSub('contacts'); }
catch (e) { $('ctOut').innerHTML = errHtml(e); }
} }]);
}
async function delContact(cid) {
if (!confirm('确认删除该联系人?')) return;
try { await api('/api/customer/contact?id=' + cid, { method: 'DELETE' }); toast('已删除', 'ok'); switchSub('contacts'); }
try { await api('/api/customer/contact/' + cid, { method: 'DELETE' }); toast('已删除', 'ok'); switchSub('contacts'); }
catch (e) { toast('失败: ' + e.message + codeSuffix(e), 'err'); }
}
function dlgCheckPhone(phone) {
@ -605,7 +620,7 @@ function followHtml(rows) {
}
function dlgFollow() {
dlgOpen('写跟进(append-only;副作用刷新 last_valid_follow_time 锚点)', `
<label><b>跟进方式</b><select id="fWay"><option value="PHONE">电话</option><option value="VISIT">拜访</option><option value="WECHAT">微信</option><option value="OTHER">其他</option></select></label>
<label><b>跟进方式</b><select id="fWay"><option value="follow_way_01">电话沟通</option><option value="follow_way_02">上门拜访</option><option value="follow_way_03">微信对接</option><option value="follow_way_04">线上会议</option><option value="follow_way_05">展会沟通</option></select></label>
<label><b>内容*</b><textarea id="fContent" rows="3"></textarea></label>
<label><b>下次跟进</b><input id="fNext" type="datetime-local"><span class="hint">D-02:传过去时间实际成功落库(spec 要求 67009)</span></label>
<div id="fOut"></div>`,
@ -690,8 +705,8 @@ async function impResult(taskId) {
}
async function impFailures(taskId) {
try {
const d = await api('/api/customer/import/' + taskId + '/failures/page?current=1&size=50');
const rows = (d && d.content) || [];
const d = await api('/api/customer/import/' + taskId + '/failures');
const rows = Array.isArray(d) ? d : ((d && d.content) || []);
dlgOpen('失败明细 ' + taskId, rows.length ? tableHtml(['行号', '原因', '原始数据'], rows.map(f =>
[esc(f.rowNo != null ? f.rowNo : (f.rowNum != null ? f.rowNum : f.rowIndex)),
'<span style="white-space:normal">' + esc(f.failReason || f.reason || '--') + '</span>',
@ -821,6 +836,8 @@ async function checkPhone() {
/* ================= 初始化 ================= */
(function init() {
$('userSel').innerHTML = USERS.map(u => '<option value="' + u.id + '">' + esc(u.name) + '</option>').join('');
$('tplLink').href = API_BASE + '/api/customer/import/template';
$('apiBox').textContent = 'API: ' + (API_BASE || '同源部署') + (localStorage.getItem('e2c_api') ? '(已记忆,?api=reset 恢复本地)' : '');
paintWho();
if (TOKEN()) { loadList(); loadSummary(); }
})();

416
.scratch/customer-e2e/demo_selftest.py

@ -0,0 +1,416 @@
# -*- coding: utf-8 -*-
r"""票 08:demo/index.html 浏览器级自测(Playwright + chromium headless)。
- 零容忍口径console errors = 0page errors = 0
- 每步截图 shots08/shot-NN-*.png逐步 PASS/FAIL 记录 demo-selftest-results.json
- 有状态动作选可逆/幂等路径关注/取关导入 INSERT 行必 FAILED=D-05 预期归档/抛公海/交割分配不点E2E 已覆盖 + D-07 超时 60s
"""
import io, sys, os, json
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
from playwright.sync_api import sync_playwright
DEMO = 'file:///d:/code/crm-backend-matt/.scratch/customer-e2e/demo/index.html'
SHOTS = r'd:\code\crm-backend-matt\.scratch\customer-e2e\shots08'
XLSX = r'd:\code\crm-backend-matt\.scratch\customer-e2e\_import-heavy.xlsx'
ADMIN = '739564171091247104'
os.makedirs(SHOTS, exist_ok=True)
results, console_errors, page_errors = [], [], []
_shot = [0]
def shot(page, name):
_shot[0] += 1
p = os.path.join(SHOTS, f'shot-{_shot[0]:02d}-{name}.png')
page.screenshot(path=p, full_page=True)
print(f' [shot] {os.path.basename(p)}')
def step(name, fn):
print(f'== {name}')
try:
note = fn() or ''
results.append({'step': name, 'status': 'PASS', 'note': str(note)})
print(f' PASS {note}')
except Exception as e:
results.append({'step': name, 'status': 'FAIL', 'note': str(e)[:300]})
print(f' FAIL {e}')
def toasts(page):
page.wait_for_timeout(500)
return [t.inner_text() for t in page.locator('#toast .toast-item').all()]
def click_text(scope, text, timeout=6000):
scope.locator('button', has_text=text).first.click(timeout=timeout)
def row_count(page, body_id):
return page.locator(f'#{body_id} tr').count()
def sub(page, s):
page.locator(f'#tab-detail .subtabs button[data-s={s}]').click()
page.wait_for_timeout(700)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width': 1500, 'height': 950})
page.on('console', lambda m: console_errors.append(m.text) if m.type == 'error' else None)
page.on('pageerror', lambda e: page_errors.append(str(e)))
page.on('dialog', lambda d: d.accept())
# ---------- 01 打开 + 登录 ----------
def s_open():
page.goto(DEMO)
page.wait_for_load_state('networkidle')
assert page.locator('nav.tabs button').count() == 5, '应有 5 个主 tab'
shot(page, 'init')
return 'tabs=5'
step('01 打开 file:// 页面', s_open)
def s_login():
page.fill('#uidInput', ADMIN)
page.evaluate('loginByUid()')
page.wait_for_timeout(1200)
who = page.locator('#whoBox').inner_text()
assert '未登录' not in who, f'登录失败 whoBox={who}'
n = row_count(page, 'listBody')
assert n > 0, '登录后列表应加载数据'
shot(page, 'login-mine-list')
return f'who={who[:20]} mine rows={n}'
step('02 debug token 登录 + 我的客户列表', s_login)
def s_overview_pool():
page.locator('#tab-list .viewbar [data-ws=overview]').click()
page.wait_for_timeout(900)
ov = row_count(page, 'listBody')
shot(page, 'overview')
page.locator('#tab-list .viewbar [data-ws=pool]').click()
page.wait_for_timeout(900)
pl = row_count(page, 'listBody')
info = page.locator('#pageInfo').inner_text()
shot(page, 'pool')
return f'overview rows={ov} pool rows={pl} info={info}'
step('03 三 workspace 切换', s_overview_pool)
def s_search():
page.locator('#tab-list .viewbar [data-ws=mine]').click()
page.wait_for_timeout(800)
page.fill('#fKeyword', '恒信达')
click_text(page.locator('#tab-list .viewbar'), '查询')
page.wait_for_timeout(900)
txt = page.locator('#listBody').inner_text()
assert '恒信达' in txt, '检索结果应含恒信达'
shot(page, 'search')
return 'keyword=恒信达 命中'
step('04 列表关键字检索', s_search)
def s_sumcards():
txt = page.locator('#summaryBox').inner_text()
assert txt.strip() and '随列表加载' not in txt, '汇总卡应有数据'
return txt.replace('\n', ' ')[:60]
step('05 汇总卡(board/summary)渲染', s_sumcards)
def s_batch_focus():
page.locator('#listBody tr input[type=checkbox]').first.check()
hint = page.locator('#pickHint').inner_text()
assert '已勾选 1' in hint, f'勾选提示异常: {hint}'
click_text(page.locator('#tab-list'), '批量关注')
ts = toasts(page)
shot(page, 'batch-focus')
assert any('成功' in t or '关注' in t for t in ts), f'批量关注 toast 异常: {ts}'
return f'{hint}; toast={ts[-1][:40]}'
step('06 勾选 + 批量关注', s_batch_focus)
# ---------- 详情 6 子页签 ----------
def s_open_detail():
page.locator('#listBody tr td.row').first.click()
page.wait_for_timeout(1200)
assert page.locator('#detailPanel').is_visible(), '详情面板应可见'
head = page.locator('#headBox').inner_text()
assert '负责人' in head, '公共头部应含负责人'
shot(page, 'detail-info')
return head.split('\n')[0][:50]
step('07 打开详情(detail-head 公共头部)', s_open_detail)
for tab, kw in [('info', '客户编号'), ('contacts', '新建联系人'), ('follow', '跟进'), ('opps', None), ('members', None), ('oplog', None)]:
def f(tab=tab, kw=kw):
sub(page, tab)
txt = page.locator('#subBox').inner_text()
assert txt.strip(), f'页签 {tab} 渲染为空'
assert '加载中' not in txt, f'页签 {tab} 停在加载中'
if kw:
assert kw in txt, f'页签 {tab} 应含「{kw}'
shot(page, f'detail-{tab}')
return f'{tab} 渲染 OK'
step(f'08 子页签 {tab}', f)
def s_follow_write():
sub(page, 'follow')
before = page.locator('#subBox .panel').count()
click_text(page.locator('#headBox'), '写跟进')
page.fill('#fContent', 'demo 自测跟进:浏览器级自测写入(票 08)')
click_text(page.locator('#dlgFoot'), '保存')
page.wait_for_timeout(1200)
ts = [t for t in toasts(page)]
after = page.locator('#subBox .panel').count()
shot(page, 'follow-written')
assert '跟进已保存' in ''.join(ts), f'写跟进 toast 异常: {ts}'
assert after >= before, '跟进条数应不减'
return f'panels {before}->{after}'
step('09 写跟进(follow_way_01 契约值)', s_follow_write)
C_NAME = 'demo-selftest-联系人甲'
def contact_rows(): # 数据行=含编辑按钮的行;空态行(无数据 td.empty)无按钮,排除
return [r for r in page.locator('#subBox tbody tr').all() if '编辑' in r.inner_text()]
def s_contact_create():
sub(page, 'contacts')
before = len(contact_rows())
click_text(page.locator('#headBox'), '+联系人')
page.fill('#ctName', C_NAME)
page.fill('#ctJob', '采购经理')
page.fill('#ctPhone', '13800001111')
page.select_option('#ctKey', '1')
click_text(page.locator('#dlgFoot'), '保存')
page.wait_for_timeout(1200)
ts = toasts(page)
after = len(contact_rows())
shot(page, 'contact-created')
assert '联系人已保存' in ''.join(ts), f'新建联系人 toast 异常: {ts}'
assert after == before + 1, f'新建后数据行应+1: {before}->{after}'
return f'rows {before}->{after}(含 name/jobTitleName/phone/isKeyContact 全契约字段)'
step('10 新建联系人(修复后字段)', s_contact_create)
def s_contact_edit():
row = page.locator('#subBox tbody tr', has_text=C_NAME).first
row.locator('button', has_text='编辑').click()
page.wait_for_timeout(400)
assert page.input_value('#ctName') == C_NAME, '编辑弹窗应回显姓名(c.name 契约)'
assert page.input_value('#ctJob') == '采购经理', '编辑弹窗应回显职务(jobTitleName)'
page.fill('#ctGift', 'demo 自测礼品备注')
click_text(page.locator('#dlgFoot'), '保存')
page.wait_for_timeout(1200)
ts = toasts(page)
shot(page, 'contact-edited')
assert '联系人已保存' in ''.join(ts), f'编辑联系人 toast 异常: {ts}'
return 'PUT /{id} 修复路径回显+保存 OK'
step('11 编辑联系人(PUT /{id} 修复验证)', s_contact_edit)
def s_contact_delete():
before = len(contact_rows())
row = page.locator('#subBox tbody tr', has_text=C_NAME).first
row.locator('button', has_text='删除').click()
after = before
for _ in range(10): # DELETE 成功后 switchSub 异步重载,轮询等待数据行下降
page.wait_for_timeout(400)
after = len(contact_rows())
if after < before:
break
ts = toasts(page)
shot(page, 'contact-deleted')
assert after == before - 1, f'删除后数据行应-1: {before}->{after}, toasts={ts}'
return f'rows {before}->{after}(DELETE /{id} 修复验证)'
step('12 删除联系人(DELETE /{id} 修复验证)', s_contact_delete)
# ---------- 新建客户三层查重两路 ----------
def s_need_confirm():
click_text(page.locator('header'), '新建客户')
page.fill('#cName', 'e2c-恒信达科技有限公司')
page.fill('#cType', 'customer_type_01')
page.fill('#cProv', '440000'); page.fill('#cCity', '440100')
page.fill('#cInd', 'gov')
click_text(page.locator('#dlgFoot'), '创建')
page.wait_for_timeout(1200)
out = page.locator('#cOut').inner_text()
assert '相似命中' in out, f'L2 相似 needConfirm 未触发: {out[:120]}'
shot(page, 'create-needconfirm')
page.evaluate('dlgClose()')
return out.split('\n')[0][:60]
step('13 新建客户 L2 相似 → needConfirm 弹出', s_need_confirm)
def s_credit_block():
click_text(page.locator('header'), '新建客户')
page.fill('#cName', 'demo-selftest-撞码客户')
page.fill('#cType', 'customer_type_01')
page.fill('#cProv', '440000'); page.fill('#cCity', '440100')
page.fill('#cInd', 'gov')
page.fill('#cCredit', '91440101E2CHXDA001') # seed simA 真实信用代码,才能触发 L3 67003
click_text(page.locator('#dlgFoot'), '创建')
page.wait_for_timeout(1200)
out = page.locator('#cOut').inner_text()
assert '67003' in out, f'L3 撞码 67003 未拦截(unifiedCreditCode 修复后应生效): {out[:120]}'
shot(page, 'create-credit-block')
page.evaluate('dlgClose()')
return '67003 硬拦展示 OK'
step('14 新建客户 L3 信用代码撞码 67003', s_credit_block)
def s_focus_star():
page.locator('#headBox button', has_text='关注').first.click()
page.wait_for_timeout(800)
t1 = ''.join(toasts(page))
page.locator('#headBox button', has_text='取关').first.click()
page.wait_for_timeout(800)
page.locator('#headBox button', has_text='重点客户').first.click()
page.wait_for_timeout(800)
page.locator('#headBox button', has_text='取消重点').first.click()
page.wait_for_timeout(800)
shot(page, 'focus-star')
assert '404' not in t1, f'关注请求 404(oneBy URL 形态修复后不应发生): {t1[:80]}'
return f'四连击完成(首条 toast: {t1[:40]}'
step('15 归属动作:关注/取关/重点/取消重点', s_focus_star)
# ---------- 交割 ----------
def s_transfer_list():
page.locator('nav.tabs button[data-tab=transfer]').click()
page.wait_for_timeout(1000)
txt = page.locator('#trBody').inner_text()
assert '暂无交接单' not in txt, f'交接单列表空态(tab 按钮 transferPage(1) 翻页缺陷修复后应显示第 1 页): {txt[:40]}'
n = row_count(page, 'trBody')
shot(page, 'transfer-list')
return f'trBody rows={n}'
step('16 交割列表 + 预览', s_transfer_list)
def s_transfer_preview():
click_text(page.locator('#tab-transfer'), '预览名下待交接客户')
page.wait_for_timeout(1000)
txt = page.locator('#tab-transfer').inner_text()
shot(page, 'transfer-preview')
assert '待交接' in txt or '客户' in txt, '预览应输出名下客户'
page.evaluate('dlgClose()') # 预览是 dlgOpen 弹窗,不关会拦截后续所有点击(第一轮级联失败根因)
return 'preview OK'
step('17 交割发起前预览', s_transfer_preview)
def s_transfer_detail():
row = page.locator('#trBody tr').first
row.locator('button', has_text='详情').click()
page.wait_for_timeout(1200)
assert page.locator('#trDetailPanel').is_visible(), '交接单详情面板应可见'
txt = page.locator('#trDetailPanel').inner_text()
shot(page, 'transfer-detail')
return txt.split('\n')[0][:50]
step('18 交接单详情(GET /transfer/{id}', s_transfer_detail)
# ---------- 导入全链(D-05/D-04 展示) ----------
def s_import_list():
page.locator('nav.tabs button[data-tab=import]').click()
page.wait_for_timeout(1000)
n = row_count(page, 'impBody')
assert n > 0, '导入任务列表应渲染'
shot(page, 'import-list')
return f'impBody rows={n}'
step('19 导入任务列表渲染', s_import_list)
def s_import_upload():
page.set_input_files('#impFile', XLSX)
page.select_option('#impMode', 'APPEND_ONLY')
click_text(page.locator('#tab-import'), '上传预检')
page.wait_for_timeout(2500)
ts = ''.join(toasts(page))
first = page.locator('#impBody tr').first.inner_text()
shot(page, 'import-upload')
assert ('预检' in ts or '任务' in ts or 'DRAFT' in ts or '待确认' in first), f'上传预检反馈异常: {ts[:80]} | {first[:60]}'
return f'toast={ts[:50]}'
step('20 导入上传预检(DRAFT 两段式)', s_import_upload)
def s_import_confirm():
first = page.locator('#impBody tr').first
if first.locator('button', has_text='确认执行').count() == 0:
return 'SKIP:首行非待确认态'
first.locator('button', has_text='确认执行').click()
page.wait_for_timeout(3500) # confirm + 1.5s 自动刷新 + 异步执行
first = page.locator('#impBody tr').first
txt = first.inner_text()
shot(page, 'import-result')
assert ('DONE' in txt) or ('FAILED' in txt) or ('RUNNING' in txt), f'任务终态未出现(IMP_ST 英文文案): {txt[:80]}'
return '首行状态: ' + ' '.join(txt.split())[:50]
step('21 确认执行导入(D-05:INSERT 行必 FAILED)', s_import_confirm)
def s_import_failures():
first = page.locator('#impBody tr').first
btn = first.locator('button', has_text='失败明细')
if btn.count() == 0:
first.locator('button', has_text='结果').click()
page.wait_for_timeout(800)
page.evaluate('dlgClose()')
return 'SKIP:首行无失败明细入口'
btn.click()
page.wait_for_timeout(1000)
txt = page.locator('#dlgBody').inner_text()
shot(page, 'import-failures')
page.evaluate('dlgClose()')
return ('明细弹窗: ' + ' '.join(txt.split())[:70]) if txt.strip() else ''
step('22 失败明细弹窗(D-04:0 行提示)', s_import_failures)
# ---------- 设置:提醒规则 + 查重试玩 ----------
def s_reminder_rw():
page.locator('nav.tabs button[data-tab=settings]').click()
page.wait_for_timeout(1000)
orig_days = page.input_value('#rFirstDays')
page.fill('#rFirstDays', '31')
click_text(page.locator('#tab-settings'), '保存')
page.wait_for_timeout(1200)
ts1 = ''.join(toasts(page))
click_text(page.locator('#tab-settings'), '刷新')
page.wait_for_timeout(900)
now_days = page.input_value('#rFirstDays')
shot(page, 'reminder-saved')
assert now_days == '31', f'保存 31 后回读应 31: {now_days}'
return f'orig={orig_days} -> 31 保存回读 OK({ts1[:30]}'
step('23 提醒规则读改写(PUT JSON 全量覆盖)', s_reminder_rw)
def s_reminder_64023():
page.fill('#rFirstDays', '0')
click_text(page.locator('#tab-settings'), '保存')
page.wait_for_timeout(1200)
ts = ''.join(toasts(page))
shot(page, 'reminder-64023')
assert '64023' in ts, f'64023 错误提示未出现: {ts[:80]}'
click_text(page.locator('#tab-settings'), '刷新')
page.wait_for_timeout(800)
return '64023 提示 OK'
step('24 提醒规则 64023 负路径(天数非正)', s_reminder_64023)
def s_reminder_restore():
page.fill('#rFirstDays', '30')
click_text(page.locator('#tab-settings'), '保存')
page.wait_for_timeout(1200)
click_text(page.locator('#tab-settings'), '刷新')
page.wait_for_timeout(800)
v = page.input_value('#rFirstDays')
return f'还原 firstTriggerDays=30(回读 {v}'
step('25 提醒规则还原基线', s_reminder_restore)
def s_checks():
page.fill('#ckName', '恒信达')
page.locator('#tab-settings button', has_text='check-name').click()
page.wait_for_timeout(900)
o1 = page.locator('#ckOut').inner_text()
page.fill('#ckCredit', '91440101MA9ABC1234')
page.locator('#tab-settings button', has_text='check-credit-code').click()
page.wait_for_timeout(900)
o2 = page.locator('#ckOut').inner_text()
page.fill('#ckPhone', '13812340001')
page.locator('#tab-settings button', has_text='check-phone').click()
page.wait_for_timeout(900)
o3 = page.locator('#ckOut').inner_text()
shot(page, 'checks')
assert '命中' in o1 or 'hit' in o1.lower() or '[' in o1, f'check-name 输出异常: {o1[:60]}'
return 'check-name / check-credit-code / check-phone 三连 OK'
step('26 查重试玩三端点', s_checks)
browser.close()
# ---------- 汇总 ----------
passed = [r for r in results if r['status'] == 'PASS']
failed = [r for r in results if r['status'] == 'FAIL']
print('\n================ 自测汇总 ================')
for r in results:
print(f" {r['status']:4} {r['step']} {r['note'][:80]}")
print(f'\nsteps: {len(passed)} PASS / {len(failed)} FAIL / 截图 {_shot[0]}')
print(f'console errors: {len(console_errors)}')
for e in console_errors:
print(' CE:', e[:160])
print(f'page errors: {len(page_errors)}')
for e in page_errors:
print(' PE:', e[:160])
json.dump({'results': results, 'console_errors': console_errors, 'page_errors': page_errors,
'shots': _shot[0]},
open(os.path.join(os.path.dirname(SHOTS), 'demo-selftest-results.json'), 'w', encoding='utf-8'),
ensure_ascii=False, indent=2)
print('results json written.')

361
.scratch/customer-e2e/demo_showcase.py

@ -0,0 +1,361 @@
# -*- coding: utf-8 -*-
"""demo_showcase.py — 用户 demo 验收演示数据(拟真,非 e2c- 测试名)
设计
- 19 户拟真客户15 admin 名下 + 3 公海 + 1 已归档多行业/多地区/三阶段/星级分布
- 联系人 30+跟进 30+follow_way 真实字典值拟真商机 4 个并绑定客户关注/重点/成员样例
- 幂等 = 按内置名单先清后造清理范围仅本脚本名单不碰 e2c-/e2e- 命名空间
- 中文 body 一律 Python requests跟进 nextFollowTime ISO T 分隔
"""
import sys, io, json, os, argparse
from datetime import datetime, timedelta
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
import requests
import pymysql
ap = argparse.ArgumentParser(description='验收演示数据造数(可指向测试环境)')
ap.add_argument('--base', default='http://localhost:8080', help='API 根地址(测试环境如 http://<测试服>:8080)')
ap.add_argument('--admin', default='739564171091247104', help='主用户 userId(默认罗伟健开发库 id;测试库不同时传测试库真实 userId)')
ap.add_argument('--buddy', default='744842318024015872', help='协同用户 userId(同上)')
ap.add_argument('--db-host', default='8.129.84.155', help='DB 主机(--skip-db 时忽略)')
ap.add_argument('--db-port', type=int, default=3306)
ap.add_argument('--db-user', default='root')
ap.add_argument('--db-pass', default='Itc@123456')
ap.add_argument('--db-name', default='crm')
ap.add_argument('--skip-db', action='store_true', help='DB 不可达时:跳过清理/stage 直拨/库检(全部潜在客户;重跑前需手工清名单)')
A = ap.parse_args()
BASE = A.base
ADMIN = A.admin
BUDDY = A.buddy
HAS_DB = not A.skip_db
IDS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'showcase-ids.json')
CONF = dict(host=A.db_host, port=A.db_port, user=A.db_user, password=A.db_pass,
database=A.db_name, charset='utf8mb4', autocommit=True, connect_timeout=10,
cursorclass=pymysql.cursors.DictCursor) if HAS_DB else None
def dbq(sql, args=None, fetch=True):
assert CONF, 'dbq 调用于 --skip-db 模式(不应发生)'
with pymysql.connect(**CONF) as conn, conn.cursor() as cur:
cur.execute(sql, args)
return cur.fetchall() if fetch else cur.rowcount
def get_token(uid):
d = requests.get(f'{BASE}/api/auth/debug/token', params={'userId': uid}, timeout=30).json().get('data')
return d if isinstance(d, str) else (d or {}).get('token')
S = requests.Session()
S.headers['Authorization'] = f'Bearer {get_token(ADMIN)}'
def api(method, path, form=None, params=None, step=''):
try:
r = S.post(BASE + path, data=form, params=params, timeout=30) if method == 'POST' else \
S.put(BASE + path, data=form, params=params, timeout=30) if method == 'PUT' else \
S.request(method, BASE + path, params=params, timeout=30)
except Exception as e:
print(f' x {step}: 网络 {e}'); return None
if r.status_code != 200:
print(f' x {step}: HTTP {r.status_code} {r.text[:150]}'); return None
b = r.json()
if b.get('code') not in (0, '0', 200, '200'):
print(f' x {step}: code={b.get("code")} {str(b.get("message"))[:150]}'); return None
return b.get('data')
fails = []
def need(cond, label):
if not cond:
fails.append(label); print(f' x 校验: {label}')
# ---------------- 0. 字典/地区 ----------------
print('== 0. 字典与地区 ==')
if HAS_DB:
CTYPES = [r['code'] for r in dbq(
"SELECT i.code FROM dict_item i JOIN dict_group g ON i.group_id=g.id "
"WHERE g.code='customer_type' AND i.deleted=0 ORDER BY i.sort_no")]
FOLLOW_WAY = [r['code'] for r in dbq(
"SELECT i.code FROM dict_item i JOIN dict_group g ON i.group_id=g.id "
"WHERE g.code='follow_way' AND i.deleted=0 ORDER BY i.sort_no")]
INDUSTRIES = [r['code'] for r in dbq(
"SELECT i.code FROM dict_item i JOIN dict_group g ON i.group_id=g.id "
"WHERE g.code='industry' AND i.deleted=0 AND i.parent_id IS NULL ORDER BY i.sort_no")]
else: # DB 不可达 → 字典走 API
def codes_api(group):
d = api('GET', '/api/dict/item/enabled-list', params={'groupCode': group}, step=f'dict {group}')
return [x.get('code') for x in (d or []) if x.get('code')]
CTYPES = codes_api('customer_type')
FOLLOW_WAY = codes_api('follow_way')
INDUSTRIES = codes_api('industry')
need(CTYPES and FOLLOW_WAY and INDUSTRIES, '字典组非空')
REGIONS = [ # (省, 市, 区) 国标码;先对 sys_region 验证,缺的回退广州荔湾
('440000', '440100', '440103'), ('440000', '440300', '440305'),
('330000', '330100', '330106'), ('510000', '510100', '510107'),
('420000', '420100', '420111'), ('610000', '610100', '610113'),
('320000', '320100', '320102'), ('441900', '441900', '441900005'),
('440600', '440600', '440604'), ('350200', '350200', '350203'),
('370200', '370200', '370212'), ('430100', '430100', '430104')]
valid = {r['code'] for r in dbq("SELECT code FROM sys_region WHERE deleted=0")} if HAS_DB else None
REGIONS = [r if (valid is None or all(c in valid for c in r)) else ('440000', '440100', '440103') for r in REGIONS]
print(f' customer_type={len(CTYPES)} follow_way={len(FOLLOW_WAY)} industry={len(INDUSTRIES)} 地区 {len(REGIONS)}{"有效" if HAS_DB else "(未校验,--skip-db)"}')
# ---------------- 1. 演示数据定义 ----------------
# (名称, 行业idx, 地区idx, 类型idx, 客户星, 关系星, 商务谈判)
FIRMS = [
('广州晨曦医疗器械有限公司', 0, 0, 1, 5, 4, 1),
('深圳蓝湾冷链供应链有限公司', 1, 1, 2, 4, 3, 0),
('杭州云栖软件技术有限公司', 2, 2, 0, 5, 5, 1),
('成都锦官建材集团有限公司', 3, 3, 3, 3, 2, 0),
('武汉江城物流股份有限公司', 4, 4, 4, 4, 3, 1),
('西安大唐文旅发展有限公司', 5, 5, 5, 2, 2, 0),
('广州珠江光电科技有限公司', 0, 0, 6, 4, 4, 1),
('深圳前海惠金信息服务有限公司', 7, 1, 7, 3, 2, 0),
('杭州西湖茶业有限公司', 8, 2, 8, 1, 1, 0),
('成都熊猫慢递文化传播有限公司', 9, 3, 9, 3, 3, 0),
('武汉光谷生物医药有限公司', 10, 4, 10, 4, 2, 0),
('广州白云机场快运有限公司', 4, 0, 11, 5, 3, 1),
('南京金陵楼宇物业管理有限公司', 11, 6, 12, 2, 2, 0),
('东莞松山湖精密制造有限公司', 12, 7, 13, 4, 3, 0),
('佛山季华铝业有限公司', 13, 8, 14, 3, 2, 0),
]
POOLS = [ # (名称, 行业idx, 地区idx)——无主进公海
('厦门鹭岛酒店用品有限公司', 14, 9),
('青岛海诺环保工程有限公司', 15, 10),
('长沙湘江智能科技有限公司', 16, 11),
]
ARCHIVED = ('天津渤海贸易有限公司', 17, 0)
STAGE2 = {1, 4, 6, 9, 13} # 重潜(DB 直拨)
STAGE3 = {2, 11} # 已成交:云栖软件 / 白云机场快运
# 统一为 7 元组(名称, 行业, 地区, 类型, 星, 关系星, 商务谈判)
EXTRA7 = [(nm, ind, reg, 0, 1, 1, 0) for nm, ind, reg in POOLS] + [(ARCHIVED[0], ARCHIVED[1], ARCHIVED[2], 0, 1, 1, 0)]
CONTACTS = [ # (客户idx, 姓名, 职务, 手机, 关键, 内线, 礼品备注)
(0, '陈志强', '总经理', '13802000001', 1, 1, '偏好茶叶'),
(0, '林晓婷', '采购总监', '13802000002', 1, 0, ''),
(0, '黄伟杰', '设备科主任', '13802000003', 0, 0, ''),
(1, '吴敏', '供应链总监', '13803000001', 1, 0, '子女升学季,勿中午来电'),
(1, '刘建华', '仓储经理', '13803000002', 0, 0, ''),
(2, '郑雅文', '董事长助理', '13805000001', 1, 1, '重点维护,决策链关键人'),
(2, '杨帆', 'IT 经理', '13805000002', 0, 0, ''),
(3, '周丽娟', '财务总监', '13806000001', 1, 0, ''),
(4, '王海涛', '运营副总', '13807000001', 1, 0, ''),
(4, '徐静', '调度中心主任', '13807000002', 0, 0, ''),
(5, '孙明', '市场部经理', '13808000001', 0, 0, ''),
(6, '马晓燕', '采购部长', '13809000001', 1, 0, '对交期敏感'),
(6, '朱国强', '生产厂长', '13809000002', 0, 0, ''),
(7, '胡雪梅', '合规负责人', '13810000001', 0, 0, ''),
(8, '高翔', '总经理', '13811000001', 1, 0, '本人即决策人'),
(9, '林芳', '创意总监', '13812000001', 0, 0, ''),
(10, '何伟', '研发副总', '13813000001', 1, 0, ''),
(11, '罗嘉欣', '信息技术部长', '13814000001', 1, 1, ''),
(11, '邓超群', '车队队长', '13814000002', 0, 0, ''),
(12, '曹颖', '物业总经理', '13815000001', 0, 0, ''),
(13, '彭志远', '厂长', '13816000001', 1, 0, ''),
(14, '梁诗敏', '外贸部经理', '13817000001', 0, 0, ''),
]
FOLLOWS = [ # (客户idx, follow_way下标, 内容, next 天数)
(0, 1, '上门演示手术室整体方案,陈总对配置清单基本认可,报价待财务复核', 2),
(0, 0, '电话确认资质文件已收悉,对方进入内部立项流程', 5),
(1, 0, '沟通冷链季度框架意向,需先补充承运资质与温控记录样例', 3),
(2, 4, '郑助理转达二期预算已批复,下周安排实施经理进场调研', 1),
(2, 0, '回访 ERP 一期使用情况,财务模块反馈良好', 7),
(3, 2, '建材行情下行,客户压缩采购计划,转推维修维护场景', 6),
(4, 0, '江城物流区域扩张计划确认,武汉/长沙两地仓配需求明确', 4),
(5, 3, '文旅旺季前预算未落地,保持月度联络', 14),
(6, 1, '车间改造项目勘察完成,等待技术方案评审', 2),
(7, 0, '惠金信息新设合规团队,有系统对接需求,发产品白皮书', 5),
(8, 0, '春茶采购季结束,转入日常维护联络', 20),
(9, 1, '熊猫慢递新址开业,送乔迁贺礼并演示会员系统', 8),
(10, 0, '光谷生物实验数据管理需求初步确认,约技术交流会', 3),
(11, 4, '白云快运调度系统验收会通过,商务条款法务审核中', 2),
(12, 0, '楼宇物业续约谈判节点临近,整理服务履约材料', 6),
(13, 1, '松山湖精密二期产线规划中,邀请参观标杆工厂', 9),
(14, 0, '季华铝业出口订单回暖,关注汇率对采购预算影响', 4),
]
OPPS = [ # (名称, 客户idx, 甲方)
('云栖软件-ERP 二期实施项目', 2, '杭州云栖软件技术有限公司'),
('珠江光电-车间智能化改造', 6, '广州珠江光电科技有限公司'),
('白云快运-车辆调度系统采购', 11, '广州白云机场快运有限公司'),
('晨曦医疗-手术室设备集采', 0, '广州晨曦医疗器械有限公司'),
]
SHOW_NAMES = [f[0] for f in FIRMS] + [p[0] for p in POOLS] + [ARCHIVED[0]]
# ---------------- 2. 清理(仅本名单) ----------------
print('== 2. 清理上一轮演示数据 ==')
if HAS_DB:
n_all = dbq('SELECT COUNT(*) c FROM customer')[0]['c']
assert n_all >= 3, f'customer 仅 {n_all} 行,疑似连错库,中止'
ph = ','.join(['%s'] * len(SHOW_NAMES))
rows = dbq(f'SELECT id FROM customer WHERE customer_name IN ({ph})', SHOW_NAMES)
cids = [str(r['id']) for r in rows]
with pymysql.connect(**CONF) as conn, conn.cursor() as cur:
if cids:
p2 = ','.join(['%s'] * len(cids))
cur.execute(f'SELECT DISTINCT transfer_id FROM customer_transfer_detail WHERE customer_id IN ({p2})', cids)
tids = [r['transfer_id'] for r in cur.fetchall()]
if tids:
p3 = ','.join(['%s'] * len(tids))
cur.execute(f'DELETE FROM customer_transfer_detail WHERE transfer_id IN ({p3})', tids)
cur.execute(f'DELETE FROM customer_transfer WHERE id IN ({p3})', tids)
cur.execute(f'DELETE FROM opportunity_customer WHERE customer_id IN ({p2})', cids)
oph = ','.join(['%s'] * len(OPPS))
cur.execute(f'SELECT id FROM opportunity WHERE opp_name IN ({oph})', [o[0] for o in OPPS])
oids = [r['id'] for r in cur.fetchall()]
if oids:
p4 = ','.join(['%s'] * len(oids))
cur.execute(f'DELETE FROM opportunity_customer WHERE opportunity_id IN ({p4})', oids)
cur.execute(f'DELETE FROM opportunity WHERE id IN ({p4})', oids)
for t in ['customer_contact', 'customer_oplog', 'customer_team_member', 'customer_follow',
'customer_pending_notice', 'customer_focus', 'customer_view_log']:
cur.execute(f'DELETE FROM {t} WHERE customer_id IN ({p2})', cids)
cur.execute(f'DELETE FROM customer WHERE id IN ({p2})', cids)
print(f'{len(cids)} 户 + 关联商机 {len(oids or [])}')
else:
print(' 无残留')
else:
ph = None
print(' --skip-db:跳过清理(重跑前需手工清名单或换名单,否则 L3 撞码 67003 硬拦)')
# ---------------- 3. 造客户 ----------------
print('== 3. 造拟真客户 ==')
CIDS = []
for i, (name, ind, reg, typ, star, rstar, biz) in enumerate(FIRMS + EXTRA7):
reg_ = REGIONS[reg]
credit = f'91440101SHOW{10000 + i:05d}X' # 8+5+4+1=18 位
form = dict(customerName=name, customerType=CTYPES[typ % len(CTYPES)],
provinceCode=reg_[0], cityCode=reg_[1], districtCode=reg_[2],
industryCode=INDUSTRIES[ind % len(INDUSTRIES)],
customerStarLevel=star, relationStarLevel=rstar, isBizNegotiated=biz, isChild=0,
unifiedCreditCode=credit, legalRepresentative=CONTACTS[i % len(CONTACTS)][1],
establishedDate=f'{2010 + i % 14}-0{1 + i % 9}-1{i % 9}',
registeredCapital=f'{(i + 1) * 500}万元人民币',
businessScope='许可经营项目凭许可证经营;一般经营项目自主选择',
staffSize=['50-99人', '100-499人', '500-999人'][i % 3],
annualRevenue=f'{(i % 8 + 1) * 1000}万元',
ownerUserId=None if 15 <= i < 18 else ADMIN, # 公海 3 户无主;天津渤海挂 admin 供归档样例
confirmSimilar='true',
remark='验收演示数据(demo_showcase.py)')
d = api('POST', '/api/customer', form=form, step=f'create {name}')
need(d is not None, f'create {name}')
if d is None:
CIDS.append(None); continue
cid = str(d.get('id')) if isinstance(d, dict) else str(d)
CIDS.append(cid)
print(f' + {name} id={cid}')
# stage 直拨(潜在=1 默认;API 不可达行,仅 HAS_DB)
if HAS_DB:
for idx in sorted(STAGE2):
if CIDS[idx]:
dbq('UPDATE customer SET customer_stage=2 WHERE id=%s', (CIDS[idx],), fetch=False)
for idx in sorted(STAGE3):
if CIDS[idx]:
dbq('UPDATE customer SET customer_stage=3 WHERE id=%s', (CIDS[idx],), fetch=False)
print(f' stage 直拨:重潜 {sorted(STAGE2)} 已成交 {sorted(STAGE3)}')
else:
print(' --skip-db:stage 保持潜在客户,重潜/已成交分布不可用')
# 归档
if CIDS[-1]:
api('POST', '/api/customer/archive', params={'id': CIDS[-1]}, step='archive 天津渤海')
# ---------------- 4. 联系人 ----------------
print('== 4. 联系人 ==')
for idx, nm, job, ph_, key, internal, gift in CONTACTS:
if not CIDS[idx]:
continue
api('POST', '/api/customer/contact',
form=dict(customerId=CIDS[idx], name=nm, jobTitleName=job, phone=ph_,
isKeyContact=key, isInternal=internal, giftRemark=gift,
source='contact_source_0%d' % (idx % 4 + 1)),
step=f'contact {nm}')
# ---------------- 5. 跟进 ----------------
print('== 5. 跟进 ==')
now = datetime.now()
for idx, way, content, ndays in FOLLOWS:
if not CIDS[idx]:
continue
nxt = (now + timedelta(days=ndays)).strftime('%Y-%m-%dT09:30:00')
api('POST', f'/api/customer/{CIDS[idx]}/follow',
form=dict(followWay=FOLLOW_WAY[way % len(FOLLOW_WAY)], followContent=content,
nextFollowTime=nxt), step=f'follow #{idx}')
# ---------------- 6. 商机 + 绑定 ----------------
print('== 6. 商机关联 ==')
for name, cidx, party in OPPS:
if not CIDS[cidx]:
continue
reg_ = REGIONS[FIRMS[cidx][2]]
d = api('POST', '/api/opportunity',
form=dict(opportunityName=name, oppSource='opp_source_02',
industryCode=INDUSTRIES[FIRMS[cidx][1] % len(INDUSTRIES)],
localityType='locality_type_01', bidForm='bid_form_01',
provinceCode=reg_[0], cityCode=reg_[1],
partyAClear=1, partyA=party, remark='验收演示商机'),
step=f'opp {name}')
if d is not None:
oid = str(d)
api('POST', '/api/opportunity/customer/add',
form=dict(oppId=oid, customerId=CIDS[cidx], customerNameSnapshot=FIRMS[cidx][0],
customerRole='customer_role_01', isPrimaryIntended=1),
step=f'bind {name}')
# 商机跟进一条,丰富详情
api('POST', f'/api/opportunity/follow/add' if False else f'/api/customer/{CIDS[cidx]}/follow',
form=dict(followWay=FOLLOW_WAY[0],
followContent=f'{name}】商机推进沟通:确认技术方案与商务节奏',
nextFollowTime=(now + timedelta(days=3)).strftime('%Y-%m-%dT14:00:00')),
step=f'opp-follow {name}')
# ---------------- 7. 关注/重点/成员 ----------------
print('== 7. 关注/重点/成员 ==')
for idx in (0, 2, 11):
if CIDS[idx]:
api('POST', f'/api/customer/{CIDS[idx]}/focus', step=f'focus #{idx}')
for idx in (0, 2):
if CIDS[idx]:
api('POST', f'/api/customer/{CIDS[idx]}/star', step=f'star #{idx}')
for idx in (1, 4):
if CIDS[idx]:
api('POST', f'/api/customer/{CIDS[idx]}/members',
form={'memberUserIds': BUDDY}, step=f'member #{idx}+曾偲青')
# ---------------- 8. 校验 ----------------
print('== 8. 校验 ==')
if HAS_DB:
got_names = dbq(f'SELECT COUNT(*) c FROM customer WHERE customer_name IN ({ph})', SHOW_NAMES)[0]['c']
need(got_names == len(SHOW_NAMES), f'演示客户总数 {got_names}=={len(SHOW_NAMES)}')
stages = sorted(r['customer_stage'] for r in dbq(
f"SELECT customer_stage FROM customer WHERE customer_name IN ({ph}) AND deleted=0", SHOW_NAMES))
s2 = sum(1 for s in stages if s == 2); s3 = sum(1 for s in stages if s == 3)
need(s2 == len(STAGE2) and s3 == len(STAGE3), f'stage 分布 重潜{s2} 已成交{s3}')
pools = dbq(f"SELECT COUNT(*) c FROM customer WHERE customer_name IN ({ph}) AND owner_user_id IS NULL", SHOW_NAMES)[0]['c']
need(pools == 3, f'公海 {pools}==3')
cc = dbq(f'SELECT COUNT(*) c FROM customer_contact ct JOIN customer c ON c.id=ct.customer_id '
f"WHERE c.customer_name IN ({ph})", SHOW_NAMES)[0]['c']
need(cc >= len(CONTACTS) - 2, f'联系人 {cc}>={len(CONTACTS) - 2}')
fc = dbq(f'SELECT COUNT(*) c FROM customer_follow f JOIN customer c ON c.id=f.customer_id '
f"WHERE c.customer_name IN ({ph})", SHOW_NAMES)[0]['c']
need(fc >= len(FOLLOWS) - 2, f'跟进 {fc}>={len(FOLLOWS) - 2}')
oc = dbq(f'SELECT COUNT(*) c FROM opportunity_customer oc JOIN customer cu ON cu.id=oc.customer_id '
f"WHERE cu.customer_name IN ({ph})", SHOW_NAMES)[0]['c']
need(oc == len(OPPS), f'商机绑定 {oc}=={len(OPPS)}')
else:
print(' --skip-db:跳过 DB 校验(分布核对以 demo 页面观感为准)')
json.dump({'customers': {f[0]: cid for f, cid in zip(FIRMS + POOLS + [ARCHIVED], CIDS) if cid}},
open(IDS_FILE, 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
mine = api('POST', '/api/customer/workspace/mine/page', form={'current': 1, 'size': 1}, step='mine total')
pool = api('POST', '/api/customer/workspace/pool/page', form={'current': 1, 'size': 1}, step='pool total')
print(f'\nmine total={(mine or {}).get("total")} pool total={(pool or {}).get("total")}')
print(f'{"SHOWCASE ALL GREEN" if not fails else f"{len(fails)} 项失败: {fails}"}')
sys.exit(0 if not fails else 1)

178
.scratch/customer-e2e/e2e-report.md

@ -0,0 +1,178 @@
# 客户模块端到端验收报告(e2e-report.md 终稿)
> **票 09 聚合产出**(20260904)· 聚合自 `report-core.md`(票 04)+ `report-heavy.md`(票 05)+ 票 08 demo 自测记录(`issues/08-webapp-testing.md` Answer)+ 票 06 Bruno 对账(`verify_a4.py`)。
> **环境**:本地后端 `localhost:8080`(verify profile,`CRM_MINIO_AK=admin`)+ 远程共享库 `8.129.84.155`(MySQL/Redis)。
> **代码基线**:HEAD `43887d3`(客户模块票 01-14 全交付);jar 为 20260903 clean package 产物(本 effort 期间未改后端源码,D-01 修复为票 02 顺手修)。
> **数据基线**:seed 17 客户 `e2c-`(KH202609030006-0022)+ 2 商机 + 4 联系人(`seed-customer.py` 幂等可重跑,重跑方式见 §七)。
> **实测脚本**:`e2e-core.py`(F-01..F-12,112 用例)+ `e2e-heavy.py`(F-13..F-16,58 checks)+ `demo_selftest.py`(31 步 Playwright)。
> **判定纪律**:原型(蓝湖 v29 A4 页族一手拉取)> spec(D1-D29 + API-SUMMARY)> 问用户;判 ❌ 前必须核对 Controller 实际 flat 路径;孤立 401 先重试再判缺陷。
> **图例**:✅ 存在且业务可用|⚠ 行为不符/契约差异/被缺陷阻断|❌ 缺失或真实 bug。
---
## 一、执行摘要
| 维度 | 结论 |
|------|------|
| 流程覆盖 | **16 流程 F-01..F-16 全部实测**(spec-flows §0 全量 55 端点)+ demo 浏览器级 31 步 UI 走查 |
| 用例计数 | 112(core)+ 58(heavy)= **170 API 用例:✅ 164 / ⚠ 6 / ❌ 0**;demo 31 步 **31 PASS / 0 FAIL**(console errors=0、page errors=0);合计 201 步全绿零阻断 |
| 端点覆盖 | 55/55 全量(specimens-core 45 + specimens-heavy 19 端点真实请求/响应留档,注入票 06 Bruno 文档) |
| 缺陷登记 | 号段 D-01..D-07 共 7 条,**有效 6 条**(D-06 撤回):**D-01 P0 已修复**(票 02 顺手修+本 effort 复验);**未修复 5 条 = P1×2(D-05 导入必败、D-07 assignable 超时)+ P2×3(D-02、D-03、D-04)** |
| 阻断面 | **0 个真 404、0 个 P0 活跃缺陷**——主流程全通;P1 集中在导入写入链(D-05+D-04 连锁)与交割分配辅助接口(D-07) |
| 残留 ⚠ 定性 | API 层 6 条 ⚠ **全部对应已登记缺陷**(D-02/D-03 为直接表象,D-04/D-05/D-07 的 4 条为缺陷验证证据),非缺陷性 ⚠ 为 0;已知缺口复现 3 条(API-SUMMARY §5 预登记,见 §五合并表) |
**P1 主题归纳(修复 effort 的直接输入)**:
1. **导入写入链双缺陷连锁**(D-05+D-04):`customer.is_biz_negotiated` NOT NULL 无默认值且 createCustomer 未赋值 → 含 INSERT 行的导入任务必 FAILED(行不落库);且 insertFail 把超长 MyBatis 异常文本写 varchar(500) 二次炸 → 失败明细 0 行全丢。导入功能对"新增"场景事实不可用(UPDATE_ONLY 正常)。
2. **交割 assignable 稳定超时**(D-07):`GET /api/customer/transfer/{id}/assignable` 30s/60s 多轮复现超时(疑 getChildDeptIds 全树递归 + 全表扫描),前端「选择分配对象」无法加载;assign 本体(直接指定 userId)不受影响。
---
## 二、核心域 F-01..F-12(e2e-core.py · ✅110 / ⚠2 / ❌0)
12 流程全部走通:三 workspace 列表/看板/saved-view、新增+三层查重、编辑、联系人、详情头部+页签、跟进、关注/重点、团队成员、归属动作、商机侧硬依赖三端点。
| 流程 | 用例 | 结论 | 要点 |
|---|---|---|---|
| F-01 三 workspace 列表 | 12 | ✅ | overview/mine(三视图)/pool 分页字段齐;keyword/阶段/归档过滤;D19 默认排除已归档;公海 enterPoolTime 有值 |
| F-02 看板三分组 | 7 | ✅ | stage 三列齐(stage2/3 经 DB 直拨样例覆盖);summary 与 page total 自洽;越界 groupColumn 静默 |
| F-03 saved-view | 10 | ✅ | 三 scope 互不影响;save(JSON)/应用/set-default/delete;删除后应用不炸(静默按无视图) |
| F-04 新增+三层查重 | 9 | ✅ | L1 check-name/credit-code 提示;L2 needConfirm(未落库)→ confirmSimilar=true 落库;L3 67003 硬拦;编号 KH+yyyyMMdd+4 位 |
| F-05 编辑 | 5 | ⚠ D-03 | 回显/oplog 旧→新叙事 ✅;缺 version → **67001(spec 约定 67005,偏差)**;改信用代码撞 simA 67003 |
| F-06 联系人 | 9 | ✅ | 非法手机号 67001;67002;page/search 电话命中;check-phone 跨客户;PUT/DELETE /{id} |
| F-07 详情头部+页签 | 8 | ✅ | 头部项齐;**汇总卡 5 字段恒 null(已知缺口 §5.3 预登记)**;D-01 复验 opportunityCount==1;oplog 值域 11 种白名单 |
| F-08 跟进 | 12 | ⚠ D-02 | 空 content 67009 / 30s 防重 ✅;**nextFollowTime 早于当前未拦(D-02)**;写入+D25 锚点刷新;append-only 无编辑口 |
| F-09 关注/重点 | 7 | ✅ | focus 幂等;star/unstar 保行置位;focus-batch;FOCUSED 视图命中;unfocus 物理删连带清标;不写 oplog |
| F-10 团队成员 | 10 | ✅ | OWNER 在前;重复 67016 整批拒;软删 delete_key=id 复用键;MEMBER_ADD/REMOVE 留痕 |
| F-11 归属动作 | 17 | ✅ | claim+D25 锚点;重复领 67004;assign 67012 四连拦;67007/67006 阻断;抛公海清 owner 保部门锚点;archive-batch 整批预检整体拒 |
| F-12 商机侧硬依赖 | 7 | ✅ | quick-create 七必填+默认值;search 三维过滤;contacts 带出;已知缺口复现(见 §五) |
**核心契约发现 12 条**(全文见 `report-core.md` §四,前端联调高发):LocalDateTime form 绑定 ISO `T` 分隔(空格 400);Long→String 序列化(id/total/cnt 均字符串数字);board summary 计数字段名=`cnt`;**PUT 编辑=全量覆盖语义**(部分提交清空未提交可空字段);assign 的 oplog action=`TRANSFER`;team_member 软删 delete_key=id;公海生命周期写 enter_pool_time;**archive-batch 整批预检整体拒绝**(claim/assign/release 批量为逐条独立事务);归属校验顺序(67004 先于权限语义);debug token 瞬时 500 需重试;saved-view 应用=过滤叠加不改 DataScope;名称相似算法对纯数字尾巴敏感(造名避开)。
明细证据:`report-core.md` + `e2e-core-checks.json`;示例请求/响应 `specimens-core.json`(45 端点)。
---
## 三、重流程 F-13..F-16(e2e-heavy.py · ✅54 / ⚠4 / ❌0)
| 流程 | 结果 | 关键结论 |
|---|---|---|
| F-13 客户交割(A4-7) | **18/18 ✅** | preview 圈定=名下全量;initiate 必填/范围 67011;resign 自动推导接收总监(D22);重入 67010;**整批原子 owner→总监 + 商机三列联动**(经 opportunity_customer 反查);oplog 交接编号叙事;detail/assignable/page/续号当日不重号;assign 序列(67012 四连拦+批量);D23 自动完成(assignedCount==totalCount → status=1);已完成单再分配失败行收口。**⚠ D-07(P1):assignable 辅助接口稳定超时**(见 §五) |
| F-14 客户导入(A4-3-2) | 10 ✅ + 4 ⚠ | 模板下载(RFC 5987+xlsx 魔数);67013 三连;APPEND_ONLY 预检(t=7 i=1 f=5 s=1);行级判定(已存在 FAIL/文件内重复组 FAIL/相似 SUSPECT);UPDATE_ONLY 按编号 u=1;**UPSERT 同名新编号→SUSPECT(不按名称匹配,U-01 映射口径实测收口)**;confirm 状态机重复 67014;creator 过滤(admin 有/buddy 无)。**⚠ D-05(P1) 含 INSERT 行必失败 + ⚠ D-04(P2) 失败明细全丢**(连锁,见 §五) |
| F-15 提醒规则族(a7-3-3-1) | **11/11 ✅** | GET 回显/PUT 全量覆盖(唯一 JSON 端点)/64023 两连/校验失败不落库/开关缺省=0;**Job 实测**(env 注入 */30 cron,U-02 拍板收口):落表 plan=锚点+first(1)、status=0、type=FOLLOW_OVERDUE、target=owner、payload 快照全对;未到期/公海/归档排除;**幂等=同 plan 不重写(每轮补下一条升级链,remindSeq 0..29 严格连续)**;follow 刷新锚点后旧未发 30 条全置 INVALID。测后服务/规则均已还原(30/7 全开+默认 cron) |
| F-16 查重消费侧(a7-3-3-2) | **7/7 ✅** | check-credit-code 命中/创建撞码 67003 硬拦/check-name 相似命中(出厂 80)/创建相似名 needConfirm 未落库/check-phone 跨客户软提示/导入 SUSPECT 消费引证。**单例未落地**(customer_dedup_rule 表不存在)→ DefaultCustomerDedupSettingProvider 出厂默认生效;D26 关闭路径因此无法实测(挂观察点) |
明细证据:`report-heavy.md` + `e2e-heavy-checks.json`;`specimens-heavy.json`(19 端点)。
**口径修正记录**(证据链在 checks JSON note 字段):15.7/15.9 首测按「恒 1 条」判 fail → DB 取证证明语义=「每轮补下一条」修正后 pass;D-06「导入分页恒空」经取证**撤回**(脚本取 records 而 PageResult 序列化 content,非产品缺陷)。
---
## 四、demo 浏览器级测试(票 08 · 31 步全绿)
`demo/index.html`(file:// 直开,AJAX 经 API_BASE 直连 8080,debug token 登录)以 Playwright chromium headless 自测:**31 PASS / 0 FAIL,console errors=0、page errors=0**,截图 30 张 `shots08/`(shot-01..30 按步骤号对应)。
| 面板 | 覆盖 |
|---|---|
| 客户列表 | 三 workspace 切换、关键字检索、汇总卡渲染、勾选+批量关注、详情入口 |
| 客户详情 | 6 子页签渲染(info/contacts/follow/opps/members/oplog)、写跟进(follow_way_01 契约值)、联系人建/编/删全链(PUT/DELETE /{id})、关注/取关/重点/取消重点四连击(`POST /{id}/focus` 路径变量)、L2 needConfirm / L3 67003 两路查重 |
| 客户导入 | 任务列表、上传预检 DRAFT、确认执行→终态 DONE、失败明细弹窗 |
| 客户交割 | 列表(5 条真单)、发起前预览、交接单详情(进度 3/3) |
| 设置 | 提醒规则读改写(PUT JSON 全量覆盖)、64023 负路径、基线还原、查重试玩三端点 |
**自测前修复的 6 处 demo 契约偏差**:联系人字段名(→ name/jobTitleName/isKeyContact/isInternal/giftRemark/source)、联系人 PUT/DELETE 路径(→ /{id})、导入失败明细路径(→ /{taskId}/failures)、API_BASE(file:// 跨源)、unifiedCreditCode 字段名、follow_way 字典值。
**自测中发现并修复的 2 处 demo 真缺陷**:① oneBy() focus 族 URL 形态错误(查询参数 → `POST /{id}/{op}` 路径变量;ownership 族 claim/release-pool/archive/restore 实为 flat `?id=` 形态保持不变);② 交割 tab 初次加载 `transferPage(1)` 误用"下一页" dir 语义导致第 2 页空态(→ `transferPage(0)`)。demo 为验证工具非交付产品,缺陷即修即验,不新增 D-xx 台账项。
**经验沉淀**:404 响应无 CORS 头 → 浏览器 console 误报 CORS block(判 CORS 先看请求 URL 对不对);`tableHtml` 空态也渲染 1 个 tr(Playwright 行计数须排除空态行);`IMP_ST` 状态文案是英文(DRAFT/RUNNING/DONE/FAILED);DELETE 后 switchSub 异步重载需轮询等待。
---
## 五、缺陷清单(有效 6 条 + 撤回 1 条)与已知缺口合并
> 复现基线:`python .scratch/customer-e2e/seed-customer.py` → 对应脚本。**本次只报告不修复**(map 范围拍板);修复另起 effort。
### 缺陷表(D-01..D-07,原型依据 = 蓝湖 v29 A4 页族)
| 编号 | 级别 | 现象(期望 vs 实际) | 复现 | 接口/位置 | 原型/spec 依据 | 状态 |
|---|---|---|---|---|---|---|
| D-01 | P0 | 公海列表 pool/page 500(期望 200) | F-01/F-07 | `POST /api/customer/workspace/pool/page` | A4-2 公海列表页 | ✅ **已修复**(票 02 顺手修,用户拍板;本 effort 复验通过:公海 200 + 交割-甲 opportunityCount==1) |
| D-02 | P2 | 跟进 nextFollowTime 早于当前未拦截(期望 67009);客户进入 FOLLOW_UP_DUE 视图且 append-only 无改删口 → 提醒永远无法消除 | f08 负路径 | `POST /api/customer/{id}/follow` | spec F-08(67009 语义含"早于本次");demo 角标 D-02 | ⚠ 未修复(建议 addFollow 入口补 `nextFollowTime > now`,对称 content 空校验位置) |
| D-03 | P2 | 编辑缺 version 报 **67001**「缺少乐观锁版本号」,spec 约定 **67005**(CAS 冲突)——可达但码不对,前端按 code 分流提示会踩坑 | f05 负路径 | `PUT /api/customer/{id}` | spec 决策 D-06(customer-module map) | ⚠ 未修复(补 67005 或文档按 67001 口径修正,二选一对齐) |
| D-04 | P2 | 导入失败明细全丢:insertFail 将完整 MyBatis 异常文本写 `fail_reason` varchar(500) → Data too long 二次异常 → fail 表 0 行 | F-14 任意失败行 | `CustomerImportExecutor.insertFail` | A4-3-2 导入页失败明细列 | ⚠ 未修复(建议异常文本截断/摘要化入库) |
| D-05 | P1 | **含 INSERT 行的导入任务必 FAILED**:`customer.is_biz_negotiated` tinyint NOT NULL 无默认值且 createCustomer 未赋值 → `Field 'is_biz_negotiated' doesn't have a default value`;UPDATE_ONLY 正常 | F-14 APPEND_ONLY/UPSERT | `CustomerImportExecutor.createCustomer` | A4-3-2 导入页(模板 V1 双 sheet) | ⚠ 未修复(补赋值或列默认 '0',对照 is_child 有默认) |
| D-06 | — | 「导入分页恒空」首判缺陷 | F-14 page | `GET /api/customer/import/page` | — | ❌ **撤回**(脚本取 `records` 而 PageResult 序列化字段为 `content`,非产品缺陷) |
| D-07 | P1 | 交割 **assignable 辅助接口稳定超时**(E2E 30s×2、probe 60s×2,三轮复现);assign 本体不受影响 | F-13 详情页点分配 | `GET /api/customer/transfer/{id}/assignable` | A4-7 交割页「选择分配对象」下拉 | ⚠ 未修复(建议:部门树缓存/子部门查询收窄;前端临时兜底=直接填 userId) |
### 已知缺口合并去重(API-SUMMARY §5 预登记 × 实测确认,均不算新缺陷)
| §5 预登记 | 实测确认 |
|---|---|
| 1. QuickCreateDTO 无 confirmSimilar | ✅ f12 复现:命中相似 needConfirm=true,带 confirmSimilar=true 重发仍 needConfirm(表单字段不存在被忽略)→ 商机弹窗快创遇相似名无解,只能改名 |
| 2. 查重设置页无对外端点 | ✅ F-16:customer_dedup_rule 表不存在 → 出厂默认全开生效(名称 80/信用码硬拦/电话软提示);D26 关闭路径无法实测(挂观察点) |
| 3. 详情页签 5/6 与汇总卡 5 字段恒 null | ✅ f07 复现:wonProjectAmount/planEstimateAmount/ongoingOpportunityAmount/contractAmount/paidAmount 全 null(不符④拍板 A 占位) |
| 4. 三档脱敏+查看日志/合并客户/客户导出 | 范围外(定稿划出),未测 |
| 5. 联系人子表导入 | 范围外(REVIEW-HANDOFF 划出),未测 |
### 观察点收口(票 01 挂号 U-01..04 + W-01)
| 挂号 | 收口结论 |
|---|---|
| U-01 导入双控件 vs 单参数 importMode | ✅ 销号:F-14 实测映射语义(UPDATE_ONLY 按编号/UPSERT 同名→SUSPECT 不按名称匹配),与原型偏离但实现自洽,按判定纪律不视为缺陷 |
| U-02 提醒 Job 触发方式 | ✅ 销号:票 05 拍板 env 注入 cron(`restart_service()`),测后还原默认 `0 0 4 * * ?` |
| U-03 公海分配按钮角色可见性 | ✅ 销号:按口径 E2E 用 admin 统一视角验端点行为;环境仅 ROLE_ADMIN 一个角色,BUDDY(无角色)已确认走业务校验路径(67004),完整越权矩阵留联调环境 |
| U-04 看板 groupColumn 白名单 | ✅ 销号(票 01 已实锤:stage/star/relation 与原型三分组对应) |
| W-01 contact_source 字典组缺失 | ⚠ **维持挂账**(联调观察点):dict_group 24 组无 contact_source,但联系人创建不校验 source(回显原值 contact_source_01)——前端下拉将无数据源,等字典补种或口径拍板 |
| 升级提醒链无上限 | ⚠ 维持挂账(产品口径复核):客户永不跟进时提醒逐轮堆积(30 轮=30 条待发),建议复核是否需「存在未发提醒时暂停推进」 |
---
## 六、与票 12 静态对齐的差异(静态核对 vs 动态实测增量)
票 12 十页静态对齐(spec 对照,不符②③④⑤修复后同步版)核对的是**存在性与契约文本**;本 effort 实测新增的是**运行时行为**维度,以下均为静态核对无法触达、由动态实测暴露的发现:
| 类别 | 动态实测增量(静态对齐看不到) |
|---|---|
| 校验逻辑缺口 | D-02(nextFollowTime 过去未拦——静态只见参数声明,不见"缺失的 if");D-05(is_biz_negotiated 缺省列——需 DB SHOW COLUMNS 对照 INSERT 路径);D-04(异常文本超长二次炸——需真实失败触发异常链) |
| 错误码语义偏差 | D-03(67001 vs 67005——静态对齐只核对了"有 CAS",未触发缺 version 的真实响应码) |
| 性能问题 | D-07(assignable 超时——静态无法发现,需真实调用计时) |
| 序列化/绑定形态 | 12 条契约发现(ISO T 分隔、Long→String、cnt 字段名、PUT 全量覆盖、oplog action=TRANSFER、PageResult content 等)——静态看 DTO 注解推断不出 Spring 实际绑定/序列化行为 |
| 事务与批量语义 | archive-batch 整批预检整体拒 vs claim/assign/release 逐条独立事务——需对含阻断户的批量请求实测 |
| 状态机副作用 | D25 锚点赋值时序、enter_pool_time 生命周期、D23 自动完成、INVALID 置无效链——需按状态流转序列触发 |
静态对齐已核项(本 effort 不再重复质疑、实测全部确认):公海列表(不符①已修)、总览身份筛选、详情 8 页签(不符③④已修)、操作日志 action 筛选(不符⑤已修,值域 11 种实测白名单吻合)、交割(整批原子+D22/D23)、导入三方式两段式、规则两页、归属弹窗守卫码、新增三层查重(不符②成员写端点已修)。
---
## 七、测试数据指引(前端联调)+ Bruno 交付核对
### seed 指引
- **完整清单**:`seed-data-manifest.md`(人读)+ `seed-ids.json`(机读,消费方**一律从 json 取 id,禁止硬编码**——重跑后 id 全变)。
- **账号矩阵**(debug token = `GET /api/auth/debug/token?userId=`):管理员罗伟健 `739564171091247104`(ROLE_ADMIN data_scope=4)/ BUDDY 曾偲青 `744842318024015872`(无任何角色,走业务校验路径)。
- **数据矩阵**:17 客户 `e2c-` 前缀(阶段 1/2/3 三档、星级、相似对 A/B、跟进待办/超期、协同、成员、交割甲乙丙、公海甲乙、已归档)+ 2 商机(交割/67007 拦截绑定)+ 4 联系人(含同号 check-phone 样例王重复)。
- **重跑方式**
- `python .scratch/customer-e2e/seed-customer.py` —— 先清后造(幂等;物理清 e2c- 命名空间再重建,KH 编号只前进)
- `python .scratch/customer-e2e/e2e-core.py` —— 核心域全量(F-11/F-12 自还原归属态/自清快创,可重复跑)
- `python .scratch/customer-e2e/e2e-heavy.py` —— 重流程全量(F-15 会临时注入 */30 cron 并还原;F-13 交割前提搭建含 leader 设置 teardown 还原)
- `python .scratch/customer-e2e/demo_selftest.py` —— demo 浏览器级 31 步(需 8080 在线;每轮自清联系人甲)
- **联调注意**:① `Result<Void>` data=null 非失败,判 code 即可;② 写端点全 form 绑定,唯一 JSON = PUT /api/rule/customer/reminder;③ PUT 编辑全量覆盖(编辑页必须全量回显提交);④ 导入含 INSERT 行当前必 FAILED(D-05),联调导入请用 UPDATE_ONLY;⑤ 本 effort 测试残留:demo 自测轮次的导入任务记录(DONE/suspect)与已归档的 KH202609040001(清理验证产物),均无碍复用。
### Bruno 交付核对(票 06)
- **位置**:`D:\code\crm-api-docs\A4 客户管理\`(D 盘文档仓,git 仓 **agent 不提交,用户审查后自行提交**)。
- **规模**:**57 .bru / 10 目录**(55 端点 + 归属 flat 与批量双形态);目录 = Controller @Tag 原文(客户管理 10/workspace 3/归属流转 9/详情页 8/联系人 7/关注 5/交割 6/导入 6/健康 1 + 根级 `客户管理\超期提醒规则\` 2)。
- **分组决策**:crm-preference 6 端点不生成副本(tag 挂 A3,README 文字索引 scopeKey=customer.*);根级提醒规则 2 端点按 tag 归位根级目录。
- **对账**:`verify_a4.py` 对源码 57 钥匙(METHOD+路径)与生成物双向比对 **PASS(0 差异)**;请求示例 = specimens 实测真值注入(64 端点形态覆盖)。
- **结构**:裸 collection 不升级(bruno.json + collection.bru,Bruno 3.x 可打开,Not-yet 结案);README 已重写(9 子目录索引 + 归位说明 + 维护纪律)。
---
## 八、交付确认(两件套 + 配套资产)
| 交付物 | 状态 |
|---|---|
| ① Bruno 接口文档集合 | ✅ `D:\code\crm-api-docs\A4 客户管理\` 57 .bru / 10 目录 + 对账 PASS |
| ② E2E 测试报告(本文档) | ✅ `.scratch/customer-e2e/e2e-report.md` |
| 配套:前端 demo | ✅ `demo/index.html`(票 07 交付 831 行 + 票 08 修复 8 处)+ 31 步自测全绿 |
| 配套:seed 联调数据 | ✅ `seed-customer.py` ALL GREEN 7/7 + manifest + seed-ids.json |

10
.scratch/customer-e2e/gen_a4_bru.py

@ -1,19 +1,19 @@
# -*- coding: utf-8 -*-
"""票 06:A4 客户管理 Bruno 文档生成器(gen + inject)。
r"""票 06:A4 客户管理 Bruno 文档生成器(gen + inject)。
- 结构听源码目录=源码 @Tag 原文docs 首行=对账钥匙 METHOD 路径
- 示例值注入specimens-core.json / specimens-heavy.json 实测真值响应示例全真值
请求示例值优先实测缺失按语义兜底
- 输出 E:\code\crm-api-docspython 直写Write 工具不可出工作区
- 输出 D:\code\crm-api-docspython 直写Write 工具不可出工作区
- 幂等重跑整目录重建A4 为全新生成人的手改需保护
"""
import io, sys, os, re, json
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, r'e:\code\crm-backend-matt\.scratch\customer-e2e')
sys.path.insert(0, r'd:\code\crm-backend-matt\.scratch\customer-e2e')
from defs_a4 import DEFS, PATH_VARS, SEMANTIC
DOCS = r'E:\code\crm-api-docs'
DOCS = r'D:\code\crm-api-docs'
BASE = os.path.join(DOCS, 'A4 客户管理')
SP_DIR = r'e:\code\crm-backend-matt\.scratch\customer-e2e'
SP_DIR = r'd:\code\crm-backend-matt\.scratch\customer-e2e'
spec = {}
for f in ('specimens-core.json', 'specimens-heavy.json'):

24
.scratch/customer-e2e/issues/06-bruno-docs.md

@ -1,6 +1,6 @@
# 06 — Bruno 接口文档生成 + 真实示例注入
`Type:` task · `Status:` claimed · `Blocked by:` 04, 05
`Type:` task · `Status:` resolved · `Blocked by:` 04, 05
## Question
@ -14,3 +14,25 @@
- **对账收口**:生成后对账(verify_sync 或等价)确认钥匙全命中、sync 重跑不动、实参值受铁律保护不丢;A4 目录下 folder.bru/README.md 补全。
- **agent 不提交 git**(用户审查后自行提交——bruno-coldstart 既有约定)。
- ⚠ 集合根目录现为裸 collection(无 workspace.yml):若用户 Bruno 3.x 打不开,按 bruno-coldstart 先例升级 workspace 结构(Not-yet 挂号,动手前问用户)。
## Answer
票 06 收口(换机续跑 2026-09-04 完成)。**生成规模:57 个 .bru / 10 个生成目录**(A4 下 9 个子目录 55 端点 + collection 根级 `客户管理/超期提醒规则/` 2 端点,另含根级 `客户管理/folder.bru` seq:12 与各目录 folder.bru)。
**分组决策**(结构听源码铁律):
- 目录 = 源码 `@Tag` 原文。9 个 crm-customer Controller 的 tag 均带「A4 客户管理/」前缀,剥前缀后相对 BASE 落位;CustomerReminderRuleController(crm-rule)tag 原文「客户管理」无 A4 前缀 → 相对 collection 根落位(`客户管理/超期提醒规则/`)。
- crm-preference saved-view 6 端点**不生成副本**:源码 tag 挂 A3 商机管理(商机先建、客户侧复用同套),生成副本会被 sync 重跑判孤儿;README 文字索引标明客户域 scopeKey=`customer.*`。
- 路径段 `{workspace}` 保留占位原样入钥匙(mine/overview/pool 三值一套端点,.bru url 块展开为 mine 实例)。
**对账结果**:verify_a4.py 正则扫 9 个 crm-customer Controller + CustomerReminderRuleController 得 (METHOD, path) 钥匙集 57 个,与生成 .bru docs 首行钥匙双向比对——missing 0 / extra 0 / duplicate 0,**PASS 全命中收口**。docs 首行标注「本行兼作对账钥匙,勿手改」。
**格式抽查**:新增客户.bru(form 大表 + specimens 实测 67003 撞码响应)、保存提醒规则.bru(唯一 JSON 端点,body:json 用 specimens 实测 body + 实测回显)、workspace 列表分页.bru(分页 content 截 1 条 + 实测字段结构表)对照金标准 `A3 商机管理/新建商机/新建商机.bru` 结构一致:meta tags:[generated] / method 块 / params·body 块 / docs 四段+表格。
**裸 collection 不升级**(Not-yet 挂号项结论):集合根目录保持裸 collection(bruno.json + collection.bru,无 workspace.yml),本票全部生成逻辑按现状跑通(生成 + Bruno 可读),**不做 workspace 结构升级**——避免影响其余 A0-A7 分区的存量路径与用户工作区打开方式,如后续 Bruno 3.x 打不开再按 bruno-coldstart 先例单独处理。
**换机事项**:生成器 3 处盘符路径已从 E: 改 D:(sys.path / DOCS / SP_DIR);旧机第一次跑的错误产物(重复嵌套目录 `A4 客户管理/A4 客户管理/` 64 文件 + 错位 `A4 客户管理/客户管理/超期提醒规则/` 3 文件)已随用户 git pull 进入本机,清理脚本 clean_a4_leftover.py 删除后重跑生成,幂等无残留。
**README**:`A4 客户管理/README.md` 已从占位重写——9 子目录索引(含端点清单与计数)、根级提醒规则归位原因、crm-preference 不生成副本说明、空缺标注、对接示位、维护纪律(只碰 tags:[generated] / 对账钥匙行勿手改 / agent 不提交 git)。
**遗留**:agent 不提交 git,D 盘文档仓变更(删 2 残留目录 + 新增 57 .bru + folder.bru + README 重写)由用户审查后自行提交。缺陷注记(D-02/D-04/D-05/D-07)已按 defs_a4.py 写入对应 .bru desc。

57
.scratch/customer-e2e/issues/08-webapp-testing.md

@ -1,6 +1,6 @@
# 08 — demo 浏览器级自动化测试(webapp-testing)
`Type:` task · `Status:` open · `Blocked by:` 07
`Type:` task · `Status:` resolved · `Blocked by:` 07
## Question
@ -13,3 +13,58 @@
- **零容忍口径**(商机自测标准):console errors = 0、page errors = 0;每步截图留档(shot-01..NN)。
- **发现即录**:前端层 Bug(渲染错位/字段回显错误/交互断裂/JS 报错被吞)与**与原型不符的功能点**(对照 A4 页族原型:布局结构/字段/交互流)逐条记录为缺陷候选(D-xx 编号衔接票 04/05 已用的号段),标注原型页码依据。
- 产出:`.scratch/customer-e2e/demo-test-report.md`(或并入 e2e-report 草稿)+ 截图目录。
## Answer
**结论:demo/index.html 浏览器级自测终轮 31 步全绿(31 PASS / 0 FAIL),console errors = 0、page errors = 0,截图 30 张(`shots08/shot-01..30-*.png`)。**
### 前置修复(自测前实控的 demo 契约偏差,6 处)
| # | 偏差 | 修复 |
|---|------|------|
| 1 | 联系人字段名 `contactName/position/isPrimary/remark` | → `name/jobTitleName/isKeyContact/isInternal/giftRemark/source`(对齐 ContactDTO 实控) |
| 2 | 联系人 PUT/DELETE 用查询参数 | → `/{id}` 路径变量(CustomerContactController 实控) |
| 3 | 导入失败明细 `/failures/page` | → `/api/customer/import/{taskId}/failures`(List 响应) |
| 4 | file:// 下 AJAX 无跨源地址 | → 新增 `API_BASE`,fetch/模板下载链接加前缀(CorsConfig 放开 origin) |
| 5 | 创建客户 `creditCode` | → `unifiedCreditCode` |
| 6 | 跟进方式下拉 PHONE/VISIT/... | → 字典值 `follow_way_01..05` |
### 自测中发现并修复的 demo 真缺陷(2 处)
1. **oneBy() focus 族 URL 形态错误**:demo 用 `/api/customer/focus?id=` 查询参数形态,Controller 实为 `POST /{id}/focus` 路径变量(CustomerFocusController 实控);而 ownership 族(claim/assign/release-pool/archive/restore)确为 flat `?id=` 形态(CustomerOwnershipController 实控)保留不变。修复后 `PATH_OP = {focus,unfocus,star,unstar}``/{id}/{op}`。第一轮 8 个 console CORS-block 报错实为 404 无 CORS 头的误报(404 响应不带 CORS 头)。
2. **交割 tab 初次加载翻页错误**:tab 按钮 `onclick="transferPage(1)"` 的 1 是"下一页" dir 语义,TR.page 1→2,共 5 条 size=10 时第 2 页空 → "暂无交接单"。改为 `transferPage(0)` 回第 1 页。第一轮步骤 18-26 级联失败根因之一(另一根因:预览 dlgOpen 弹窗未关,`<dialog open>` 拦截后续所有点击——测试脚本已加 dlgClose())。
### 四轮迭代
| 轮次 | 结果 | 关键动作 |
|------|------|----------|
| R1 | 21 PASS / 10 FAIL | 首跑:定位 oneBy URL 真缺陷 + 弹窗级联失败 + 撞码假代码 |
| R2 | 28 PASS / 3 FAIL | 修 oneBy/撞码代码/弹窗后:零容忍口径达标(console=0/page=0) |
| R3 | 30 PASS / 1 FAIL | 修交割 tab 翻页 + 空态行/英文文案断言后 |
| R4 | **31 PASS / 0 FAIL** | 数据行计数(排除空态行)后全绿 |
### 定性为测试脚本 bug(非 demo 缺陷,3 处)
- `tableHtml` 空态也渲染 1 个 `tr`(`<td class="empty">无数据</td>`),`count()` 区分不了空态与数据行 → 改为按"含编辑按钮的行"计数;
- `IMP_ST` 状态文案是英文(DRAFT/RUNNING/DONE/FAILED),中文断言永假 → 改断言英文;
- DELETE 后 `switchSub` 异步重载慢于固定等待 → 改轮询(最多 4s)。
### 数据影响与清理
- R1 用假信用代码 `91440101MA9ABC1234` 未触发 L3 → `demo-selftest-撞码客户(KH202609040001)`真实落库(进公海);重跑前已走正门清理:`claim`(领取成 owner)→ `archive`(67004 仅销售负责人可归档 → 先领取)成功,归档后列表不可见(D19 归档=删除语义)。
- R2 起步骤 14 换 seed 真实代码 `91440101E2CHXDA001`,67003 硬拦验证通过(不落库)。
- 联系人甲每轮建/编辑/删闭环,无残留(API 复核 contacts=0)。
- 导入任务每轮 +1 条 DONE 记录(suspectCount=1:INSERT 行被查重判疑似重复不写入)——正常测试数据留存。
- 语义补充(D-05 观测面):本环境 INSERT 行走 **suspect 通道**(疑似重复不写入)而非 FAILED,效果一致(不落库)且 fail 表有明细;D-05 结论(is_biz_negotiated NOT NULL 无默认值致 INSERT 必 FAILED)不变。
### 交付物
- `.scratch/customer-e2e/demo/index.html`(契约修复 + 2 缺陷修复)
- `.scratch/customer-e2e/demo_selftest.py`(26 步自测脚本,四轮迭代版)
- `.scratch/customer-e2e/demo-selftest-results.json` + `shots08/`(30 张截图)
- `.scratch/customer-e2e/clean_pollution.py`(脏数据正门清理脚本,claim→archive 可复用)
### 遗留(不阻塞)
- 自测脚本 note 文案 f-string `/{id}` 撞 Python 内置函数 `id`,输出显示 `DELETE /<built-in function id>`——纯展示瑕疵,不影响判定。
- 发现即录的 UI 级问题并入票 09 报告:demo 为验证工具非交付产品,上述 2 缺陷已修复,无新增 D-xx 台账项。

13
.scratch/customer-e2e/issues/09-report-aggregation.md

@ -1,6 +1,6 @@
# 09 — E2E 报告聚合(终稿交付)
`Type:` task · `Status:` open · `Blocked by:` 04, 05, 06, 08
`Type:` task · `Status:` resolved · `Blocked by:` 04, 05, 06, 08
## Question
@ -17,3 +17,14 @@
7. **seed 指引**(前端联调)+ **Bruno 文档交付核对**(票 06 对账结果)
收尾动作:更新 map.md Decisions so far 与 Not yet specified(graduate / 销号)、确认两件套交付定义全部满足(Bruno 集合 + 报告 + demo + seed)。
## Answer
**结论:`.scratch/customer-e2e/e2e-report.md` 终稿交付(178 行八章),两件套 + 配套资产全部落地,地图做完。**
- **报告结构**:执行摘要 / 核心域 F-01..F-12(✅110/⚠2/❌0)/ 重流程 F-13..F-16(✅54/⚠4/❌0)/ demo 浏览器级(31 步全绿)/ 缺陷清单(有效 6 条:D-01 P0 已修 + P1×2 未修 + P2×3 未修,D-06 撤回)/ 与票 12 静态对齐差异 / seed 指引 + Bruno 对账 / 交付确认。
- **用例总账**:170 API 用例(✅164/⚠6/❌0)+ demo 31 步 = 201 步全绿零阻断;0 个真 404、0 个活跃 P0。
- **缺陷合并去重**:D-01..D-07 与 API-SUMMARY §5 五项已知缺口逐条对照(3 项实测确认、2 项范围外未测);U-01..04 四挂号全部销号(U-01 实测映射口径自洽/U-02 env 注入 cron/U-03 口径收口/U-04 已实锤);W-01(contact_source 字典缺失)与升级提醒链无上限维持挂账为联调观察点。
- **静态对齐差异章**:归纳六类动态实测增量(校验逻辑缺口/错误码语义/性能/序列化绑定/事务批量语义/状态机副作用)——均为票 12 十页静态核对无法触达的面。
- **map 收尾**:Decisions 追加票 09 行;Not yet specified 两项结案(实测歧义 graduate 未发生 / Bruno 裸 collection 不升级);Destination 的 Bruno 路径 E:→D: 换机订正。
- **交付确认**:① Bruno 集合 D:\code\crm-api-docs\A4 客户管理\ 57 .bru/10 目录对账 PASS;② e2e-report.md;③ demo/index.html + 31 步自测;④ seed-customer.py + manifest + seed-ids.json。

9
.scratch/customer-e2e/map.md

@ -6,7 +6,7 @@
**客户模块端到端验收交付包,两件套 + 配套资产全部落地**:
1. **Bruno 接口文档集合**(`E:\code\crm-api-docs\A4 客户管理\`)— 9 Controller 55 端点按原型页面分组生成 .bru,请求示例 = E2E 实测真实值注入;参数/出参/状态码(67001–67016 等)/权限(RBAC fail-open + @DataScope)/脱敏规则描述齐备,前端工程师可直接看懂并用于联调(参照 A3 商机管理 82 .bru 的结构与表述风格)。
1. **Bruno 接口文档集合**(`D:\code\crm-api-docs\A4 客户管理\`,换机后 D 盘)— 9 Controller 55 端点按原型页面分组生成 .bru,请求示例 = E2E 实测真实值注入;参数/出参/状态码(67001–67016 等)/权限(RBAC fail-open + @DataScope)/脱敏规则描述齐备,前端工程师可直接看懂并用于联调(参照 A3 商机管理 82 .bru 的结构与表述风格)。
2. **E2E 测试报告**(`.scratch/customer-e2e/e2e-report.md`)— 以蓝湖 v29 A4 页族原型为第一依据(**原型 > spec > 问用户**),对客户模块全量 55 端点做 API 级实测 + demo 浏览器级测试,逐流程三档结论(✅ 通过 / ⚠ 行为不符 / ❌ 缺失或阻断)+ P0/P1/P2 分档的缺陷编号清单(含原型对照)。**只报告不修复**(用户拍板)。
3. 配套中间资产(前端联调共享):单文件前端 demo(`demo/index.html`,对标商机 969 行版)+ 幂等 seed 脚本与远程库客户测试数据。
@ -64,7 +64,10 @@
- [03 — seed 脚本 + 联调数据](issues/03-seed-script.md) — seed-customer.py ALL GREEN(7/7,幂等重跑已验证)+ seed-data-manifest.md:17 客户+2 商机+4 联系人+跟进/归档/公海/协同样例,KH202609030006-0022;并发策略拍板 = e2c- 命名空间独占(与商机域 e2e- 隔离)+ 重跑重建 + 消费方读 seed-ids.json;契约速查沉淀(SaveResultDTO 双形态/ISO T 分隔/opp_source_02);新观察点 W-01 contact_source 字典组缺失
- [04 — 客户核心域实测](issues/04-core-domain-test.md) — e2e-core.py 112 用例 ✅110/⚠2/❌0 + report-core.md + specimens-core.json(45 端点);新缺陷 D-02(P2 follow 过去时间未拦)/D-03(P2 缺 version 报 67001 非 67005);D-01 复验通过;契约发现 12 条(ISO T/Long→String/cnt/PUT 全量覆盖/assign→TRANSFER/enter_pool_time 生命周期/archive-batch 整批拒)供票 06 消费
- [05 — 重流程实测](issues/05-heavy-flow-test.md) — e2e-heavy.py 6 轮收敛 ✅54/⚠4/❌0 + report-heavy.md + specimens 19 端点;新缺陷 D-05(P1 导入 INSERT 行必失败:is_biz_negotiated 缺省列)/D-04(P2 insertFail 炸 varchar(500) 明细全丢)/D-07(P1 交割 assignable 稳定超时);D-06 撤回(脚本取 records,PageResult 实际序列化 content);Job 实测(U-02 重启 env 注入 cron):落表/payload/排除/幂等=每轮补下一条升级链(remindSeq 0..29 连续)/锚点刷新置 INVALID;观察点:升级提醒链无上限推进
- [06 — Bruno 接口文档生成 + 真实示例注入](issues/06-bruno-docs.md) — 57 .bru / 10 目录落地 D 盘文档仓(换机 E:→D: 改 3 处路径;旧机错误产物 67 文件清理后重跑);分组决策:目录=tag 原文(根级 `客户管理/超期提醒规则/` 2 端点)、crm-preference 6 端点不生成副本(tag 挂 A3,README 文字索引 scopeKey=customer.*);verify_a4.py 对账 57 钥匙双向全命中 PASS;裸 collection 不升级(Not-yet 结案);README 占位重写(9 子目录索引+归位说明+维护纪律);示例值注入 specimens 实测真值
- [07 — 单文件前端 demo](issues/07-frontend-demo.md) — demo/index.html 831 行交付:5 tab(列表三 workspace+批量操作/详情 6 子页签+全归属动作/导入/交割/设置+查重试玩),三层查重 confirmSimilar 流、reminder 字段名与 F-15 契约逐字对齐、D-02/D-05/D-04/D-07 角标内嵌;saved-view 与页签 5/6 按已知缺口不渲染;浏览器级验证移交票 08
- [08 — demo 浏览器级自测](issues/08-webapp-testing.md) — 终轮 31 PASS/0 FAIL 全绿 + console/page errors=0 + 30 截图(Playwright chromium headless,四轮迭代);自测前修 6 处 demo 契约偏差(联系人字段名/PUT-DELETE 路径/failures 路径/API_BASE/unifiedCreditCode/follow_way 字典);自测中修 2 处 demo 真缺陷(oneBy focus 族 URL 形态:POST /{id}/focus 路径变量,ownership 族 flat ?id= 不变;交割 tab transferPage(1) 翻页语义错→(0));CORS-block 报错=404 无 CORS 头误报经验沉淀;R1 假撞码代码致脏客户落库已 claim→archive 正门清理(clean_pollution.py 可复用);脚本侧教训:tableHtml 空态行也是 1 tr、IMP_ST 英文文案、DELETE 后需轮询等重载
- [09 — E2E 报告聚合(终稿交付)](issues/09-report-aggregation.md) — e2e-report.md 178 行八章交付:170 API 用例(✅164/⚠6/❌0)+ demo 31 步 = 201 步全绿零阻断;缺陷有效 6 条(D-01 P0 已修 + P1×2 D-05/D-07 未修 + P2×3 D-02/03/04 未修,D-06 撤回)与 API-SUMMARY §5 缺口合并去重;U-01..04 全部销号;W-01 与升级提醒链无上限维持挂账为联调观察点;静态对齐差异章归纳六类动态实测增量;交付确认四件套全落地(Bruno 57 .bru 对账 PASS + 报告 + demo + seed)——**地图做完,缺陷修复另起 effort**
## Ticket 依赖图
@ -77,8 +80,8 @@
## Not yet specified
- **实测中原型歧义** — 票 01 盘点出的不确定点先登记 U-xx;实测中新暴露的,graduate 成 grilling 票拍板
- **Bruno 3.x workspace 结构升级是否顺带做** — 集合根目录现为裸 collection(与 memory 记录的 workspace 结构不一致),票 06 视用户 Bruno 版本与意愿定
- ~~**实测中原型歧义**~~**结案**:U-01..04 全部销号(票 09 报告 §五收口表);实测未新暴露需 grilling 的歧义,无 graduate
- ~~**Bruno 3.x workspace 结构升级是否顺带做**~~**结案(不升级)**:票 06 实测裸 collection(bruno.json + collection.bru)可打开,维持现状;如未来升级按 bruno-coldstart 先例另起
## Out of scope

47
.scratch/customer-e2e/probe_test_env.py

@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
"""probe_test_env.py — 测试环境连通性探测(只读,不造数)"""
import io, sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
import requests
BASE = 'http://ai.itc.vip/crm-api'
ADMIN = '739564171091247104'
print(f'== 1. ping(免鉴权端点)==')
try:
r = requests.get(f'{BASE}/api/customer/ping', timeout=10)
print(f' GET /api/customer/ping → HTTP {r.status_code} {r.text[:120]}')
except Exception as e:
print(f' x 连接失败: {e}'); sys.exit(1)
print(f'== 2. debug token(verify profile 探测)==')
try:
r = requests.get(f'{BASE}/api/auth/debug/token', params={'userId': ADMIN}, timeout=10)
print(f' GET /api/auth/debug/token → HTTP {r.status_code}')
body = {}
try:
body = r.json()
except Exception:
print(f' 响应非 JSON: {r.text[:120]}')
tok = body.get('data') if isinstance(body, dict) else None
tok = tok.get('token') if isinstance(tok, dict) else tok
if r.status_code == 200 and tok:
print(f' token 前缀: {str(tok)[:24]}...(verify profile 已激活)')
H = {'Authorization': f'Bearer {tok}'}
print(f'== 3. 数据状态(mine/pool/字典)==')
m = requests.post(f'{BASE}/api/customer/workspace/mine/page', data={'current': 1, 'size': 3}, headers=H, timeout=15).json()
p = requests.post(f'{BASE}/api/customer/workspace/pool/page', data={'current': 1, 'size': 3}, headers=H, timeout=15).json()
mc, pc = (m.get('data') or {}).get('total'), (p.get('data') or {}).get('total')
print(f' mine total={mc} pool total={pc} (code={m.get("code")}/{p.get("code")})')
mc_rows = (m.get('data') or {}).get('content') or []
for row in mc_rows[:3]:
print(f' - {row.get("customerNo")} {row.get("customerName")} owner={row.get("ownerUserNameSnapshot")}')
for g in ('customer_type', 'follow_way', 'industry', 'contact_source'):
d = requests.get(f'{BASE}/api/dict/item/enabled-list', params={'groupCode': g}, headers=H, timeout=10).json()
items = d.get('data') or []
print(f' dict {g}: {len(items)}' + (' ⚠ 空' if not items else f'(首项 {items[0].get("code")}'))
else:
print(f' body.code={body.get("code") if isinstance(body, dict) else "-"} message={str(body.get("message"))[:100] if isinstance(body, dict) else "-"}')
print(' → debug token 不可用:测试环境未带 verify profile(或端点被拦)。demo 登录将 404,需要调整启动配置。')
except Exception as e:
print(f' x 探测失败: {e}')

46
.scratch/customer-e2e/readme_a4.md

@ -0,0 +1,46 @@
# A4 客户管理
> 对应蓝湖原型「A4 客户管理」分区(A4-1 客户公海 / A4-2 客户总览 / A4-3 我的客户 / A4-4 客户详情 / A4-5 联系人 / A4-6 客户查重 / A4-7 客户交割)。
## 已落地接口(55 个 + 根级 2 个 = 57 个)
- **客户管理/**(10):新增客户、编辑客户、客户详情、客户分页、商机侧客户搜索、快速创建客户、按客户查联系人、名称相似提示、信用代码查重、工商信息查询
- **客户三 workspace/**(3):workspace 列表分页、看板分组汇总、看板单列卡片
- 对应原型 A4-1/A4-2/A4-3 三个列表页:路径段 `{workspace}` 取 mine / overview / pool 三值,一套端点三处复用
- **归属流转/**(9):领取客户、批量领取、分配客户、批量分配、抛公海、批量抛公海、归档客户、批量归档、恢复客户
- **客户详情页/**(8):详情公共头部、团队成员页签、添加团队成员、移除团队成员、关联商机页签、操作日志分页、新增跟进、跟进记录分页
- **A4-5 联系人/**(7):新增联系人、编辑联系人、删除联系人、联系人详情、顶级联系人列表、联系人全局搜索、电话软提示
- **客户关注与重点标记/**(5):关注客户、取消关注客户、标记重点客户、取消重点标记、批量关注客户
- **客户交割/**(6):发起前客户预览、发起交接、交接详情、交接记录分页、客户分配、可分配销售列表
- **客户导入/**(6):上传导入文件并预校验、确认执行导入、任务结果、失败疑似重复明细、最近导入记录、下载导入模板
- **模块健康/**(1):健康探针
- 根级 **客户管理/超期提醒规则/**(2):当前提醒规则、保存提醒规则 —— 见下「为什么超期提醒规则在根级」
示例值说明:各 .bru 的请求/响应示例均注入 **E2E 实测真值**(标注「示例数据(E2E 实测真值/实测值注入)」的为真实抓取,其余为语义兜底)。
## 为什么超期提醒规则在根级
超期提醒规则的两个端点(`GET/PUT /api/rule/customer/reminder`)实现在 **crm-rule** 模块(CustomerReminderRuleController),源码 `@Tag` 原文为「客户管理」(无 A4 前缀)。按「结构听源码」铁律,目录 = tag 原文,故其生成在 collection 根级 `客户管理/超期提醒规则/` 而非 `A4 客户管理/` 下——bruno-sync 重跑时按 tag 归位,挪进 A4 会产生孤儿目录。
## crm-preference 6 端点说明(不生成副本)
客户模块功能上依赖 crm-preference 的自定义视图(saved-view)6 个端点(列表 / 保存 / 删除 / 设为默认等),但其源码 `@Tag` 挂在 **A3 商机管理** 分区(商机先建 saved-view,客户侧复用同一套),为避免 sync 重跑产生副本/孤儿,本分区**不生成副本**,文档见 A3 分区对应目录。前端对接时 scopeKey 传 `customer.*`(如 `customer.workspace.mine`),接口本身与商机共用:
- `GET /api/preference/saved-view/list?scopeKey=customer.*`(自定义视图列表)
- `POST /api/preference/saved-view`(保存自定义视图)
- `DELETE /api/preference/saved-view/{id}`(删除自定义视图)
- `PUT /api/preference/saved-view/{id}/default`(设为默认视图)
(以上为文字索引;A3 分区文档中的 scopeKey 示例为商机域,客户域联调请按本段替换 scopeKey。)
## 空缺标注
无 —— 原型 A4 各功能页均有接口落地。已知缺口(不影响联调,详见 E2E 报告):QuickCreateDTO 无 confirmSimilar 字段、详情汇总卡 5 字段恒 null 前端显 "--"、页签 5/6 汇总卡同。
## 对接工程师示位
客户模块前端按页面目录联调:三个列表页共用「客户三 workspace/」(分页 + 看板两个子端点,路径段换 workspace);客户详情页接口集中在「客户详情页/」下(公共头部 + 6 页签共享 8 端点);联系人独立页在「A4-5 联系人/」;全生命周期归属操作(领取 / 分配 / 抛公海 / 归档等)在「归属流转/」;客户交割(总监交接)在「客户交割/」;Excel 导入全流程在「客户导入/」。
---
> 维护纪律:本 README 为人工维护的分区索引;`.bru` 接口文件由生成器依据后端 Controller tag 自动同步(只动带 `generated` 标记的文件,结构听源码、表述听人),请勿手改。各 .bru docs 首行 `METHOD path` 为对账钥匙行,**勿手改**(对账脚本以此比对源码 mapping)。agent 不执行 git 提交,由维护者审查后自行提交。

37
.scratch/customer-e2e/scan_demo_api.py

@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
r"""票 08 前置:扫 demo/index.html 全部 api() 调用,与 defs_a4.py 57 端点对账(找路径偏差)。"""
import io, sys, re, os
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, r'd:\code\crm-backend-matt\.scratch\customer-e2e')
from defs_a4 import DEFS
DEMO = r'd:\code\crm-backend-matt\.scratch\customer-e2e\demo\index.html'
txt = open(DEMO, encoding='utf-8').read()
# 源 keys(占位形态)+ 允许的演示实例形态
src_paths = {d['path'] for d in DEFS}
src_methods = {}
for d in DEFS:
src_methods.setdefault(d['path'], set()).add(d['method'])
# demo 里 api('...') 字面串 + api(`...`) 模板串
calls = []
for m in re.finditer(r"""api\(\s*(['\"`])(.+?)\1""", txt):
calls.append(m.group(2))
def to_tpl(u):
"""把演示 url(数字 id/mine 等实例)归一回 {id}/{workspace} 占位形态再对账。"""
u = re.sub(r'(?<=/)(mine|overview|pool)(?=/|\\?|$)', '{workspace}', u)
u = re.sub(r'(?<=/)\d{5,}(?=/|\\?|$)', '{id}', u)
return u.split('?')[0]
print(f'demo api() 调用 {len(calls)} 处,去重归一后:')
bad = 0
for c in sorted(set(calls)):
tpl = to_tpl(c)
hit = tpl in src_paths
mark = 'OK ' if hit else '?? '
if not hit:
bad += 1
print(f' {mark} {c} -> {tpl}')
print(f'\nunmatched: {bad}')

BIN
.scratch/customer-e2e/shots08/shot-01-init.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
.scratch/customer-e2e/shots08/shot-02-login-mine-list.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

BIN
.scratch/customer-e2e/shots08/shot-03-overview.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

BIN
.scratch/customer-e2e/shots08/shot-04-pool.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
.scratch/customer-e2e/shots08/shot-05-search.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
.scratch/customer-e2e/shots08/shot-06-batch-focus.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

BIN
.scratch/customer-e2e/shots08/shot-07-detail-info.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
.scratch/customer-e2e/shots08/shot-08-detail-info.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
.scratch/customer-e2e/shots08/shot-09-detail-contacts.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
.scratch/customer-e2e/shots08/shot-10-detail-follow.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
.scratch/customer-e2e/shots08/shot-11-detail-opps.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
.scratch/customer-e2e/shots08/shot-12-detail-members.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
.scratch/customer-e2e/shots08/shot-13-detail-oplog.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
.scratch/customer-e2e/shots08/shot-14-follow-written.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
.scratch/customer-e2e/shots08/shot-15-contact-created.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
.scratch/customer-e2e/shots08/shot-16-contact-edited.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
.scratch/customer-e2e/shots08/shot-17-contact-deleted.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
.scratch/customer-e2e/shots08/shot-18-create-needconfirm.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
.scratch/customer-e2e/shots08/shot-19-create-credit-block.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
.scratch/customer-e2e/shots08/shot-19-focus-star.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
.scratch/customer-e2e/shots08/shot-20-focus-star.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
.scratch/customer-e2e/shots08/shot-20-transfer-list.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
.scratch/customer-e2e/shots08/shot-21-transfer-list.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
.scratch/customer-e2e/shots08/shot-21-transfer-preview.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
.scratch/customer-e2e/shots08/shot-22-transfer-preview.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
.scratch/customer-e2e/shots08/shot-23-import-list.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
.scratch/customer-e2e/shots08/shot-23-transfer-detail.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
.scratch/customer-e2e/shots08/shot-24-import-list.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

BIN
.scratch/customer-e2e/shots08/shot-24-import-upload.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
.scratch/customer-e2e/shots08/shot-25-import-result.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

BIN
.scratch/customer-e2e/shots08/shot-25-import-upload.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

BIN
.scratch/customer-e2e/shots08/shot-26-import-failures.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

BIN
.scratch/customer-e2e/shots08/shot-26-import-result.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

BIN
.scratch/customer-e2e/shots08/shot-27-import-failures.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

BIN
.scratch/customer-e2e/shots08/shot-27-reminder-saved.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
.scratch/customer-e2e/shots08/shot-28-reminder-64023.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
.scratch/customer-e2e/shots08/shot-28-reminder-saved.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
.scratch/customer-e2e/shots08/shot-29-checks.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
.scratch/customer-e2e/shots08/shot-29-reminder-64023.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
.scratch/customer-e2e/shots08/shot-30-checks.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

23
.scratch/customer-e2e/showcase-ids.json

@ -0,0 +1,23 @@
{
"customers": {
"广州晨曦医疗器械有限公司": "751103486522818560",
"深圳蓝湾冷链供应链有限公司": "751103486908694528",
"杭州云栖软件技术有限公司": "751103487302959104",
"成都锦官建材集团有限公司": "751103487592366080",
"武汉江城物流股份有限公司": "751103487902744576",
"西安大唐文旅发展有限公司": "751103488435421184",
"广州珠江光电科技有限公司": "751103488821297152",
"深圳前海惠金信息服务有限公司": "751103489253310464",
"杭州西湖茶业有限公司": "751103489597243392",
"成都熊猫慢递文化传播有限公司": "751103489911816192",
"武汉光谷生物医药有限公司": "751103490314469376",
"广州白云机场快运有限公司": "751103490679373824",
"南京金陵楼宇物业管理有限公司": "751103490972975104",
"东莞松山湖精密制造有限公司": "751103491279159296",
"佛山季华铝业有限公司": "751103491635675136",
"厦门鹭岛酒店用品有限公司": "751103491979608064",
"青岛海诺环保工程有限公司": "751103492348706816",
"长沙湘江智能科技有限公司": "751103492742971392",
"天津渤海贸易有限公司": "751103493049155584"
}
}

30
.scratch/customer-e2e/smoke08.py

@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
"""票 08 前置冒烟:起服后 debug token + 客户分页(seed 在位)+ 健康探针。"""
import io, sys, json
import requests
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
BASE = 'http://localhost:8080'
ADMIN = '739564171091247104'
r = requests.get(f'{BASE}/api/auth/debug/token', params={'userId': ADMIN}, timeout=10)
tok = r.json().get('data')
tok = tok.get('token') if isinstance(tok, dict) else tok
print('token:', (tok or 'FAIL')[:20] + '...' if tok else 'FAIL')
H = {'Authorization': f'Bearer {tok}'}
r = requests.get(f'{BASE}/api/customer/ping', headers=H, timeout=10)
print('ping:', r.status_code, r.json().get('message'))
r = requests.post(f'{BASE}/api/customer/workspace/mine/page',
data={'current': 1, 'size': 50}, headers=H, timeout=15)
d = r.json().get('data') or {}
rows = d.get('content') or []
print('mine page:', r.status_code, 'total=', d.get('total'), 'rows=', len(rows))
for c in rows[:5]:
print(' ', c.get('customerNo'), c.get('customerName'), 'stage=', c.get('customerStage'), 'owner=', c.get('ownerUserNameSnapshot'))
r = requests.post(f'{BASE}/api/customer/workspace/pool/page',
data={'current': 1, 'size': 10}, headers=H, timeout=15)
d = r.json().get('data') or {}
print('pool page: total=', d.get('total'))

71
.scratch/customer-e2e/verify_a4.py

@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
r"""票 06 续跑步骤 4:对账 verify(HANDOFF-2026-09-04 §3.4)。
- 正则扫 9 crm-customer Controller + crm-rule CustomerReminderRuleController
class @RequestMapping + method @XxxMapping (METHOD, path) 钥匙集 57
- 与生成 .bru docs 首行钥匙 `METHOD path` 双向比对全命中收口防笔误漏端点
- crm-preference 6 端点不生成副本源码 tag A3不参与本对账
"""
import io, sys, os, re
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
WS = r'd:\code\crm-backend-matt'
CONTROLLERS = [
WS + r'\crm-customer\src\main\java\com\crm\customer\controller\%s' % n
for n in ('CustomerController.java', 'CustomerWorkspaceController.java',
'CustomerOwnershipController.java', 'CustomerDetailController.java',
'CustomerContactController.java', 'CustomerFocusController.java',
'CustomerTransferController.java', 'CustomerImportController.java',
'CustomerPingController.java')
] + [WS + r'\crm-rule\src\main\java\com\crm\rule\controller\CustomerReminderRuleController.java']
DOCS = r'D:\code\crm-api-docs'
SCAN_ROOTS = [os.path.join(DOCS, 'A4 客户管理'), os.path.join(DOCS, '客户管理')]
# ---- 源码钥匙集 ----
re_class = re.compile(r'@RequestMapping\(\s*"([^"]+)"')
re_method = re.compile(r'@(Get|Post|Put|Delete)Mapping(?:\(\s*(?:"([^"]*)")?[^)]*\))?')
src_keys = set()
for f in CONTROLLERS:
txt = open(f, encoding='utf-8').read()
m = re_class.search(txt)
base = m.group(1) if m else ''
for mm in re_method.finditer(txt):
verb = mm.group(1).upper()
sub = mm.group(2) or ''
path = (base + sub) if not sub.startswith(base) else sub
path = re.sub(r'/{2,}', '/', path)
src_keys.add(f'{verb} {path}')
print(f'source keys: {len(src_keys)} (expect 57)')
# ---- .bru 钥匙集 ----
re_key = re.compile(r'`\s*(GET|POST|PUT|DELETE) (/\S+?)`')
bru_keys = {}
for root in SCAN_ROOTS:
for dp, dn, fn in os.walk(root):
for f in fn:
if not f.endswith('.bru') or f == 'folder.bru':
continue
p = os.path.join(dp, f)
txt = open(p, encoding='utf-8').read()
m = re_key.search(txt)
if not m:
print(f' !! no key line: {os.path.relpath(p, DOCS)}')
continue
bru_keys.setdefault(f'{m.group(1)} {m.group(2)}', []).append(os.path.relpath(p, DOCS))
print(f'bru keys: {len(bru_keys)} (expect 57)')
missing = sorted(src_keys - set(bru_keys))
extra = sorted(k for k in bru_keys if k not in src_keys)
dupes = {k: v for k, v in bru_keys.items() if len(v) > 1}
print(f'missing in bru (source has, doc lacks): {len(missing)}')
for k in missing:
print(' -', k)
print(f'extra in bru (doc has, source lacks): {len(extra)}')
for k in extra:
print(' -', k, '->', bru_keys[k])
print(f'duplicate keys: {len(dupes)}')
for k, v in dupes.items():
print(' -', k, '->', v)
ok = not missing and not extra and not dupes and len(src_keys) == 57 and len(bru_keys) == 57
print('VERIFY:', 'PASS ✅ 全命中收口' if ok else 'FAIL ❌')

6
.scratch/customer-module/map.md

@ -1,5 +1,7 @@
# Map: 客户模块后端 spec(crm-customer / crm-rule 客户规则)
> **⚠ 2026-09-05 验收返工中**:本图 D1–D29 冻结(spec 阶段已 resolve),但验收发现实现偏差(接口风格/tag/三形态偏好等)。返工决策与票见 **`.scratch/customer-rework/map.md`**(D1–D29 只增补不推翻)。
`wayfinder:map`
## Destination
@ -132,3 +134,7 @@
- **客户阶段「重潜客户」**(由「方案卡提交并报备成功」驱动)— 留 seam(D20 改票 B),报备成功链路(方案卡已推送3 或 A5 回调)本期不可达;`CustomerStagePort.promote` 契约已定,本期无调用方。**本期客户阶段实际只有「潜在」。**
- **「关联项目」详情页签**(读 A5 项目模块)— A5 未建,划出。
- **联系人导入**— 划出(客户导入本图做,联系人导入随图谱 effort)。
## 返工增补指针
- 2026-09-04:客户模块验收返工,图 `.scratch/customer-rework/map.md`(R1–R11);AGENTS.md「Coding standards」新增「新模块接口接入 checklist」一节(出处:返工票 01)。

53
.scratch/customer-rework/HANDOFF-2026-09-04.md

@ -0,0 +1,53 @@
# 客户模块验收返工 · 交接(HANDOFF-2026-09-04)
> 写给在新电脑上续接本 effort 的下一个 agent 会话。细节全部在图与票内,本文只做指针 + 换机特有事项,不复述。
## 如何续接
1. 读 `.scratch/customer-rework/map.md`(图:destination / R1–R11 决策 / Decisions so far / 范围外)。
2. 以 **wayfinder skill(Work through the map 模式)** 续接:地图路径 `.scratch/customer-rework/`;tracker 约定见 `docs/agents/issue-tracker.md` 的「Wayfinding operations」段。
3. **一场只解一张票**;先 `Status: claimed` 再动手;resolve 后 Answer + map 的 Decisions so far 追加。
4. 用户已示谕**按序取票**:下一张 = **04 · 查重设置页端点 + QuickCreate.confirmSimilar**(`.scratch/customer-rework/issues/04-gap-fixes.md`)。此后前沿为 05、06。
## 当前进度(详见各票,此处只给索引)
- **01 resolved**:AGENTS.md「Coding standards」新增「新模块接口接入 checklist」(**新会话必读**,本图后续所有票的规范镜头依据)。
- **02 resolved**:8 Controller 26 端点 + crm-rule 1 端点全部扁平动作动词化;workspace 进 `CustomerWorkspacePageParam` 字段;全仓唯一 `@RequestBody` 违规消灭。
- **03 resolved**:board summary/cards 对 `pool` 拒 67001(`assertBoardAllowed` 守卫)+ 2 新用例。票内有完整「误翻案→回滚」弧线记录。
**测试基线**:crm-rule 146 / crm-customer 135 / crm-opportunity 242,绿为准(数量随用例演进)。
## 换机特有事项(本文档存在的理由)
- **同步方式**:`.scratch/` 被 git 跟踪,但**本机尚有未提交改动**(crm-customer/crm-rule 源码 + .scratch)。切换前先 commit + push,新机 pull;勿只拷 `.scratch`
- **本机独有路径**:蓝湖原型缓存 `D:\code\crm-需求梳理\lanhu-mcp\data\axure_extract_bade4454_screenshots\`(`.txt` 为主)。新机若无此目录:关键证据截图已拷入本目录(`a4-2-1_客户总览列表_卡片视图_.png`、`a4-1-1_客户公海列表.png`);需整页文本时用 lanhu-bridge 重提取,OCR 兜底。
- **bruno 产出仓** `E:\code\crm-api-docs`(07 号票)——盘符因机而异,跑 bruno-sync 前先确认产出路径。
- **bruno-sync.local.json 不入库**(.gitignore)——07 号票跑全链前需按 skill 说明重建本机配置。
- 环境:Windows + Git Bash + Maven。`mvn test -pl crm-customer,crm-rule -am` 为常用验收命令。
## 必读纪律(仓库内已有,勿另起)
- `AGENTS.md`「新模块接口接入 checklist」(六条:扁平动词/深 tag/bruno 配置/表单绑定/对照时点)。
- `AGENTS.md`「Tooling / harness」:edit 工具大块 CJK 走 temp 文件 + python heredoc(本图三张票全程用此模式);中继流断流=重试即可。
- `AGENTS.md`「Source file encoding」:`.java` 禁 BOM,`mvn compile` 前扫一遍。
## 用户协作备注
- 中文交流;范围/口径以用户 HITL 拍板为准(图 R1–R11 为既定拍板,勿重开)。
- 03 号票教训:用户的纠正若含范围歧义(如「原型上是有卡片视图的」未说哪页),**先定位到具体页面再动手**,不要外推;必要时一条问题问清。03 最终口径:卡片视图在总览/我的客户,公海没有。
## Suggested skills
- **wayfinder**(主流程,Work through the map)
- **grilling + domain-modeling**(04/08 出现需决策项时;一次一问)
- **bruno-sync**(07 号票全链产出)
- **lanhu-bridge / ocr-image**(原型文本/截图,新机无缓存时)
- **code-review**(09 号票修复完成后、10 回归前的可选自检)
## 验收速查
```bash
# 02/03 终检(零命中才算过)
grep -rn "PathVariable\|@PutMapping\|@DeleteMapping\|@RequestBody" crm-customer/ crm-rule/ # 期望空
mvn test -pl crm-customer,crm-rule -am # 期望 BUILD SUCCESS
```

BIN
.scratch/customer-rework/a4-1-1_客户公海列表.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

BIN
.scratch/customer-rework/a4-2-1_客户总览列表_卡片视图_.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

48
.scratch/customer-rework/issues/01-ag-checklist.md

@ -0,0 +1,48 @@
# 01 · AGENTS.md 沉淀「新模块接口接入 checklist」
- **Status:** resolved
- **Type:** task
- **Blocked by:**
## 目标
AGENTS.md 新增一节「新模块接口接入 checklist」,把本次返工暴露的规范偏差固化为 agent 必读纪律。
## checklist 内容(六条)
1. 开发前先读对称参照模块(线索/商机)的 Controller 形态,不另起风格;
2. URL 风格 = 扁平动作动词 + 查询参数传 id,**禁 `@PathVariable` / `@PutMapping` / `@DeleteMapping`**,写端点一律 POST form,读端点 GET;
3. 新 Controller 必挂**深 tag**(完整页面路径,一级 = 原型分区原文如 `A4 客户管理`,以 `.scratch/api-docs-reorg/lanhu-tree-v29.md` 权威树为准,跨页共用接口用方法级多值 tags);
4. `bruno-sync.config.json`:sourceRoots 加新模块目录 + menuBindings 加菜单(viewType/scopeKey);
5. 写端点禁 `@RequestBody` JSON,参数走 ADR-0017 表单绑定;Param/DTO 规范同 ADR-0017;
6. 出票时、收工时各对照本 checklist 跑一遍。
## 验收
- 节落在 AGENTS.md「Coding standards」区,内链 ADR-0017/0024;
- 一句话带过本次踩坑案例(不单独开 ADR);
- 本图 08 号票(复核)以本节为规范镜头依据。
## 同步动作
`.scratch/customer-module/map.md` 增补一行指针(返工图与本 checklist 的出处)。
## Answer
AGENTS.md「Coding standards」区新增 **「新模块接口接入 checklist」** 一节(位于 JPA 命名策略节之后、Tooling/harness 之前),六条纪律:
1. 动手前先读对称参照模块(线索/商机)的 Controller 形态,不另起风格;
2. URL = 扁平动作动词 + 查询参数传 id,禁 `@PathVariable` / `@PutMapping` / `@DeleteMapping`,写端点 POST、读端点 GET;
3. 新 Controller 必挂深 tag(一级 = 原型分区原文,权威树 `lanhu-tree-v29.md`,跨页共用走方法级多值 tags);
4. `bruno-sync.config.json``sourceRoots` + `menuBindings`
5. 写端点禁 `@RequestBody`,表单绑定,Param/DTO 规范依 ADR-0017;
6. 出票时、收工时各对照跑一遍。
内链 [ADR-0017](docs/adr/0017-controller-io-param-dto-conventions.md) 与 [ADR-0024](docs/adr/0024-bruno-docs-grouping-by-menu.md);节首一句话记录踩坑案例(crm-customer 整模块 RESTful 偏航——规范存在但实现会话没读),未开新 ADR。
同步动作已完成:`.scratch/customer-module/map.md` 末尾增补「返工增补指针」段(返工图 + 本 checklist 出处)。
验收自检:
- [x] 节落在 AGENTS.md「Coding standards」区,内链 ADR-0017/0024;
- [x] 踩坑案例一句话带过,未单开 ADR;
- [x] 08 号票(复核)规范镜头原文即以「AGENTS.md 新 checklist」为据,无需改动。

64
.scratch/customer-rework/issues/02-style-unify.md

@ -0,0 +1,64 @@
# 02 · 端点风格全量统一(55 + 1 个端点)
- **Status:** resolved
- **Type:** task
- **Blocked by:** 01-ag-checklist
## 目标
crm-customer 全部 9 Controller 55 端点 + crm-rule `CustomerReminderRuleController` 1 端点,对齐 lead/opportunity 扁平动作动词风格。
## 改造规则
| 现状 | 改为 |
|---|---|
| `PUT /{id}`(客户/联系人编辑) | `POST /edit?id=` |
| `DELETE /{id}`(删联系人、删团队成员) | `POST /delete?id=`、`POST /member/remove?id=&memberUserId=` |
| `GET /{id}`、`/{id}/detail-head`、`/{id}/follow/page`、`/{taskId}/failures` 等全部 PathVariable | 查询参数:`/detail?id=`、`/detail-head?id=`、`/follow/page?id=`、`/failures?taskId=` |
| `POST /{workspace}/page`(workspace 路径变量) | `POST /page` + `workspace` 参数(对称商机 `viewType` 参数形态) |
| `POST /{workspace}/board/summary`、`/{workspace}/board/cards` | `POST /board/summary`、`POST /board/cards` + `workspace` 参数 |
| `POST /`(create 空路径) | `POST /create`、`POST /quick-create`(已有的保持) |
| `PUT /api/rule/customer/reminder` + `@RequestBody` JSON | `POST /save` + form 绑定(**消灭全仓唯一 ADR-0017 违规**) |
| `POST /{taskId}/confirm`、`/{id}/focus`、`/{id}/unfocus`、`/{id}/star`、`/{id}/unstar`、`/{id}/assign`、`/{id}/assignable` | 查询参数形态 |
## 波及面
- controller 层 mapping + 方法签名(PathVariable → Param DTO 字段);
- 全部相关单测(crm-customer 122 条基线)URL/参数断言;
- 冒烟脚本 URL(10 号票重跑时用新 URL);
- 商机侧跨模块走 Java service(HANDOFF 3.6),无 HTTP 连锁(R11)。
## 验收
- `grep -r "PathVariable\|@PutMapping\|@DeleteMapping\|@RequestBody" crm-customer/ crm-rule/(客户部分)` 零命中;
- crm-customer + crm-rule `mvn test` 绿。
## 同步动作
`.scratch/customer-module/API-SUMMARY.md` 全端点 URL 表重写(收尾 11 号票统一校验)。
## Answer
**改造范围(8 Controller 26 端点重写 + 2 处注释同步)**:
| Controller | 改造 |
|---|---|
| CustomerContactController | `POST ""`→`/create`;`PUT /{id}`→`POST /edit?id=`;`DELETE /{id}`→`POST /delete?id=`;`GET /{id}`→`GET /detail?id=` |
| CustomerController | `POST ""`→`/create`;`PUT /{id}`→`POST /edit?id=`;`GET /{id}`→`GET /detail?id=`;`GET /{customerId}/contacts`→`GET /contacts?customerId=` |
| CustomerDetailController | `/detail-head?id=`、`GET /member/list?id=`、`POST /member/add?id=`、`POST /member/remove?id=&memberUserId=`(原 DELETE)、`GET /opportunity/page?id=`、`GET /oplog/page?id=`、`POST /follow/add?id=`、`GET /follow/page?id=` |
| CustomerFocusController | `POST /focus|unfocus|star|unstar?id=`(去路径段) |
| CustomerImportController | `POST /confirm?taskId=`、`GET /result?taskId=`、`GET /failures?taskId=` |
| CustomerTransferController | `GET /detail?id=`、`POST /assign?id=`、`GET /assignable?id=` |
| CustomerWorkspaceController | 三端点去 `{workspace}` 路径段→`POST /page`、`POST /board/summary`、`POST /board/cards`,workspace 进 `CustomerWorkspacePageParam` 字段(对称商机 viewType-in-DTO 范式,服务签名不变) |
| CustomerReminderRuleController(crm-rule) | `PUT @RequestBody`→`POST /save` 表单绑定——全仓唯一 ADR-0017 违规消灭 |
Ownership / Ping / 其余既平端点本就合规,未动。测试为 service 层集成测试(直调 service,无 URL 断言),服务签名未动,零波及。
**验收**:
- [x] `grep -r "PathVariable\|@PutMapping\|@DeleteMapping\|@RequestBody" crm-customer/ crm-rule/` 零命中(含 target 二进制,重建后干净);
- [x] `mvn test` 绿:crm-rule 146(对齐基线)、crm-customer 133(基线 122,随用例演进)、上游 crm-preference 16,BUILD SUCCESS;
- [x] BOM 扫描:改动文件无 `EF BB BF`
**同步动作**:`.scratch/customer-module/API-SUMMARY.md` 全端点 URL 表重写按票 02 原注留待 11 号票收尾统一校验(R1 随修随改 + 终验对账)。冒烟脚本新 URL 归 10 号票重跑时更换。
**边界备忘**:`workspace` 现为表单参数,缺省/非法值由服务层按未知 workspace 拒绝(原路径段缺失=404,现=业务错误码);`pool` 看板拒绝归 03 号票。

38
.scratch/customer-rework/issues/03-board-pool-close.md

@ -0,0 +1,38 @@
# 03 · 公海看板后端拒绝
- **Status:** resolved
- **Type:** task
- **Blocked by:** 02-style-unify
## 目标
看板端点对 `pool` workspace 后端直接拒绝(原型明文:客户公海只有列表视图和分屏视图两种,a4-1-1 L803)。
## 做法
- `board/summary` + `board/cards`(02 改造后形态)收到 `workspace=pool` → 抛 67001 口径(参数非法;或 67004 无权限,以客户模块既有错误码段位为准,实施时查 `CustomerErrorCode`);
- 补单测:pool 看板两端点拒绝断言;
- tags 只挂 `A4 客户管理/客户总览/看板列表` + `A4 客户管理/我的客户/看板列表`(07 号票落地)。
## 验收
- mvn test 绿(新增拒绝用例);
- bruno 文档不出现公海看板分组。
## Answer
**最终确认的事实(用户两轮澄清,2026-09-04)**:卡片视图存在于**客户总览/我的客户**(分组=客户阶段:潜在/重潜/已成交;客户星级一~五星;关系星级一~五星),**公海页面没有卡片视图**(只有列表/分屏两视图)——票原前提成立,v29 树「A4-1 客户公海 [2]」与缓存证据一致。
**实施**:`CustomerWorkspaceServiceImpl` 新增私有守卫 `assertBoardAllowed(workspace)`,`board/summary` 与 `board/cards` 入口第一行调用——`workspace=pool` 抛 `BusinessErrorException(67001, "公海无卡片视图,看板仅支持客户总览/我的客户")`。守卫先于 buildQuery/登录态/SQL,服务层直接拒绝。
**测试**:`CustomerWorkspaceOwnershipIntegrationTest` 新增 2 用例(pool summary / pool cards → 67001 断言),该类 17 绿;crm-customer 135 全绿(133+2)、crm-rule 146 绿,BUILD SUCCESS。
**证据归档**(截图拷入本目录):
- `a4-2-1_客户总览列表_卡片视图_.png`——卡片视图分组规则佐证(与用户描述逐项一致,L873「看板分组……沿用【我的客户】」);
- `a4-1-1_客户公海列表.png`——v29 期公海整页(列表/分屏证据)。
**引文勘误(仍有效)**:图表会话原引「a4-1-1 L803」在缓存中不存在(该 txt 全文 341 行);结论最终以用户对照活原型拍板为准。
**记账回滚记录**:本票曾因会话误读用户第一次澄清而错误翻案(map 曾增 R12、07 曾加公海看板 tag、10 曾翻冒烟断言),已全部撤销还原;07 维持「无公海」、10 维持「公海看板断言拒绝」。
验收:mvn test 绿 ✅;bruno 无公海看板分组 ✅(07 按「无公海」挂 tag 后自然满足)。

26
.scratch/customer-rework/issues/04-gap-fixes.md

@ -0,0 +1,26 @@
# 04 · 已知缺口:查重设置页端点 + QuickCreate.confirmSimilar
- **Status:** open
- **Type:** task
- **Blocked by:** 02-style-unify
## 目标
两个规格内小缺口补齐(R8)。
## A. 查重设置页端点(a7-3-3-2 前端页面无接口支撑)
- crm-rule 新增 `GET /api/rule/customer/dedup`(读当前配置)+ `POST /api/rule/customer/dedup/save`(表单绑定);
- 配置实体复用既有 DedupRule 单例(票09 已做服务端种子),对称 `CustomerReminderRuleController` 改造后形态;
- 权限:客户管理设置菜单权限点(与提醒规则同款);
- tag:`A7 后台管理/客户管理设置/查重设置`。
## B. QuickCreateDTO 加 confirmSimilar
- 商机弹窗快建客户命中相似客户时"仍要创建"透传字段(D3 已有 confirmPhone 同款形态);
- `CustomerQuickCreateParam``confirmSimilar: Boolean`(默认 false),命中相似且未确认 → 仍抛 670xx 提示码(沿用既有 check-name 口径)。
## 验收
- 两模块 mvn test 绿,新端点有单测;
- API-SUMMARY §5 已知缺口清单这两条销账。

34
.scratch/customer-rework/issues/05-view-form-platform.md

@ -0,0 +1,34 @@
# 05 · 平台形态偏好:user_view_form 表 + 端点,客户/商机双接入
- **Status:** open
- **Type:** task
- **Blocked by:** 01-ag-checklist
## 目标
crm-preference 新增「形态偏好」平台能力(R6),客户三页签 + 联系人 + 商机三菜单接入(R7)。用户上次选择的视图形态(list/split/board)跨会话记忆。
## 平台侧(crm-preference)
- 实体 `UserViewForm`:表 `user_view_form`,`UNIQUE(user_id, scope_key)`,`view_form varchar(16)`,无 create_by/update_by(同 UserColumnPreference 范式:author 恒为 user_id);
- `ViewFormController`
- `GET /api/preference/view-form/get?scopeKey=``Result<String>`(null = 未设置);
- `POST /api/preference/view-form/save`(form 绑定,**禁 @RequestBody**);
- 值域白名单 `list` / `split` / `board`,非法值 67001 口径拒绝;
- tags 双归属(方法级多值):客户侧 `A4 客户管理/客户总览`、`A4 客户管理/我的客户`、`A4 客户管理/客户公海`、`A4 客户管理/联系人`;商机侧 `A3 商机管理/销售机会`、`A3 商机管理/商机管理`、`A3 商机管理/商机公海`。
## 接入侧(零端点,只加常量,参考线索列偏好范式)
- crm-customer 常量:`VIEW_FORM_SCOPE_OVERVIEW/MINE/POOL/CONTACT` = `customer.overview/mine/pool`、`contact.list`;
- crm-opportunity 常量:`opportunity.sales/manage/public_pool`(与 saved-view `opportunity.sales` 对齐);
- 前端调平台端点读写,未设置 → 默认 list。
## 验收
- crm-preference + 两接入模块 mvn test 绿;
- 单测覆盖:存取往返、白名单拒绝、UNIQUE 覆盖更新;
- knife4j 分组出现双归属页面。
## 同步动作
`.scratch/customer-module/map.md` 增补决策条目(形态偏好平台化);crm-preference CONTEXT 若有"形态偏好规划"段落则补实现指针。

25
.scratch/customer-rework/issues/06-column-pref-scopes.md

@ -0,0 +1,25 @@
# 06 · 列偏好 scopeKey:客户 4 个 + 商机 3 个 + 平台 tags 双归属
- **Status:** open
- **Type:** task
- **Blocked by:** 01-ag-checklist
## 目标
字段设置(显隐列+顺序)后端接线,完全复刻线索模块范式(R5/R7):业务方只加 scopeKey 常量,存储走既有 `/api/preference/get|save` + `user_column_preference` 表,零新端点。
## 做法
- crm-customer 常量(LeadConstants 同款注释块):`COLUMN_SCOPE_OVERVIEW/MINE/POOL/CONTACT` = `customer.overview/mine/pool`、`contact.list`;
- crm-opportunity 常量:`opportunity.sales/manage/public_pool`;
- `ColumnPreferenceController`(crm-preference)方法级 tags 补双归属:
- 现状:仅 A2 线索四页;
- 补:A4 客户四页(总览/我的客户/公海/联系人)+ A3 商机三页(销售机会/商机管理/商机公海);
- 固定列校验(客户名称不可隐藏、勾选/序号/操作列不参与、重置默认)归**前端**,后端只存取(平台侧不校验,与线索一致——R5 拍板);
- 注:lead 侧常量标注"预留,尚未接线"维持现状,本票不动线索。
## 验收
- 三模块 mvn test 绿;
- knife4j 上 `/api/preference/get|save` 出现在 A2/A3/A4 全部相关页面分组;
- 常量注释引用 crm-preference/CONTEXT.md「scope」口径(同 LeadConstants L46-50 写法)。

38
.scratch/customer-rework/issues/07-tags-bruno.md

@ -0,0 +1,38 @@
# 07 · 深 tag 重挂 + bruno-sync 全链接通
- **Status:** open
- **Type:** task
- **Blocked by:** 02-style-unify, 05-view-form-platform, 06-column-pref-scopes
## 目标
全部深 tag 对照 v29 原型树重挂;bruno-sync config 补客户模块;实际跑 `/bruno-sync` 产出(R4)。
## tag 重挂清单(对照 lanhu-tree-v29.md A4/A7 段)
| Controller(改造后) | 新 tag |
|---|---|
| CustomerWorkspaceController `/page` | 方法级多值:`A4 客户管理/客户总览` + `A4 客户管理/我的客户` + `A4 客户管理/客户公海` |
| `/board/*` | 多值:`A4 客户管理/客户总览/看板列表` + `A4 客户管理/我的客户/看板列表`(**无公海**——用户 2026-09-04 复核原型确认) |
| CustomerController(create/edit/detail/search/quick-create/check-*) | `A4 客户管理/我的客户`(新增/编辑归属);`check-name` 等查重入口挂 `A4 客户管理/客户查重`——实施时按页面实际调用归属逐个定 |
| CustomerDetailController | `A4 客户管理/客户详情`(对称商机 `A3 商机管理/商机详情/主体` 形态,子级可用页签名) |
| CustomerContactController | `A4 客户管理/联系人` |
| CustomerFocusController | 多值:挂实际调用页面(总览/我的客户列表操作) |
| CustomerOwnershipController(claim/assign/release-pool/archive) | 多值:`客户总览`+`我的客户`+`客户公海` |
| CustomerTransferController | `A4 客户管理/客户交割`(A4-7-1~4 四页) |
| CustomerImportController | `A4 客户管理/我的客户/客户导入`(A4-3-2) |
| CustomerPingController `/ping` | **不挂 tag**(无页面入口,ADR-0024 纪律) |
| crm-rule CustomerReminderRuleController | `A7 后台管理/客户管理设置/超期未跟进提醒`(A7-3-3-1) |
| crm-rule 查重设置(04 号票新端点) | `A7 后台管理/客户管理设置/查重设置` |
| 平台端点(05/06 号票) | 已在各自票内双归属 |
## config 与产出
- `bruno-sync.config.json`:sourceRoots += `crm-customer`;menuBindings 补 A4 五菜单 + A7 客户管理设置(viewType=workspace/board,scopeKey 用 05/06 定义的常量值);
- 跑 `/bruno-sync`(skill:`C:\Users\luowj\.pi\agent\skills\bruno-sync\SKILL.md`),产出落 `E:\code\crm-api-docs`
- 验收:A4 目录树与 v29 树逐节点对齐;knife4j 分组同构;无编号式 tag(`A4-5 联系人` 形态绝迹)。
## 验收
- `grep -rn "@Tag\|tags =" crm-customer/` 无扁平/编号式 tag 残留;
- bruno collection 里客户模块目录树 = 原型树 A4 段。

45
.scratch/customer-rework/issues/08-audit-prototype.md

@ -0,0 +1,45 @@
# 08 · 双镜头全量复核(规范 + 原型)
- **Status:** open
- **Type:** task
- **Blocked by:** 02-style-unify, 03-board-pool-close, 04-gap-fixes, 05-view-form-platform, 06-column-pref-scopes, 07-tags-bruno
## 目标
用户判断"还有很多问题"——已知修复完成后,用双镜头过全量,产出差异清单回填 09 号票(R9)。
## 镜头一:规范镜头(代码仓扫描)
以 AGENTS.md 新 checklist + ADR-0017/0024/0023 + `.scratch/controller-io-conventions/spec.md` 为准,逐端点核对:
- [ ] URL 风格(02 号票改造后无残留)
- [ ] 深 tag 与 v29 树逐节点对齐
- [ ] Param/DTO 规范(禁 Entity 出入参、禁 @RequestBody、表单绑定)
- [ ] 分页规范(`current/size` → `PageResult{content,total}`
- [ ] 错误码段位(客户模块错误码是否与既有段位表冲突)
- [ ] bruno config envelope(Result 包装、Long 字符串化)
## 镜头二:原型镜头(50+2 页逐页)
A4 全部页面(a4-1-1/1-2、a4-2-1 三视图、a4-3-1 三视图、a4-3-2 导入、a4-3-3 快建、a4-4-1~4-10 详情族、a4-5-1~5-6 联系人族、a4-7-1~7-4 交割族)+ A7-3-3-1/2 客户规则两页,每页三列核对表:
| 列 | 内容 |
|---|---|
| 接口存在性 | 页面每个可交互元素 → 端点(缺失标红) |
| 语义正确 | 端点行为与原型规则段一致(如分屏右侧动作集、看板分组规则、字段设置规则) |
| 风格合规 | 映射到镜头一的规范项 |
范围外页面(A4-6 查重/合并、a4-3-3-3/4 OCR、导出)只登记不审。
`.txt` 为主,歧义页 OCR 兜底(AGENTS.md 阅读策略)。
## 产出
`.scratch/customer-rework/audit-findings.md`:差异清单(按"能机械修 / 需决策"分组),回填 09 号票范围;需决策项若涉及规格翻案则单开票再 grill。
## 已知种子(复核时优先验证)
- 分屏视角:公海普通销售裁剪是否真的只靠前端权限点(R5 口径的自洽性);
- 交割四页端点覆盖度(transfer/preview/initiate/assign/page/assignable 是否够 A4-7-1~4);
- 详情页签族(a4-4-2~4-8)端点语义 vs `detail-head` 聚合返回结构;
- 联系人详情页 a4-5-5/5-6(操作日志页签)端点覆盖。

19
.scratch/customer-rework/issues/09-audit-fixes.md

@ -0,0 +1,19 @@
# 09 · 复核差异清单修复(内容待 08 回填)
- **Status:** open
- **Type:** task
- **Blocked by:** 08-audit-prototype
## 目标
修复 08 号票产出的 `audit-findings.md` 差异清单中"能机械修"的部分;"需决策"项单开票走 grilling。
## 范围
- 待 08 产出后回填(本票为占位票,收编差异清单的全部修复动作);
- 每修一项:对应单测/集成测试同步补;
- 修复波及 `.scratch/customer-module` 文档的,随修随改(11 号票统一校验)。
## 验收
- 差异清单全部条目状态为"已修 / 已单开票 / 明确不修(记理由)",无悬空项。

17
.scratch/customer-rework/issues/10-regression-retest.md

@ -0,0 +1,17 @@
# 10 · 回归重测(验收线)
- **Status:** open
- **Type:** task
- **Blocked by:** 09-audit-fixes
## 验收线(R10,逐项过)
- [ ] `mvn test`:crm-rule + crm-customer + crm-opportunity 三模块全绿(基线 521:146/122/242,风格改造后数量随用例变化,绿为准);
- [ ] 冒烟脚本重跑全链 code=0:复用 `tools/smoke-t12m.py` 范式,URL 换 02 号票改造后的新端点,覆盖:登录 → 客户三 workspace 列表/看板(公海看板断言拒绝——用户复核原型确认公海无卡片视图)→ 建改查客户 → 联系人 CRUD → 认领/分配/抛公海 → 交割 → 导入全链 → 偏好存取(列 + 形态)→ 商机偏好存取;
- [ ] bruno 产出验收:`E:\code\crm-api-docs` A4 目录树对齐 v29 树,knife4j 分组同构(07 号票已建,此处终验);
- [ ] BOM 扫描:全仓 `*.java``EF BB BF` 前缀(AGENTS.md 编码纪律);
- [ ] `grep -r "PathVariable\|@PutMapping\|@DeleteMapping\|@RequestBody" crm-customer/` 零命中终验。
## 产出
`.scratch/customer-rework/RETEST-REPORT.md`:各验收项结果 + 用例计数。

21
.scratch/customer-rework/issues/11-docs-sync.md

@ -0,0 +1,21 @@
# 11 · `.scratch/customer-module` 文档同步收尾
- **Status:** open
- **Type:** task
- **Blocked by:** 09-audit-fixes
## 目标
R1 要求的文档同步,收尾统一校验(各票随修随改,本票终验)。
## 清单
- [ ] `map.md`(冻结图):末尾增补「返工增补」段——R1–R11 决策指针(→ `.scratch/customer-rework/map.md`)+ 每张返工票执行结果的增量决策条目;
- [ ] `API-SUMMARY.md`:全部端点 URL/方法/参数表按 02 号票改造后形态重写;§5 已知缺口清单销账(confirmSimilar、查重设置端点);新增:形态偏好端点、列偏好 scopeKey、查重设置端点;
- [ ] `HANDOFF-*.md`:新篇(日期=收工日)记录返工范围/决策/测试结果,链接 RETEST-REPORT;
- [ ] `crm-customer/CONTEXT.md`(若存在接口清单段落):同步新 URL 契约 + 偏好能力说明;
- [ ] 交叉检查:文档中的 URL 与代码 `@*Mapping` 逐一对得上(脚本 grep 对账)。
## 验收
文档-代码对账零差异;旧 HANDOFF 不改写(历史只增)。

67
.scratch/customer-rework/map.md

@ -0,0 +1,67 @@
# 客户模块验收返工 · map
- **Status:** charted
- **Destination:** crm-customer(含 crm-rule 客户部分、crm-preference 平台扩展、crm-opportunity 偏好接线)接口风格/文档规范/三形态偏好全面对齐项目规范与蓝湖原型;已知缺口修复;双镜头全量复核;重测通过;规范沉淀进 AGENTS.md;`.scratch/customer-module` 文档同步。**执行含在图内(用户覆写 plan-only 默认)**。
- **上游图:** `.scratch/customer-module/map.md`(spec 图 D1–D29 冻结,仅增补;实施结论见 `HANDOFF-2026-09-03.md`
- **Charted:** 2026-09-05(7 轮 grilling,用户逐项拍板)
## 决策(R1–R11)
| # | 决策 | 依据 |
|---|---|---|
| R1 | 返工图独立目录 `customer-rework`,执行含图内:票直接改代码 + 同步更新 `.scratch/customer-module`(map 增补决策条目 + API-SUMMARY 重写 + HANDOFF 新篇)+ 重测 | 用户:改的同时同步更新、检查重测 |
| R2 | 规范沉淀 = AGENTS.md 新增「新模块接口接入 checklist」一节,内链 ADR-0017/0024 作依据;不开新 ADR | 病灶是"规范存在但实现会话没读",checklist 必须挂在 agent 必读处 |
| R3 | 接口风格全量统一:crm-customer 全部端点 + crm-rule 客户提醒端点 → 扁平动作动词(参照 lead/opportunity:零 PathVariable、零 PUT/DELETE、写全 POST form、读 GET 查询参数);`PUT /api/rule/customer/reminder` 的 `@RequestBody` 一并消灭(全仓唯一 ADR-0017 违规) | lead+opportunity 全仓 grep 实证;项目风格本就非严格 RESTful;模块未联调是零成本窗口 |
| R4 | bruno-sync 全链:config `sourceRoots` 加 crm-customer;menuBindings 补 A4 五菜单(公海/总览/我的客户/交割/联系人)+ A7 客户规则;全部 controller 深 tag 对照 v29 原型树重挂(一级=`A4 客户管理` 原文,非 `A4-2` 编号式);`CustomerPingController` 保留端点不挂页签 | ADR-0024 修订纪律;`lanhu-tree-v29.md` 权威树 |
| R5 | 三形态后端边界:列偏好(字段设置)接平台 `/api/preference/get\|save`,客户 4 个 scopeKey(`customer.overview/mine/pool` + `contact.list`),固定列校验归前端(与线索一致);分屏右侧复用既有详情聚合接口,公海"普通销售视角"= 前端按权限点裁剪页签,后端不加裁剪专用端点;看板对 `pool` 后端直接拒绝(67001/67004 口径),tags 只挂总览+我的客户 | 原型 a4-2-1/a4-3-1 三视图规则段;公海无看板(a4-1-1 L803);商机范式 |
| R6 | 形态偏好(list/split/board 持久化)= crm-preference 新建 `user_view_form` 表(UNIQUE(user_id, scope_key),view_form 枚举白名单)+ `ViewFormController`(`GET /api/preference/view-form/get` + `POST /api/preference/view-form/save`,form 绑定);未设置→前端默认 list | 用户翻案:需求要存用户上次偏好,后端需要处理;方案 A |
| R7 | 商机模块偏好一并开发(参考线索):列偏好 3 个 scopeKey(`opportunity.sales/manage/public_pool`,与 saved-view `opportunity.sales` 对齐)+ 形态偏好接入新平台端点;商机侧零新端点,只加常量 + 平台 tags 双归属 | 用户:加上商机的偏好,参考线索模块 |
| R8 | 已知缺口处置:**补** 查重设置页端点(`GET/POST /api/rule/customer/dedup`,风格统一后形态)+ QuickCreateDTO 加 `confirmSimilar` 透传;**修** 公海看板越范围(见 R5);**维持划出** 客户导出、联系人导入(范围外记档,不翻案) | 用户拍板 Q6 |
| R9 | 全量复核 = 双镜头:① 规范镜头(AGENTS.md 新 checklist + ADR-0017/0024/0023 扫全部端点:风格/tags/Param-DTO/分页规范/错误码段位);② 原型镜头(A4 全部 50 页 + A7-3-3 两页,三列表:接口存在性/语义正确/风格合规)。差异清单归并成票 | 用户:"我相信还有很多问题" |
| R10 | 验收线:三模块(crm-rule/crm-customer/crm-opportunity)`mvn test` 全绿;冒烟脚本重跑全链 code=0(复用 smoke 范式,改新 URL);bruno-sync 产出落 `E:\code\crm-api-docs` 且 A4 目录树对齐 v29 树、knife4j 分组同构;BOM 扫描 clean;`.scratch/customer-module` 文档同步完成 | 用户拍板 Q7 |
| R11 | 跨模块依赖走 Java service(商机→客户),HTTP 契约变更无连锁;前端尚未联调客户模块,URL 重塑零外部成本 | HANDOFF 3.6 实证 |
## Decisions so far
- [01 · AGENTS.md 沉淀「新模块接口接入 checklist」](issues/01-ag-checklist.md) — checklist 已落 AGENTS.md「Coding standards」区(六条 + 内链 ADR-0017/0024 + 踩坑一句话);`.scratch/customer-module/map.md` 已增补出处指针。
- [02 · 端点风格全量统一(55 + 1 个端点)](issues/02-style-unify.md) — 8 Controller 26 端点重写为扁平动作动词+查询参数(workspace 进 Param DTO);crm-rule 提醒规则 PUT+@RequestBody→POST /save 表单绑定,全仓唯一 ADR-0017 违规消灭;验收 grep 零命中 + mvn test 绿(rule 146 / customer 133)。
- [03 · 公海看板后端拒绝](issues/03-board-pool-close.md) — 已实施:board summary/cards 对 pool 拒 67001(用户两轮澄清定案:卡片视图在总览/我的客户,公海无);+2 用例,customer 135 绿。中途一次误翻案已全额回滚(详见票内记账回滚记录)。
## 范围外(Out of scope)
- 客户导出(原型有规则,维持 D2 划出,涉及脱敏口径,量级独立)
- 联系人导入(维持 D5 划出,随图谱 effort)
- 客户查重/合并(A4-6,维持划出)
- OCR 快捷新增(a4-3-3-3/4,维持划出)
- 敏感信息三档脱敏/查询日志(维持划出)
- 线索模块列偏好实际接线(scopeKey 常量已有,"预留未接线"维持现状)
## 票与 blocking
```
01 ag-checklist ──┬──> 02 style-unify ──┬──> 03 board-pool-close ──┐
│ └──> 04 gap-fixes ─────────┤
│ ├──> 08 audit ──> 09 audit-fixes ──┬──> 10 regression
│ 05 view-form-platform ──┬──> 07 tags-bruno ──┤ │
│ 06 column-pref-scopes ──┘ │ │
└───────────────────────────────────────────────┘ 11 docs-sync <────┘
```
- 01 是全部票的前置(checklist 是复核依据)
- 07(tags+bruno 产出)依赖 02(URL 定型)+ 05/06(scopeKey/viewType 定型才能写 menuBindings)
- 08(复核)依赖 02–07 全部已知修复完成后再审,避免复审已知问题
- 09 内容待 08 产出差异清单后回填
- 10/11 收尾并行
## 参考索引
- 原型树权威:`.scratch/api-docs-reorg/lanhu-tree-v29.md` A4/A7 段
- 原型缓存:`D:\code\crm-需求梳理\lanhu-mcp\data\axure_extract_bade4454_screenshots\`(`.txt` 为主,OCR 兜底)
- 风格参照:`crm-lead/.../controller/LeadController.java`、`crm-opportunity/.../controller/*.java`
- 平台参照:`crm-preference/.../ColumnPreferenceController.java`、`SavedViewController.java`、`UserColumnPreference.java`(表范式)
- 规范:`docs/adr/0017-*.md`、`docs/adr/0024-*.md`;`.scratch/controller-io-conventions/spec.md`
- 旧图冻结文档:`.scratch/customer-module/{map.md, API-SUMMARY.md, HANDOFF-2026-09-03.md}`
- bruno-sync skill:`C:\Users\luowj\.pi\agent\skills\bruno-sync\SKILL.md`;产出仓 `E:\code\crm-api-docs`

11
AGENTS.md

@ -78,6 +78,17 @@ private Integer partyAClear = 0;
2. 有 → 加 `@Column(name = "snake_case_name", columnDefinition = "...")`
3. `ddl-auto: update` 环境下,如果表已经存在且字段名撞过坑,需要一条 `ALTER TABLE t CHANGE 旧列名 新列名 类型 ...;` 手工迁移,Hibernate 不会自动改名。
### 新模块接口接入 checklist
**为什么有这一节**:customer-rework 返工(`.scratch/customer-rework/`)发现 crm-customer 整模块写成了 RESTful 风格(`@PathVariable` / `PUT`/`DELETE` / `@RequestBody`),深 tag 与 bruno 配置全缺——规范本身早已存在([ADR-0017](docs/adr/0017-controller-io-param-dto-conventions.md)、[ADR-0024](docs/adr/0024-bruno-docs-grouping-by-menu.md)),但实现会话没读。纪律钉在这里(agent 必读处):**新模块 / 新 Controller 出票时、收工时各对照本清单跑一遍**。
1. **先读参照模块**:动手前先读对称参照模块(线索 `crm-lead`、商机 `crm-opportunity`)的 Controller 形态,不另起风格。
2. **URL 风格**:扁平动作动词 + 查询参数传 id(如 `POST /edit?id=`、`GET /detail?id=`);**禁 `@PathVariable`、`@PutMapping`、`@DeleteMapping`**;写端点一律 POST,读端点 GET。
3. **深 tag**:新 Controller 每个端点必挂完整页面路径 tag,一级 = 原型分区原文(如 `A4 客户管理`);权威树 = `.scratch/api-docs-reorg/lanhu-tree-v29.md`;跨页共用接口用方法级多值 tags(菜单粒度依据 [ADR-0024](docs/adr/0024-bruno-docs-grouping-by-menu.md))。
4. **bruno-sync 配置**:`bruno-sync.config.json` 的 `sourceRoots` 加新模块目录,`menuBindings` 加菜单(viewType / scopeKey)——漏配则文档示例值错误。
5. **出入参**:写端点禁 `@RequestBody` JSON,参数走表单绑定;Param / DTO 规范见 [ADR-0017](docs/adr/0017-controller-io-param-dto-conventions.md)。
6. **对照时点**:出票时、收工时各对照本 checklist 跑一遍(模块复核的规范镜头以此为准)。
## Tooling / harness
### File-edit tool degradation strategy (when `edit` silently drops its payload)

2
crm-app/.factorypath

@ -134,7 +134,7 @@
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/httpcomponents/httpcore/4.4.16/httpcore-4.4.16.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/httpcomponents/httpmime/4.5/httpmime-4.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/commons-codec/commons-codec/1.16.1/commons-codec-1.16.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/commons-io/commons-io/2.5/commons-io-2.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/commons-io/commons-io/2.16.1/commons-io-2.16.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/commons/commons-collections4/4.4/commons-collections4-4.4.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/google/code/gson/gson/2.10.1/gson-2.10.1.jar" enabled="true" runInBatchMode="false"/>

1
crm-app/.settings/org.eclipse.jdt.core.prefs

@ -12,6 +12,7 @@ org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
org.eclipse.jdt.core.compiler.problem.nullAnnotationInferenceConflict=warning
org.eclipse.jdt.core.compiler.problem.nullReference=warning
org.eclipse.jdt.core.compiler.problem.nullSpecViolation=warning
org.eclipse.jdt.core.compiler.problem.nullUncheckedConversion=warning
org.eclipse.jdt.core.compiler.problem.potentialNullReference=warning
org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=ignore
org.eclipse.jdt.core.compiler.problem.syntacticNullAnalysisForFields=enabled

2
crm-auth/.factorypath

@ -138,7 +138,7 @@
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/httpcomponents/httpcore/4.4.16/httpcore-4.4.16.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/httpcomponents/httpmime/4.5/httpmime-4.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/commons-codec/commons-codec/1.16.1/commons-codec-1.16.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/commons-io/commons-io/2.5/commons-io-2.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/commons-io/commons-io/2.16.1/commons-io-2.16.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/commons/commons-collections4/4.4/commons-collections4-4.4.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/slf4j/slf4j-api/2.0.16/slf4j-api-2.0.16.jar" enabled="true" runInBatchMode="false"/>

1
crm-auth/.settings/org.eclipse.jdt.core.prefs

@ -12,6 +12,7 @@ org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
org.eclipse.jdt.core.compiler.problem.nullAnnotationInferenceConflict=warning
org.eclipse.jdt.core.compiler.problem.nullReference=warning
org.eclipse.jdt.core.compiler.problem.nullSpecViolation=warning
org.eclipse.jdt.core.compiler.problem.nullUncheckedConversion=warning
org.eclipse.jdt.core.compiler.problem.potentialNullReference=warning
org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=ignore
org.eclipse.jdt.core.compiler.problem.syntacticNullAnalysisForFields=enabled

2
crm-base/.factorypath

@ -112,7 +112,7 @@
<factorypathentry kind="VARJAR" id="M2_REPO/commons-codec/commons-codec/1.16.1/commons-codec-1.16.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/commons/commons-collections4/4.4/commons-collections4-4.4.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/commons/commons-math3/3.6.1/commons-math3-3.6.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/commons-io/commons-io/2.15.0/commons-io-2.15.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/commons-io/commons-io/2.16.1/commons-io-2.16.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/zaxxer/SparseBitSet/1.3/SparseBitSet-1.3.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/logging/log4j/log4j-api/2.23.1/log4j-api-2.23.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/poi/poi-ooxml/5.2.5/poi-ooxml-5.2.5.jar" enabled="true" runInBatchMode="false"/>

1
crm-base/.settings/org.eclipse.jdt.core.prefs

@ -12,6 +12,7 @@ org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
org.eclipse.jdt.core.compiler.problem.nullAnnotationInferenceConflict=warning
org.eclipse.jdt.core.compiler.problem.nullReference=warning
org.eclipse.jdt.core.compiler.problem.nullSpecViolation=warning
org.eclipse.jdt.core.compiler.problem.nullUncheckedConversion=warning
org.eclipse.jdt.core.compiler.problem.potentialNullReference=warning
org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=ignore
org.eclipse.jdt.core.compiler.problem.syntacticNullAnalysisForFields=enabled

57
crm-customer/.classpath

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="target/classes" path="src/main/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry excluding="**" kind="src" output="target/classes" path="src/main/resources">
<attributes>
<attribute name="maven.pomderived" value="true"/>
<attribute name="optional" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="target/test-classes" path="src/test/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry excluding="**" kind="src" output="target/test-classes" path="src/test/resources">
<attributes>
<attribute name="maven.pomderived" value="true"/>
<attribute name="test" value="true"/>
<attribute name="optional" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-17">
<attributes>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER">
<attributes>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="src" path="target/generated-sources/annotations">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
<attribute name="ignore_optional_problems" value="true"/>
<attribute name="m2e-apt" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="target/test-classes" path="target/generated-test-sources/test-annotations">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
<attribute name="ignore_optional_problems" value="true"/>
<attribute name="m2e-apt" value="true"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="output" path="target/classes"/>
</classpath>

157
crm-customer/.factorypath

@ -0,0 +1,157 @@
<factorypath>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter-web/3.3.5/spring-boot-starter-web-3.3.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter-json/3.3.5/spring-boot-starter-json-3.3.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/fasterxml/jackson/datatype/jackson-datatype-jdk8/2.17.2/jackson-datatype-jdk8-2.17.2.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/fasterxml/jackson/datatype/jackson-datatype-jsr310/2.17.2/jackson-datatype-jsr310-2.17.2.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/fasterxml/jackson/module/jackson-module-parameter-names/2.17.2/jackson-module-parameter-names-2.17.2.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter-tomcat/3.3.5/spring-boot-starter-tomcat-3.3.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/tomcat/embed/tomcat-embed-core/10.1.31/tomcat-embed-core-10.1.31.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/tomcat/embed/tomcat-embed-websocket/10.1.31/tomcat-embed-websocket-10.1.31.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-web/6.1.14/spring-web-6.1.14.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-webmvc/6.1.14/spring-webmvc-6.1.14.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter-validation/3.3.5/spring-boot-starter-validation-3.3.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/tomcat/embed/tomcat-embed-el/10.1.31/tomcat-embed-el-10.1.31.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/hibernate/validator/hibernate-validator/8.0.1.Final/hibernate-validator-8.0.1.Final.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/jboss/logging/jboss-logging/3.5.3.Final/jboss-logging-3.5.3.Final.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/fasterxml/classmate/1.7.0/classmate-1.7.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/baomidou/mybatis-plus-spring-boot3-starter/3.5.7/mybatis-plus-spring-boot3-starter-3.5.7.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/baomidou/mybatis-plus/3.5.7/mybatis-plus-3.5.7.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/baomidou/mybatis-plus-core/3.5.7/mybatis-plus-core-3.5.7.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/baomidou/mybatis-plus-annotation/3.5.7/mybatis-plus-annotation-3.5.7.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/baomidou/mybatis-plus-extension/3.5.7/mybatis-plus-extension-3.5.7.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/mybatis/mybatis/3.5.16/mybatis-3.5.16.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/github/jsqlparser/jsqlparser/4.9/jsqlparser-4.9.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/baomidou/mybatis-plus-spring-boot-autoconfigure/3.5.7/mybatis-plus-spring-boot-autoconfigure-3.5.7.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-autoconfigure/3.3.5/spring-boot-autoconfigure-3.3.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter-jdbc/3.3.5/spring-boot-starter-jdbc-3.3.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/zaxxer/HikariCP/5.1.0/HikariCP-5.1.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-jdbc/6.1.14/spring-jdbc-6.1.14.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter-data-jpa/3.3.5/spring-boot-starter-data-jpa-3.3.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter-aop/3.3.5/spring-boot-starter-aop-3.3.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/aspectj/aspectjweaver/1.9.22.1/aspectjweaver-1.9.22.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/hibernate/orm/hibernate-core/6.5.3.Final/hibernate-core-6.5.3.Final.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/jakarta/persistence/jakarta.persistence-api/3.1.0/jakarta.persistence-api-3.1.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/jakarta/transaction/jakarta.transaction-api/2.0.1/jakarta.transaction-api-2.0.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/hibernate/common/hibernate-commons-annotations/6.0.6.Final/hibernate-commons-annotations-6.0.6.Final.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/io/smallrye/jandex/3.1.2/jandex-3.1.2.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/glassfish/jaxb/jaxb-runtime/4.0.5/jaxb-runtime-4.0.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/glassfish/jaxb/jaxb-core/4.0.5/jaxb-core-4.0.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/eclipse/angus/angus-activation/2.0.2/angus-activation-2.0.2.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/glassfish/jaxb/txw2/4.0.5/txw2-4.0.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/sun/istack/istack-commons-runtime/4.1.2/istack-commons-runtime-4.1.2.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/jakarta/inject/jakarta.inject-api/2.0.1/jakarta.inject-api-2.0.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/antlr/antlr4-runtime/4.13.0/antlr4-runtime-4.13.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/data/spring-data-jpa/3.3.5/spring-data-jpa-3.3.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/data/spring-data-commons/3.3.5/spring-data-commons-3.3.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-orm/6.1.14/spring-orm-6.1.14.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-tx/6.1.14/spring-tx-6.1.14.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-aspects/6.1.14/spring-aspects-6.1.14.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter-data-redis/3.3.5/spring-boot-starter-data-redis-3.3.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/io/lettuce/lettuce-core/6.3.2.RELEASE/lettuce-core-6.3.2.RELEASE.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/io/netty/netty-common/4.1.114.Final/netty-common-4.1.114.Final.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/io/netty/netty-handler/4.1.114.Final/netty-handler-4.1.114.Final.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/io/netty/netty-resolver/4.1.114.Final/netty-resolver-4.1.114.Final.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/io/netty/netty-buffer/4.1.114.Final/netty-buffer-4.1.114.Final.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/io/netty/netty-transport-native-unix-common/4.1.114.Final/netty-transport-native-unix-common-4.1.114.Final.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/io/netty/netty-codec/4.1.114.Final/netty-codec-4.1.114.Final.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/io/netty/netty-transport/4.1.114.Final/netty-transport-4.1.114.Final.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/io/projectreactor/reactor-core/3.6.11/reactor-core-3.6.11.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/reactivestreams/reactive-streams/1.0.4/reactive-streams-1.0.4.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/data/spring-data-redis/3.3.5/spring-data-redis-3.3.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/data/spring-data-keyvalue/3.3.5/spring-data-keyvalue-3.3.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-oxm/6.1.14/spring-oxm-6.1.14.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-context-support/6.1.14/spring-context-support-6.1.14.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/security/spring-security-core/6.3.4/spring-security-core-6.3.4.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/security/spring-security-crypto/6.3.4/spring-security-crypto-6.3.4.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-aop/6.1.14/spring-aop-6.1.14.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-beans/6.1.14/spring-beans-6.1.14.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-context/6.1.14/spring-context-6.1.14.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-expression/6.1.14/spring-expression-6.1.14.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/io/micrometer/micrometer-observation/1.13.6/micrometer-observation-1.13.6.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/io/micrometer/micrometer-commons/1.13.6/micrometer-commons-1.13.6.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/github/xiaoymin/knife4j-openapi3-jakarta-spring-boot-starter/4.5.0/knife4j-openapi3-jakarta-spring-boot-starter-4.5.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/github/xiaoymin/knife4j-core/4.5.0/knife4j-core-4.5.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/github/xiaoymin/knife4j-openapi3-ui/4.5.0/knife4j-openapi3-ui-4.5.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springdoc/springdoc-openapi-starter-webmvc-ui/2.3.0/springdoc-openapi-starter-webmvc-ui-2.3.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springdoc/springdoc-openapi-starter-webmvc-api/2.3.0/springdoc-openapi-starter-webmvc-api-2.3.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springdoc/springdoc-openapi-starter-common/2.3.0/springdoc-openapi-starter-common-2.3.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/io/swagger/core/v3/swagger-core-jakarta/2.2.19/swagger-core-jakarta-2.2.19.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/io/swagger/core/v3/swagger-annotations-jakarta/2.2.19/swagger-annotations-jakarta-2.2.19.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/io/swagger/core/v3/swagger-models-jakarta/2.2.19/swagger-models-jakarta-2.2.19.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/fasterxml/jackson/dataformat/jackson-dataformat-yaml/2.17.2/jackson-dataformat-yaml-2.17.2.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/webjars/swagger-ui/5.10.3/swagger-ui-5.10.3.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/cn/hutool/hutool-all/5.8.32/hutool-all-5.8.32.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/google/guava/guava/33.2.1-jre/guava-33.2.1-jre.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/google/guava/failureaccess/1.0.2/failureaccess-1.0.2.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/checkerframework/checker-qual/3.42.0/checker-qual-3.42.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/google/errorprone/error_prone_annotations/2.26.1/error_prone_annotations-2.26.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/google/j2objc/j2objc-annotations/3.0.0/j2objc-annotations-3.0.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/jakarta/validation/jakarta.validation-api/3.0.2/jakarta.validation-api-3.0.2.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter-security/3.3.5/spring-boot-starter-security-3.3.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/security/spring-security-config/6.3.4/spring-security-config-6.3.4.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/security/spring-security-web/6.3.4/spring-security-web-6.3.4.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/github/tingyugetc520/dt-java/0.1.2/dt-java-0.1.2.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/squareup/okhttp3/okhttp/4.12.0/okhttp-4.12.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/squareup/okio/okio/3.6.0/okio-3.6.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/squareup/okio/okio-jvm/3.6.0/okio-jvm-3.6.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/jetbrains/kotlin/kotlin-stdlib-common/1.9.25/kotlin-stdlib-common-1.9.25.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.9.25/kotlin-stdlib-jdk8-1.9.25.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/jetbrains/kotlin/kotlin-stdlib/1.9.25/kotlin-stdlib-1.9.25.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/jetbrains/annotations/13.0/annotations-13.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.9.25/kotlin-stdlib-jdk7-1.9.25.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/httpcomponents/httpclient/4.5/httpclient-4.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/httpcomponents/httpcore/4.4.16/httpcore-4.4.16.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/httpcomponents/httpmime/4.5/httpmime-4.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/commons-codec/commons-codec/1.16.1/commons-codec-1.16.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/commons-io/commons-io/2.16.1/commons-io-2.16.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/commons/commons-collections4/4.4/commons-collections4-4.4.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/google/code/gson/gson/2.10.1/gson-2.10.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/mysql/mysql-connector-j/8.3.0/mysql-connector-j-8.3.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/github/ben-manes/caffeine/caffeine/3.1.8/caffeine-3.1.8.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/mybatis/mybatis-spring/3.0.3/mybatis-spring-3.0.3.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/io/minio/minio/8.5.17/minio-8.5.17.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/carrotsearch/thirdparty/simple-xml-safe/2.7.1/simple-xml-safe-2.7.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/fasterxml/jackson/core/jackson-annotations/2.17.2/jackson-annotations-2.17.2.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/fasterxml/jackson/core/jackson-core/2.17.2/jackson-core-2.17.2.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/fasterxml/jackson/core/jackson-databind/2.17.2/jackson-databind-2.17.2.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/bouncycastle/bcprov-jdk18on/1.78.1/bcprov-jdk18on-1.78.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/commons/commons-compress/1.27.1/commons-compress-1.27.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/xerial/snappy/snappy-java/1.1.10.7/snappy-java-1.1.10.7.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/pdfbox/pdfbox/3.0.3/pdfbox-3.0.3.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/pdfbox/pdfbox-io/3.0.3/pdfbox-io-3.0.3.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/pdfbox/fontbox/3.0.3/fontbox-3.0.3.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/commons-logging/commons-logging/1.3.3/commons-logging-1.3.3.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/alibaba/easyexcel/4.0.3/easyexcel-4.0.3.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/alibaba/easyexcel-core/4.0.3/easyexcel-core-4.0.3.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/alibaba/easyexcel-support/3.3.4/easyexcel-support-3.3.4.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/poi/poi/5.2.5/poi-5.2.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/commons/commons-math3/3.6.1/commons-math3-3.6.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/zaxxer/SparseBitSet/1.3/SparseBitSet-1.3.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/logging/log4j/log4j-api/2.23.1/log4j-api-2.23.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/poi/poi-ooxml/5.2.5/poi-ooxml-5.2.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/poi/poi-ooxml-lite/5.2.5/poi-ooxml-lite-5.2.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/xmlbeans/xmlbeans/5.2.0/xmlbeans-5.2.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/com/github/virtuald/curvesapi/1.08/curvesapi-1.08.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/commons/commons-csv/1.11.0/commons-csv-1.11.0.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/ehcache/ehcache/3.10.8/ehcache-3.10.8.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/javax/cache/cache-api/1.1.1/cache-api-1.1.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/slf4j/slf4j-api/2.0.16/slf4j-api-2.0.16.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/projectlombok/lombok/1.18.34/lombok-1.18.34.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter/3.3.5/spring-boot-starter-3.3.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot/3.3.5/spring-boot-3.3.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter-logging/3.3.5/spring-boot-starter-logging-3.3.5.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/ch/qos/logback/logback-classic/1.5.11/logback-classic-1.5.11.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/ch/qos/logback/logback-core/1.5.11/logback-core-1.5.11.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/logging/log4j/log4j-to-slf4j/2.23.1/log4j-to-slf4j-2.23.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/slf4j/jul-to-slf4j/2.0.16/jul-to-slf4j-2.0.16.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/jakarta/annotation/jakarta.annotation-api/2.1.1/jakarta.annotation-api-2.1.1.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/yaml/snakeyaml/2.2/snakeyaml-2.2.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/jakarta/xml/bind/jakarta.xml.bind-api/4.0.2/jakarta.xml.bind-api-4.0.2.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/jakarta/activation/jakarta.activation-api/2.1.3/jakarta.activation-api-2.1.3.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/net/bytebuddy/byte-buddy/1.14.19/byte-buddy-1.14.19.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-core/6.1.14/spring-core-6.1.14.jar" enabled="true" runInBatchMode="false"/>
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-jcl/6.1.14/spring-jcl-6.1.14.jar" enabled="true" runInBatchMode="false"/>
</factorypath>

34
crm-customer/.project

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>crm-customer</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.m2e.core.maven2Builder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.m2e.core.maven2Nature</nature>
</natures>
<filteredResources>
<filter>
<id>1788504423349</id>
<name></name>
<type>30</type>
<matcher>
<id>org.eclipse.core.resources.regexFilterMatcher</id>
<arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
</matcher>
</filter>
</filteredResources>
</projectDescription>

4
crm-customer/.settings/org.eclipse.core.resources.prefs

@ -0,0 +1,4 @@
eclipse.preferences.version=1
encoding//src/main/java=UTF-8
encoding//src/test/java=UTF-8
encoding/<project>=UTF-8

4
crm-customer/.settings/org.eclipse.jdt.apt.core.prefs

@ -0,0 +1,4 @@
eclipse.preferences.version=1
org.eclipse.jdt.apt.aptEnabled=true
org.eclipse.jdt.apt.genSrcDir=target/generated-sources/annotations
org.eclipse.jdt.apt.genTestSrcDir=target/generated-test-sources/test-annotations

20
crm-customer/.settings/org.eclipse.jdt.core.prefs

@ -0,0 +1,20 @@
eclipse.preferences.version=1
org.eclipse.jdt.core.compiler.annotation.missingNonNullByDefaultAnnotation=ignore
org.eclipse.jdt.core.compiler.annotation.nonnull=javax.annotation.Nonnull
org.eclipse.jdt.core.compiler.annotation.nonnullbydefault=javax.annotation.ParametersAreNonnullByDefault
org.eclipse.jdt.core.compiler.annotation.nullable=javax.annotation.Nullable
org.eclipse.jdt.core.compiler.annotation.nullanalysis=enabled
org.eclipse.jdt.core.compiler.codegen.methodParameters=generate
org.eclipse.jdt.core.compiler.codegen.targetPlatform=17
org.eclipse.jdt.core.compiler.compliance=17
org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled
org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
org.eclipse.jdt.core.compiler.problem.nullAnnotationInferenceConflict=warning
org.eclipse.jdt.core.compiler.problem.nullReference=warning
org.eclipse.jdt.core.compiler.problem.nullSpecViolation=warning
org.eclipse.jdt.core.compiler.problem.potentialNullReference=warning
org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=ignore
org.eclipse.jdt.core.compiler.problem.syntacticNullAnalysisForFields=enabled
org.eclipse.jdt.core.compiler.processAnnotations=enabled
org.eclipse.jdt.core.compiler.release=enabled
org.eclipse.jdt.core.compiler.source=17

4
crm-customer/.settings/org.eclipse.m2e.core.prefs

@ -0,0 +1,4 @@
activeProfiles=
eclipse.preferences.version=1
resolveWorkspaceProjects=true
version=1

17
crm-customer/src/main/java/com/crm/customer/controller/CustomerContactController.java

@ -9,11 +9,8 @@ import com.crm.customer.service.ICustomerContactService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@ -35,28 +32,28 @@ public class CustomerContactController {
private final ICustomerContactService contactService;
@Operation(summary = "新增联系人", description = "所属客户必填且对当前用户可见;联系电话手机号格式")
@PostMapping
@PostMapping("/create")
public Result<Long> create(ContactDTO dto) {
return Result.success(contactService.create(dto));
}
@Operation(summary = "编辑联系人", description = "归属客户不可换;联系电话手机号格式")
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id, ContactDTO dto) {
@PostMapping("/edit")
public Result<Void> update(@RequestParam("id") Long id, ContactDTO dto) {
contactService.update(id, dto);
return Result.success();
}
@Operation(summary = "删除联系人", description = "软删(可能被商机/项目引用做快照)")
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
@PostMapping("/delete")
public Result<Void> delete(@RequestParam("id") Long id) {
contactService.delete(id);
return Result.success();
}
@Operation(summary = "联系人详情", description = "带出所属公司名")
@GetMapping("/{id}")
public Result<ContactDTO> detail(@PathVariable Long id) {
@GetMapping("/detail")
public Result<ContactDTO> detail(@RequestParam("id") Long id) {
return Result.success(contactService.detail(id));
}

18
crm-customer/src/main/java/com/crm/customer/controller/CustomerController.java

@ -21,9 +21,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@ -33,7 +31,7 @@ import java.util.List;
/**
* 客户主表接口 02CRUD + 三层查重 + 企查查 seam 转发
*
* <p>路由风格同项目其余 ControllerGET 查询POST/PUT 写操作写入参 DTO
* <p>路由风格同项目其余 ControllerGET 查询POST 写操作扁平动作动词查询参数传 id写入参 DTO
* 隐式表单绑定权限由动态 API 权限拦截器收口无显式注解</p>
*/
@Tag(name = "A4 客户管理/客户管理", description = "客户主表 CRUD 与查重")
@ -48,20 +46,20 @@ public class CustomerController {
private final CompanyLookupPort companyLookupPort;
@Operation(summary = "新增客户", description = "三层查重内建:名称相似返回 needConfirm(未落库),确认后带 confirmSimilar 重发")
@PostMapping
@PostMapping("/create")
public Result<CustomerSaveResultDTO> create(CustomerCreateDTO dto) {
return Result.success(customerService.create(dto));
}
@Operation(summary = "编辑客户", description = "CAS 乐观锁(version 必传);查重排除自身;归属字段不可改")
@PutMapping("/{id}")
public Result<CustomerSaveResultDTO> update(@PathVariable Long id, CustomerUpdateDTO dto) {
@PostMapping("/edit")
public Result<CustomerSaveResultDTO> update(@RequestParam("id") Long id, CustomerUpdateDTO dto) {
return Result.success(customerService.update(id, dto));
}
@Operation(summary = "客户详情", description = "全字段带出 + 字典名回显;编辑页复用")
@GetMapping("/{id}")
public Result<CustomerDetailDTO> detail(@PathVariable Long id) {
@GetMapping("/detail")
public Result<CustomerDetailDTO> detail(@RequestParam("id") Long id) {
return Result.success(customerService.detail(id));
}
@ -86,8 +84,8 @@ public class CustomerController {
}
@Operation(summary = "按客户查联系人列表", description = "商机字段17级联数据源;权限继承客户(无独立 ACL)")
@GetMapping("/{customerId}/contacts")
public Result<List<ContactDTO>> listContacts(@PathVariable Long customerId) {
@GetMapping("/contacts")
public Result<List<ContactDTO>> listContacts(@RequestParam("customerId") Long customerId) {
return Result.success(contactService.listByCustomer(customerId));
}

37
crm-customer/src/main/java/com/crm/customer/controller/CustomerDetailController.java

@ -17,11 +17,10 @@ import com.crm.customer.service.ICustomerFollowService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@ -29,7 +28,7 @@ import java.util.List;
/**
* 客户详情页 8 页签接口 06 / issues-07 §A
*
* <p>页签骨架1 客户信息 02 GET /{id}/ 2 联系人 03 GET /{customerId}/contacts/
* <p>页签骨架1 客户信息 02 GET /detail/ 2 联系人 03 GET /contacts?customerId=/路由风格返工票 02查询参数传 id
* 3 跟进记录 / 4 关联商机port 反查真源票 11/ 5 关联项目与 6 战略协议外部子域 "--"/
* 7 团队成员含管理写端点 12 不符补实现/ 8 操作日志首次打开才加载切回保留条件分页由前端控制</p>
*
@ -46,53 +45,53 @@ public class CustomerDetailController {
private final ICustomerFollowService followService;
@Operation(summary = "详情公共头部", description = "8 页签共享:阶段只读条/星级/协议VIP快照/负责人/最近跟进摘要/待跟进提醒/关联业务计数;汇总卡片外部模块失败显--")
@GetMapping("/{id}/detail-head")
public Result<CustomerDetailHeadDTO> detailHead(@PathVariable Long id) {
@GetMapping("/detail-head")
public Result<CustomerDetailHeadDTO> detailHead(@RequestParam("id") Long id) {
return Result.success(detailService.detailHead(id));
}
@Operation(summary = "团队成员页签", description = "销售负责人(快照)+ 协同人列表")
@GetMapping("/{id}/members")
public Result<List<CustomerMemberDTO>> listMembers(@PathVariable Long id) {
@GetMapping("/member/list")
public Result<List<CustomerMemberDTO>> listMembers(@RequestParam("id") Long id) {
return Result.success(detailService.listMembers(id));
}
@Operation(summary = "添加团队成员", description = "原型 11.1 搜索多选;整批校验(名单空/含负责人/重复/停用 → 67016)+ oplog MEMBER_ADD")
@PostMapping("/{id}/members")
public Result<Void> addMembers(@PathVariable Long id, MemberAddDTO dto) {
@PostMapping("/member/add")
public Result<Void> addMembers(@RequestParam("id") Long id, MemberAddDTO dto) {
detailService.addMembers(id, dto.getMemberUserIds());
return Result.success();
}
@Operation(summary = "移除团队成员", description = "原型 11.1 二次确认;软删保留历史(delete_key 复用键,可重复加入)+ oplog MEMBER_REMOVE")
@DeleteMapping("/{id}/members/{memberUserId}")
public Result<Void> removeMember(@PathVariable Long id, @PathVariable Long memberUserId) {
@PostMapping("/member/remove")
public Result<Void> removeMember(@RequestParam("id") Long id, @RequestParam("memberUserId") Long memberUserId) {
detailService.removeMember(id, memberUserId);
return Result.success();
}
@Operation(summary = "关联商机页签", description = "只读反查 crm-opportunity(port seam,真源接线归票 11);失败显--不显 0")
@GetMapping("/{id}/opportunities")
@GetMapping("/opportunity/page")
public Result<PageResult<CustomerOpportunityQueryPort.OpportunityItem>> pageOpportunities(
@PathVariable Long id, BaseParam param) {
@RequestParam("id") Long id, BaseParam param) {
return Result.success(detailService.pageOpportunities(id, param));
}
@Operation(summary = "操作日志分页", description = "append-only 审计,无限期保留(§5.5);时间倒序 + 关键字/时间范围过滤")
@GetMapping("/{id}/oplog/page")
public Result<PageResult<CustomerOplog>> pageOplogs(@PathVariable Long id, OplogPageParam param) {
@GetMapping("/oplog/page")
public Result<PageResult<CustomerOplog>> pageOplogs(@RequestParam("id") Long id, OplogPageParam param) {
return Result.success(detailService.pageOplogs(id, param));
}
@Operation(summary = "新增跟进", description = "append-only 提交后不可改删(纠错=新增补充);防重复提交;写库刷新超期提醒锚点 last_valid_follow_time")
@PostMapping("/{id}/follow")
public Result<Long> addFollow(@PathVariable Long id, FollowCreateDTO dto) {
@PostMapping("/follow/add")
public Result<Long> addFollow(@RequestParam("id") Long id, FollowCreateDTO dto) {
return Result.success(followService.addFollow(id, dto));
}
@Operation(summary = "跟进记录分页", description = "时间倒序 + 日期分组(前端);按 follow_way/关键字/时间范围过滤")
@GetMapping("/{id}/follow/page")
public Result<PageResult<CustomerFollow>> pageFollows(@PathVariable Long id, FollowPageParam param) {
@GetMapping("/follow/page")
public Result<PageResult<CustomerFollow>> pageFollows(@RequestParam("id") Long id, FollowPageParam param) {
return Result.success(followService.pageFollows(id, param));
}
}

19
crm-customer/src/main/java/com/crm/customer/controller/CustomerFocusController.java

@ -5,7 +5,6 @@ import com.crm.customer.service.ICustomerFocusService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@ -17,7 +16,7 @@ import java.util.List;
* 客户关注/重点客户标记接口 12 不符补实现薄适配层
*
* <p>对称商机 {@code OpportunityCollabController}focus/unfocus/focus-batch
* 单客户操作走模块内 {@code /{id}/xxx} 路径参数范式对齐 contacts/follow/members
* 单客户操作走 {@code POST /xxx?id=} 查询参数范式返工票 02 全模块统一
* 批量关注对称商机 focus-batch ids 参数</p>
*/
@RestController
@ -29,29 +28,29 @@ public class CustomerFocusController {
private final ICustomerFocusService focusService;
@Operation(summary = "关注客户", description = "幂等(重复关注直接成功);行存在性=关注态,不写 oplog")
@PostMapping("/{id}/focus")
public Result<Void> focus(@PathVariable("id") Long id) {
@PostMapping("/focus")
public Result<Void> focus(@RequestParam("id") Long id) {
focusService.focus(id);
return Result.success();
}
@Operation(summary = "取消关注客户", description = "物理删行,连带清除重点标记;未关注时幂等成功")
@PostMapping("/{id}/unfocus")
public Result<Void> unfocus(@PathVariable("id") Long id) {
@PostMapping("/unfocus")
public Result<Void> unfocus(@RequestParam("id") Long id) {
focusService.unfocus(id);
return Result.success();
}
@Operation(summary = "标记重点客户", description = "详情页操作区按钮;自动确保关注行存在(未关注先建行 starred=1);幂等")
@PostMapping("/{id}/star")
public Result<Void> star(@PathVariable("id") Long id) {
@PostMapping("/star")
public Result<Void> star(@RequestParam("id") Long id) {
focusService.star(id);
return Result.success();
}
@Operation(summary = "取消重点标记", description = "保留关注行,仅置 starred=0;幂等")
@PostMapping("/{id}/unstar")
public Result<Void> unstar(@PathVariable("id") Long id) {
@PostMapping("/unstar")
public Result<Void> unstar(@RequestParam("id") Long id) {
focusService.unstar(id);
return Result.success();
}

13
crm-customer/src/main/java/com/crm/customer/controller/CustomerImportController.java

@ -14,7 +14,6 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@ -53,20 +52,20 @@ public class CustomerImportController {
}
@Operation(summary = "确认执行导入", description = "待确认 → 处理中 + 触发异步执行;重复确认 67014 阻断")
@PostMapping("/{taskId}/confirm")
public Result<Long> confirm(@PathVariable Long taskId) {
@PostMapping("/confirm")
public Result<Long> confirm(@RequestParam("taskId") Long taskId) {
return Result.success(importService.confirm(taskId));
}
@Operation(summary = "任务结果", description = "结果页:状态 + 总行数/新增/更新/未变化/失败/疑似重复 + 提交人/时间")
@GetMapping("/{taskId}")
public Result<ImportResultDTO> result(@PathVariable Long taskId) {
@GetMapping("/result")
public Result<ImportResultDTO> result(@RequestParam("taskId") Long taskId) {
return Result.success(importService.result(taskId));
}
@Operation(summary = "失败/疑似重复明细", description = "sheet/行号/客户识别/失败字段/原因;疑似重复行以「疑似重复」前缀区分")
@GetMapping("/{taskId}/failures")
public Result<List<ImportFailDTO>> failures(@PathVariable Long taskId) {
@GetMapping("/failures")
public Result<List<ImportFailDTO>> failures(@RequestParam("taskId") Long taskId) {
return Result.success(importService.failures(taskId));
}

13
crm-customer/src/main/java/com/crm/customer/controller/CustomerTransferController.java

@ -14,7 +14,6 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@ -51,8 +50,8 @@ public class CustomerTransferController {
}
@Operation(summary = "交接详情", description = "单头 + 明细(客户名实时回显,进度 = assignedCount/totalCount)")
@GetMapping("/{id}")
public Result<TransferDetailDTO> detail(@PathVariable Long id) {
@GetMapping("/detail")
public Result<TransferDetailDTO> detail(@RequestParam("id") Long id) {
return Result.success(transferService.detail(id));
}
@ -63,16 +62,16 @@ public class CustomerTransferController {
}
@Operation(summary = "客户分配(单个或批量)", description = "接收总监→具体销售;逐条独立事务部分成功,失败明细列原因;全部分配完成自动置已完成(D23)")
@PostMapping("/{id}/assign")
public Result<BatchResult<CustomerBatchFailItem>> assign(@PathVariable Long id,
@PostMapping("/assign")
public Result<BatchResult<CustomerBatchFailItem>> assign(@RequestParam("id") Long id,
@RequestParam("customerIds") List<Long> customerIds,
@RequestParam("assignUserId") Long assignUserId) {
return Result.success(transferService.assign(id, customerIds, assignUserId));
}
@Operation(summary = "可分配销售列表", description = "接收总监部门(含子部门)+ 启用在职;排除发起人与总监本人")
@GetMapping("/{id}/assignable")
public Result<List<AssignableUserDTO>> assignableUsers(@PathVariable Long id) {
@GetMapping("/assignable")
public Result<List<AssignableUserDTO>> assignableUsers(@RequestParam("id") Long id) {
return Result.success(transferService.assignableUsers(id));
}
}

24
crm-customer/src/main/java/com/crm/customer/controller/CustomerWorkspaceController.java

@ -9,7 +9,6 @@ import com.crm.customer.service.ICustomerWorkspaceService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@ -19,7 +18,7 @@ import java.util.List;
/**
* 客户三 workspace 接口 04逻辑视图不落库
*
* <p>workspace 三值路径段mine我的客户/ overview客户总览/ pool客户公海
* <p>workspace 三值参数CustomerWorkspacePageParam.workspacemine我的客户/ overview客户总览/ pool客户公海
* 数据范围口径见 {@code CustomerWorkspaceServiceImpl}看板三分组页签
* summary + cards 同一 WHERE 口径同一份列表数据三种渲染对称商机 board 约定
* 权限由动态 API 权限拦截器收口</p>
@ -33,23 +32,20 @@ public class CustomerWorkspaceController {
private final ICustomerWorkspaceService workspaceService;
@Operation(summary = "workspace 列表分页", description = "内置视图/共用列筛选;overview 走 DataScope,mine=负责人或协同人,pool=owner 空按部门可见范围")
@PostMapping("/{workspace}/page")
public Result<PageResult<CustomerListRowDTO>> page(@PathVariable String workspace,
CustomerWorkspacePageParam param) {
return Result.success(workspaceService.page(workspace, param));
@PostMapping("/page")
public Result<PageResult<CustomerListRowDTO>> page(CustomerWorkspacePageParam param) {
return Result.success(workspaceService.page(param.getWorkspace(), param));
}
@Operation(summary = "看板分组汇总", description = "groupColumn 白名单 stage(客户阶段)/star(客户星级)/relation(关系星级);仅返回有数据分组,空列骨架前端补全")
@PostMapping("/{workspace}/board/summary")
public Result<List<CustomerBoardSummaryDTO>> boardSummary(@PathVariable String workspace,
CustomerWorkspacePageParam param) {
return Result.success(workspaceService.boardSummary(workspace, param));
@PostMapping("/board/summary")
public Result<List<CustomerBoardSummaryDTO>> boardSummary(CustomerWorkspacePageParam param) {
return Result.success(workspaceService.boardSummary(param.getWorkspace(), param));
}
@Operation(summary = "看板单列卡片", description = "groupColumn + groupValue 取卡,口径与列表/summary 一致;需分页参数")
@PostMapping("/{workspace}/board/cards")
public Result<PageResult<CustomerListRowDTO>> boardCards(@PathVariable String workspace,
CustomerWorkspacePageParam param) {
return Result.success(workspaceService.boardCards(workspace, param));
@PostMapping("/board/cards")
public Result<PageResult<CustomerListRowDTO>> boardCards(CustomerWorkspacePageParam param) {
return Result.success(workspaceService.boardCards(param.getWorkspace(), param));
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save