@chenchaolong/plugin-trade-compliance-workbench 1.0.18 → 1.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/lib/catalog-import/catalog-import.pipeline.d.ts.map +1 -1
  2. package/dist/lib/catalog-import/catalog-import.pipeline.js +2 -1
  3. package/dist/lib/catalog-import/catalog-import.pipeline.js.map +1 -1
  4. package/dist/lib/catalog-import/confidence.js +2 -2
  5. package/dist/lib/catalog-import/confidence.js.map +1 -1
  6. package/dist/lib/catalog-import/contracts.d.ts +5 -2
  7. package/dist/lib/catalog-import/contracts.d.ts.map +1 -1
  8. package/dist/lib/catalog-import/outcome.d.ts +19 -0
  9. package/dist/lib/catalog-import/outcome.d.ts.map +1 -0
  10. package/dist/lib/catalog-import/outcome.js +18 -0
  11. package/dist/lib/catalog-import/outcome.js.map +1 -0
  12. package/dist/lib/catalog-import/spreadsheet-structure.d.ts +15 -0
  13. package/dist/lib/catalog-import/spreadsheet-structure.d.ts.map +1 -0
  14. package/dist/lib/catalog-import/spreadsheet-structure.js +44 -0
  15. package/dist/lib/catalog-import/spreadsheet-structure.js.map +1 -0
  16. package/dist/lib/catalog-import/spreadsheet.parser.d.ts.map +1 -1
  17. package/dist/lib/catalog-import/spreadsheet.parser.js +71 -0
  18. package/dist/lib/catalog-import/spreadsheet.parser.js.map +1 -1
  19. package/dist/lib/domain/contract-number.js +2 -2
  20. package/dist/lib/domain/contract-number.js.map +1 -1
  21. package/dist/lib/entities/catalog.entity.d.ts +5 -1
  22. package/dist/lib/entities/catalog.entity.d.ts.map +1 -1
  23. package/dist/lib/entities/catalog.entity.js +16 -0
  24. package/dist/lib/entities/catalog.entity.js.map +1 -1
  25. package/dist/lib/remote-components/trade-compliance-workbench/app.css +1 -1
  26. package/dist/lib/remote-components/trade-compliance-workbench/app.js +5 -5
  27. package/dist/lib/remote-ui/app-source.d.ts.map +1 -1
  28. package/dist/lib/remote-ui/app-source.js +208 -67
  29. package/dist/lib/remote-ui/app-source.js.map +1 -1
  30. package/dist/lib/remote-ui/components/forms.js +1 -1
  31. package/dist/lib/remote-ui/components/forms.js.map +1 -1
  32. package/dist/lib/remote-ui/components/task-status.d.ts +2 -1
  33. package/dist/lib/remote-ui/components/task-status.d.ts.map +1 -1
  34. package/dist/lib/remote-ui/components/task-status.js +5 -4
  35. package/dist/lib/remote-ui/components/task-status.js.map +1 -1
  36. package/dist/lib/remote-ui/contracts.d.ts +3 -0
  37. package/dist/lib/remote-ui/contracts.d.ts.map +1 -1
  38. package/dist/lib/remote-ui/import-task-status.d.ts +10 -0
  39. package/dist/lib/remote-ui/import-task-status.d.ts.map +1 -0
  40. package/dist/lib/remote-ui/import-task-status.js +236 -0
  41. package/dist/lib/remote-ui/import-task-status.js.map +1 -0
  42. package/dist/lib/remote-ui/model.d.ts.map +1 -1
  43. package/dist/lib/remote-ui/model.js +2 -1
  44. package/dist/lib/remote-ui/model.js.map +1 -1
  45. package/dist/lib/remote-ui/pages/analytics-prototype.css +307 -0
  46. package/dist/lib/remote-ui/pages/catalogs.css +681 -0
  47. package/dist/lib/remote-ui/pages/catalogs.d.ts +9 -3
  48. package/dist/lib/remote-ui/pages/catalogs.d.ts.map +1 -1
  49. package/dist/lib/remote-ui/pages/catalogs.js +36 -7
  50. package/dist/lib/remote-ui/pages/catalogs.js.map +1 -1
  51. package/dist/lib/remote-ui/pages/contracts.css +259 -0
  52. package/dist/lib/remote-ui/pages/contracts.d.ts.map +1 -1
  53. package/dist/lib/remote-ui/pages/contracts.js +36 -13
  54. package/dist/lib/remote-ui/pages/contracts.js.map +1 -1
  55. package/dist/lib/remote-ui/pages/files-prototype.css +547 -0
  56. package/dist/lib/remote-ui/pages/files.d.ts +3 -2
  57. package/dist/lib/remote-ui/pages/files.d.ts.map +1 -1
  58. package/dist/lib/remote-ui/pages/files.js +15 -15
  59. package/dist/lib/remote-ui/pages/files.js.map +1 -1
  60. package/dist/lib/remote-ui/pages/procurement-contracts.css +939 -0
  61. package/dist/lib/remote-ui/pages/procurement.js +1 -1
  62. package/dist/lib/remote-ui/pages/procurement.js.map +1 -1
  63. package/dist/lib/remote-ui/pages/sales-files.css +512 -0
  64. package/dist/lib/remote-ui/pages/sales.js +2 -2
  65. package/dist/lib/remote-ui/pages/sales.js.map +1 -1
  66. package/dist/lib/remote-ui/pages/settings.css +178 -0
  67. package/dist/lib/remote-ui/pages/settings.js +1 -1
  68. package/dist/lib/remote-ui/pages/settings.js.map +1 -1
  69. package/dist/lib/remote-ui/polling.d.ts +1 -1
  70. package/dist/lib/remote-ui/polling.d.ts.map +1 -1
  71. package/dist/lib/remote-ui/polling.js +4 -2
  72. package/dist/lib/remote-ui/polling.js.map +1 -1
  73. package/dist/lib/remote-ui/purchase-review-adapter.d.ts +4 -0
  74. package/dist/lib/remote-ui/purchase-review-adapter.d.ts.map +1 -0
  75. package/dist/lib/remote-ui/purchase-review-adapter.js +78 -0
  76. package/dist/lib/remote-ui/purchase-review-adapter.js.map +1 -0
  77. package/dist/lib/remote-ui/styles.css +588 -0
  78. package/dist/lib/sales-file.service.d.ts.map +1 -1
  79. package/dist/lib/sales-file.service.js +19 -7
  80. package/dist/lib/sales-file.service.js.map +1 -1
  81. package/dist/lib/types.d.ts +12 -1
  82. package/dist/lib/types.d.ts.map +1 -1
  83. package/dist/lib/types.js +1 -1
  84. package/dist/lib/types.js.map +1 -1
  85. package/dist/lib/workbench-view.provider.d.ts +24 -0
  86. package/dist/lib/workbench-view.provider.d.ts.map +1 -1
  87. package/dist/lib/workbench-view.provider.js +19 -4
  88. package/dist/lib/workbench-view.provider.js.map +1 -1
  89. package/dist/lib/workbench.plugin.d.ts.map +1 -1
  90. package/dist/lib/workbench.plugin.js +1 -2
  91. package/dist/lib/workbench.plugin.js.map +1 -1
  92. package/dist/lib/workbench.service.d.ts +7 -1
  93. package/dist/lib/workbench.service.d.ts.map +1 -1
  94. package/dist/lib/workbench.service.js +276 -75
  95. package/dist/lib/workbench.service.js.map +1 -1
  96. package/package.json +4 -1
@@ -24,16 +24,23 @@ import { TranslationProvider } from './adapters/translation.provider.js';
24
24
  import { parseBankAccountType, parseExchangeRateInput, parseExpectedRevision, validateBankSettingsInput, validateProfitSettingsInput, validatePurchaseLineDiscriminators, validateTaxRates } from './runtime-validation.js';
25
25
  import { publicCustomerContractReanalysisDto, publicImportTaskDto } from './public-dto.js';
26
26
  import { classifyCandidate } from './catalog-import/confidence.js';
27
+ import { catalogTerminalOutcome } from './catalog-import/outcome.js';
27
28
  const DEFAULT_IMPORT_ISSUE_EXPORT_MAX_ROWS = 50_000;
28
29
  const HARD_IMPORT_ISSUE_EXPORT_MAX_ROWS = 100_000;
29
30
  const CUSTOMER_REANALYSIS_TIMEOUT_MS = 20 * 60 * 1000;
30
- const ANALYTICS_EXPORT_MAX_ROWS = 100_000;
31
+ const DEFAULT_ANALYTICS_EXPORT_MAX_ROWS = 100_000;
31
32
  const CONTROLLED_CATALOG_TRANSLATION_CONCURRENCY = 4;
32
33
  const CONTROLLED_CATALOG_TRANSLATION_TIMEOUT_MS = 60_000;
33
34
  const CONTROLLED_CATALOG_PROCESSING_CLAIM_LEASE_MS = 90_000;
34
35
  const CONTROLLED_CATALOG_PROCESSING_HEARTBEAT_MS = 30_000;
35
36
  const CONTROLLED_CATALOG_PROCESSING_CLAIM_INITIAL_POLL_MS = 250;
36
37
  const CONTROLLED_CATALOG_PROCESSING_CLAIM_MAX_POLL_MS = 1_000;
38
+ const SKIPPED_REASON_MESSAGES = {
39
+ SECTION_OR_NOTE_SKIPPED: '章节或说明行已跳过',
40
+ INVALID_HS_CODE: 'HS 编码格式无效',
41
+ EMPTY_PRODUCT_DESCRIPTION: '商品描述为空',
42
+ STRUCTURAL_MAPPING_AMBIGUOUS: '表格列结构无法可靠判断'
43
+ };
37
44
  const RUNNING_IMPORT_TASK_STATUSES = ['EXTRACTING', 'AI_PARSING', 'VALIDATING', 'PERSISTING'];
38
45
  const RECOVERABLE_IMPORT_TASK_STATUSES = ['QUEUED', 'EXTRACTING', 'AI_PARSING', 'VALIDATING', 'PERSISTING'];
39
46
  const RECOVERABLE_REANALYSIS_STATUSES = ['PENDING', 'RUNNING'];
@@ -716,7 +723,8 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
716
723
  const batch = await batchRepo.save(batchRepo.create({
717
724
  ...auditScope(scope), catalogType, sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName,
718
725
  importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null,
719
- totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0,
726
+ totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0, skippedCount: 0,
727
+ inferenceMetadata: input.inferenceMetadata ?? null,
720
728
  importTaskId: task.id, riskRecalculatedAt: null
721
729
  }));
722
730
  batchId = batch.id;
@@ -736,14 +744,15 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
736
744
  const batch = await batchRepo.save(batchRepo.create({
737
745
  ...auditScope(scope), sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName,
738
746
  importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null,
739
- totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0, importTaskId: task.id
747
+ totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0, skippedCount: 0,
748
+ inferenceMetadata: input.inferenceMetadata ?? null, importTaskId: task.id
740
749
  }));
741
750
  batchId = batch.id;
742
751
  }
743
752
  task.status = 'PERSISTING';
744
753
  task.resultEntityId = batchId;
745
754
  task.completedAt = null;
746
- task.progress = initialFastCatalogProgress(input.totalCandidates);
755
+ task.progress = initialFastCatalogProgress(input.totalCandidates, input.inferenceMetadata);
747
756
  await taskRepo.save(task);
748
757
  return { committedCursor: 0, batchId };
749
758
  });
@@ -771,23 +780,29 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
771
780
  const batchRepo = manager.getRepository(ControlledCatalogBatch);
772
781
  const batch = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId } }), '管控目录批次不存在');
773
782
  await this.appendControlledCatalogRows(manager, scope, batch, controlledRows, localizedRows);
783
+ await this.appendCatalogSkippedSources(manager.getRepository(ControlledCatalogImportIssue), scope, batch, input.skipped);
774
784
  await batchRepo.save(batch);
775
785
  task.progress.successCount = batch.successCount;
776
786
  task.progress.duplicateCount = batch.duplicateCount;
777
787
  task.progress.invalidCount = batch.invalidCount;
788
+ task.progress.skippedCount = batch.skippedCount;
778
789
  }
779
790
  else {
780
791
  const batch = await this.appendFastSanctionCandidates(manager, scope, task.resultEntityId, input.candidates);
792
+ await this.appendCatalogSkippedSources(manager.getRepository(SanctionCatalogImportIssue), scope, batch, input.skipped);
793
+ await manager.getRepository(SanctionCatalogBatch).save(batch);
781
794
  task.progress.successCount = batch.successCount;
782
795
  task.progress.duplicateCount = batch.duplicateCount;
783
796
  task.progress.invalidCount = batch.invalidCount;
797
+ task.progress.skippedCount = batch.skippedCount;
784
798
  }
799
+ let insertedReviewCount = 0;
785
800
  if (input.reviewBlocks.length) {
786
801
  const reviewRepo = manager.getRepository(CatalogReviewBlock);
787
802
  for (const block of input.reviewBlocks) {
788
803
  const existing = await reviewRepo.findOne({ where: {
789
804
  ...activeScope(scope), importTaskId: task.id, ordinal: block.ordinal,
790
- sourceLocation: block.sourceLocation, status: 'PENDING'
805
+ sourceLocation: block.sourceLocation
791
806
  } });
792
807
  if (existing)
793
808
  continue;
@@ -798,11 +813,12 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
798
813
  payload: block.payload, reasons: block.reasons,
799
814
  leaseToken: null, leaseExpiresAt: null, resolvedAt: null
800
815
  }));
816
+ insertedReviewCount += 1;
801
817
  }
802
818
  }
803
819
  task.progress.committedCursor = input.endCursor;
804
820
  task.progress.processedCount = input.endCursor + input.reviewBlocks.length + input.skipped.length;
805
- task.progress.reviewPendingCount += input.reviewBlocks.length;
821
+ task.progress.reviewPendingCount += insertedReviewCount;
806
822
  task.progress.currentBatch = input.endCursor === 0 ? 0 : Math.ceil(input.endCursor / 100);
807
823
  task.progress.progressVersion += 1;
808
824
  task.progress.lastProgressAt = new Date().toISOString();
@@ -820,18 +836,42 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
820
836
  if (!current.progress || current.progress.mode !== 'FAST_PIPELINE') {
821
837
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入进度不存在');
822
838
  }
823
- current.status = current.progress.reviewPendingCount > 0 ? 'AWAITING_REVIEW' : 'SUCCEEDED';
824
- current.progress.stage = current.progress.reviewPendingCount > 0 ? 'REVIEWING' : 'SUCCEEDED';
825
- current.progress.observerStatus = current.progress.reviewPendingCount > 0 ? 'PENDING' : 'COMPLETED';
839
+ if (current.progress.reviewPendingCount > 0) {
840
+ current.status = 'AWAITING_REVIEW';
841
+ current.progress.stage = 'REVIEWING';
842
+ current.progress.observerStatus = 'PENDING';
843
+ current.completedAt = null;
844
+ }
845
+ else {
846
+ const counts = await this.catalogBatchCounts(manager, scope, current);
847
+ const outcome = catalogTerminalOutcome(counts);
848
+ current.progress.successCount = counts.successCount;
849
+ current.progress.duplicateCount = counts.duplicateCount;
850
+ current.progress.invalidCount = counts.invalidCount;
851
+ current.progress.skippedCount = counts.skippedCount;
852
+ current.progress.outcome = outcome.outcome;
853
+ if (outcome.status === 'FAILED') {
854
+ await this.applyImportTaskFailure(manager, scope, current, 'FAILED', outcome.errorCode, outcome.errorMessage);
855
+ }
856
+ else {
857
+ current.status = 'SUCCEEDED';
858
+ current.progress.stage = 'SUCCEEDED';
859
+ current.progress.observerStatus = 'COMPLETED';
860
+ current.errorCode = null;
861
+ current.errorMessage = null;
862
+ current.completedAt = new Date();
863
+ }
864
+ }
826
865
  current.progress.progressVersion += 1;
827
866
  current.progress.lastProgressAt = new Date().toISOString();
828
- current.completedAt = new Date();
829
867
  return repo.save(current);
830
868
  });
831
- if (task.documentType === 'CONTROLLED_CATALOG')
832
- await this.recalculateProductRisks(scope, 'CATALOG_CHANGED', task.resultEntityId ?? undefined);
833
- else
834
- await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
869
+ if (task.status === 'SUCCEEDED' && (task.progress?.successCount ?? 0) > 0) {
870
+ if (task.documentType === 'CONTROLLED_CATALOG')
871
+ await this.recalculateProductRisks(scope, 'CATALOG_CHANGED', task.resultEntityId ?? undefined);
872
+ else
873
+ await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
874
+ }
835
875
  return task;
836
876
  }
837
877
  async waitImportProgress(scope, taskId, afterProgressVersion = 0, waitMs = 10_000) {
@@ -914,12 +954,14 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
914
954
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '复核块租约无效或已过期');
915
955
  }
916
956
  const acceptedCandidates = [];
957
+ const rejectedBlocks = [];
917
958
  let resolved = 0;
918
959
  let rejected = 0;
919
960
  for (const item of items) {
920
961
  const block = blocks.find(value => value.id === item.blockId);
921
962
  if (item.decision === 'REJECT') {
922
963
  block.status = 'REJECTED';
964
+ rejectedBlocks.push(block);
923
965
  rejected += 1;
924
966
  }
925
967
  else {
@@ -942,12 +984,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
942
984
  const batchRepo = manager.getRepository(ControlledCatalogBatch);
943
985
  const batch = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId } }), '管控目录批次不存在');
944
986
  const rows = acceptedCandidates.flatMap(candidateToControlledRows);
945
- const localized = rows.map(row => ({
946
- productName: String(row.productName ?? '').trim(),
947
- controlDescription: optionalString(row.controlDescription),
948
- originalProductName: null, originalControlDescription: null,
949
- translationSource: row.translationSource ?? null
950
- }));
987
+ const localized = await this.localizeControlledCatalogRows(rows);
951
988
  await this.appendControlledCatalogRows(manager, scope, batch, rows, localized);
952
989
  await batchRepo.save(batch);
953
990
  task.progress.successCount = batch.successCount;
@@ -960,6 +997,13 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
960
997
  task.progress.duplicateCount = batch.duplicateCount;
961
998
  task.progress.invalidCount = batch.invalidCount;
962
999
  }
1000
+ const rejectedBatch = await this.appendRejectedCatalogReviews(manager, scope, task, rejectedBlocks);
1001
+ if (rejectedBatch) {
1002
+ task.progress.successCount = rejectedBatch.successCount;
1003
+ task.progress.duplicateCount = rejectedBatch.duplicateCount;
1004
+ task.progress.invalidCount = rejectedBatch.invalidCount;
1005
+ task.progress.skippedCount = rejectedBatch.skippedCount;
1006
+ }
963
1007
  await reviewRepo.save(blocks);
964
1008
  task.progress.reviewResolvedCount += resolved;
965
1009
  task.progress.reviewRejectedCount += rejected;
@@ -967,10 +1011,24 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
967
1011
  task.progress.progressVersion += 1;
968
1012
  task.progress.lastProgressAt = new Date().toISOString();
969
1013
  if (task.progress.reviewPendingCount === 0) {
970
- task.status = 'SUCCEEDED';
971
- task.progress.stage = 'SUCCEEDED';
972
- task.progress.observerStatus = 'COMPLETED';
973
- task.completedAt = new Date();
1014
+ const counts = await this.catalogBatchCounts(manager, scope, task);
1015
+ const outcome = catalogTerminalOutcome(counts);
1016
+ task.progress.successCount = counts.successCount;
1017
+ task.progress.duplicateCount = counts.duplicateCount;
1018
+ task.progress.invalidCount = counts.invalidCount;
1019
+ task.progress.skippedCount = counts.skippedCount;
1020
+ task.progress.outcome = outcome.outcome;
1021
+ if (outcome.status === 'FAILED') {
1022
+ await this.applyImportTaskFailure(manager, scope, task, 'FAILED', outcome.errorCode, outcome.errorMessage);
1023
+ }
1024
+ else {
1025
+ task.status = 'SUCCEEDED';
1026
+ task.progress.stage = 'SUCCEEDED';
1027
+ task.progress.observerStatus = 'COMPLETED';
1028
+ task.errorCode = null;
1029
+ task.errorMessage = null;
1030
+ task.completedAt = new Date();
1031
+ }
974
1032
  }
975
1033
  else {
976
1034
  task.status = 'AWAITING_REVIEW';
@@ -1039,6 +1097,148 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1039
1097
  }
1040
1098
  return batchRepo.save(batch);
1041
1099
  }
1100
+ async appendCatalogSkippedSources(repository, scope, batch, skippedSources) {
1101
+ for (const skipped of skippedSources) {
1102
+ const parsedPayload = { skipReason: skipped.reason, sourceLocation: skipped.sourceLocation };
1103
+ const existing = await repository.findOne({ where: {
1104
+ ...activeScope(scope), batchId: batch.id, issueType: 'INVALID', parsedPayload
1105
+ } });
1106
+ if (existing)
1107
+ continue;
1108
+ batch.totalCount += 1;
1109
+ batch.invalidCount += 1;
1110
+ batch.skippedCount += 1;
1111
+ await repository.save(repository.create({
1112
+ ...createdScope(scope), batchId: batch.id, issueType: 'INVALID', sourceSequence: batch.totalCount,
1113
+ sourceLocation: skipped.sourceLocation || null, sourceContent: skipped.sourceContent || null,
1114
+ parsedPayload, message: SKIPPED_REASON_MESSAGES[skipped.reason] ?? '无法入库的来源行已跳过'
1115
+ }));
1116
+ }
1117
+ }
1118
+ async appendRejectedCatalogReviews(manager, scope, task, blocks) {
1119
+ if (blocks.length === 0 || !task.resultEntityId)
1120
+ return null;
1121
+ const controlled = task.documentType === 'CONTROLLED_CATALOG';
1122
+ const batchRepo = controlled
1123
+ ? manager.getRepository(ControlledCatalogBatch)
1124
+ : manager.getRepository(SanctionCatalogBatch);
1125
+ const issueRepo = controlled
1126
+ ? manager.getRepository(ControlledCatalogImportIssue)
1127
+ : manager.getRepository(SanctionCatalogImportIssue);
1128
+ const batch = await required(batchRepo.findOne({ where: {
1129
+ ...activeScope(scope), id: task.resultEntityId
1130
+ } }), controlled ? '管控目录批次不存在' : '制裁目录批次不存在');
1131
+ for (const block of blocks) {
1132
+ const parsedPayload = { reviewBlockId: block.id, reviewDecision: 'REJECT' };
1133
+ const existing = await issueRepo.findOne({ where: {
1134
+ ...activeScope(scope), batchId: batch.id, issueType: 'INVALID', parsedPayload
1135
+ } });
1136
+ if (existing)
1137
+ continue;
1138
+ batch.totalCount += 1;
1139
+ batch.invalidCount += 1;
1140
+ batch.skippedCount += 1;
1141
+ await issueRepo.save(issueRepo.create({
1142
+ ...createdScope(scope), batchId: batch.id, issueType: 'INVALID', sourceSequence: batch.totalCount,
1143
+ sourceLocation: block.sourceLocation, sourceContent: block.sourceContent,
1144
+ parsedPayload, message: '审核未通过,来源行已跳过'
1145
+ }));
1146
+ }
1147
+ return batchRepo.save(batch);
1148
+ }
1149
+ async catalogBatchCounts(manager, scope, task) {
1150
+ if (!task.resultEntityId)
1151
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录导入批次不存在');
1152
+ const batch = task.documentType === 'CONTROLLED_CATALOG'
1153
+ ? await required(manager.getRepository(ControlledCatalogBatch).findOne({
1154
+ where: { ...activeScope(scope), id: task.resultEntityId }
1155
+ }), '管控目录批次不存在')
1156
+ : await required(manager.getRepository(SanctionCatalogBatch).findOne({
1157
+ where: { ...activeScope(scope), id: task.resultEntityId }
1158
+ }), '制裁目录批次不存在');
1159
+ return {
1160
+ successCount: batch.successCount,
1161
+ duplicateCount: batch.duplicateCount,
1162
+ invalidCount: batch.invalidCount,
1163
+ skippedCount: batch.skippedCount ?? 0
1164
+ };
1165
+ }
1166
+ async rollbackFailedCatalogReplacement(manager, scope, task) {
1167
+ if (!task.resultEntityId || (task.documentType !== 'CONTROLLED_CATALOG' && task.documentType !== 'SANCTION_CATALOG'))
1168
+ return;
1169
+ const now = new Date();
1170
+ if (task.documentType === 'CONTROLLED_CATALOG') {
1171
+ const batchRepo = manager.getRepository(ControlledCatalogBatch);
1172
+ const recordRepo = manager.getRepository(ControlledGoodsRecord);
1173
+ const current = await batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId } });
1174
+ if (!current?.replacedBatchId)
1175
+ return;
1176
+ const replaced = await batchRepo.findOne({ where: { ...activeScope(scope), id: current.replacedBatchId } });
1177
+ if (!replaced || replaced.deleteOperation !== 'CATALOG_REPLACE')
1178
+ return;
1179
+ current.deletedAt = now;
1180
+ current.deletedById = scope.userId ?? null;
1181
+ current.deleteOperation = 'IMPORT_FAILED';
1182
+ current.updatedById = scope.userId ?? null;
1183
+ await batchRepo.save(current);
1184
+ await recordRepo.update({
1185
+ ...activeScope(scope), batchId: current.id, deletedAt: IsNull()
1186
+ }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'IMPORT_FAILED', updatedById: scope.userId ?? null });
1187
+ replaced.deletedAt = null;
1188
+ replaced.deletedById = null;
1189
+ replaced.deleteOperation = null;
1190
+ replaced.updatedById = scope.userId ?? null;
1191
+ await batchRepo.save(replaced);
1192
+ await recordRepo.update({
1193
+ ...activeScope(scope), batchId: replaced.id, deleteOperation: 'CATALOG_REPLACE'
1194
+ }, { deletedAt: null, deletedById: null, deleteOperation: null, updatedById: scope.userId ?? null });
1195
+ return;
1196
+ }
1197
+ const batchRepo = manager.getRepository(SanctionCatalogBatch);
1198
+ const recordRepo = manager.getRepository(SanctionedCompanyRecord);
1199
+ const aliasRepo = manager.getRepository(SanctionedCompanyAlias);
1200
+ const current = await batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId } });
1201
+ if (!current?.replacedBatchId)
1202
+ return;
1203
+ const replaced = await batchRepo.findOne({ where: { ...activeScope(scope), id: current.replacedBatchId } });
1204
+ if (!replaced || replaced.deleteOperation !== 'SANCTION_CATALOG_REPLACE')
1205
+ return;
1206
+ current.deletedAt = now;
1207
+ current.deletedById = scope.userId ?? null;
1208
+ current.deleteOperation = 'IMPORT_FAILED';
1209
+ current.updatedById = scope.userId ?? null;
1210
+ await batchRepo.save(current);
1211
+ const failedRecords = await recordRepo.find({
1212
+ where: { ...activeScope(scope), batchId: current.id, deletedAt: IsNull() }, select: { id: true }
1213
+ });
1214
+ if (failedRecords.length > 0) {
1215
+ await aliasRepo.update({
1216
+ ...activeScope(scope), sanctionedCompanyId: In(failedRecords.map(record => record.id)), deletedAt: IsNull()
1217
+ }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'IMPORT_FAILED', updatedById: scope.userId ?? null });
1218
+ }
1219
+ await recordRepo.update({
1220
+ ...activeScope(scope), batchId: current.id, deletedAt: IsNull()
1221
+ }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'IMPORT_FAILED', updatedById: scope.userId ?? null });
1222
+ replaced.deletedAt = null;
1223
+ replaced.deletedById = null;
1224
+ replaced.deleteOperation = null;
1225
+ replaced.updatedById = scope.userId ?? null;
1226
+ await batchRepo.save(replaced);
1227
+ await recordRepo.update({
1228
+ ...activeScope(scope), batchId: replaced.id, deleteOperation: 'SANCTION_CATALOG_REPLACE'
1229
+ }, { deletedAt: null, deletedById: null, deleteOperation: null, updatedById: scope.userId ?? null });
1230
+ }
1231
+ async applyImportTaskFailure(manager, scope, task, status, errorCode, errorMessage, completedAt = new Date()) {
1232
+ task.status = status;
1233
+ task.errorCode = errorCode;
1234
+ task.errorMessage = errorMessage;
1235
+ task.completedAt = completedAt;
1236
+ if (task.progress?.mode === 'FAST_PIPELINE') {
1237
+ task.progress.stage = 'FAILED';
1238
+ task.progress.observerStatus = 'FAILED';
1239
+ await this.rollbackFailedCatalogReplacement(manager, scope, task);
1240
+ }
1241
+ }
1042
1242
  async createImportTask(scope, input) {
1043
1243
  const repo = this.dataSource.getRepository(ImportTask);
1044
1244
  const targetAssistantId = requiredId(input.targetAssistantId, '目标智能体标识');
@@ -1075,10 +1275,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1075
1275
  if (!task)
1076
1276
  return null;
1077
1277
  if (!task.targetAssistantId) {
1078
- task.status = 'FAILED';
1079
- task.errorCode = 'TARGET_ASSISTANT_MISSING';
1080
- task.errorMessage = '历史解析任务缺少目标智能体,无法自动恢复';
1081
- task.completedAt = new Date();
1278
+ await this.applyImportTaskFailure(manager, scope, task, 'FAILED', 'TARGET_ASSISTANT_MISSING', '历史解析任务缺少目标智能体,无法自动恢复');
1082
1279
  await repo.save(task);
1083
1280
  return null;
1084
1281
  }
@@ -1102,10 +1299,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1102
1299
  if (!task)
1103
1300
  return null;
1104
1301
  if (!task.targetAssistantId) {
1105
- task.status = 'FAILED';
1106
- task.errorCode = 'TARGET_ASSISTANT_MISSING';
1107
- task.errorMessage = '历史解析任务缺少目标智能体,无法自动恢复';
1108
- task.completedAt = new Date();
1302
+ await this.applyImportTaskFailure(manager, scope, task, 'FAILED', 'TARGET_ASSISTANT_MISSING', '历史解析任务缺少目标智能体,无法自动恢复');
1109
1303
  await repo.save(task);
1110
1304
  return null;
1111
1305
  }
@@ -1181,10 +1375,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1181
1375
  const task = await repo.findOne({ where: { ...activeScope(snapshot), id: snapshot.id }, lock: { mode: 'pessimistic_write' } });
1182
1376
  if (!task || task.targetAssistantId || !RECOVERABLE_IMPORT_TASK_STATUSES.includes(task.status))
1183
1377
  return;
1184
- task.status = 'FAILED';
1185
- task.errorCode = 'TARGET_ASSISTANT_MISSING';
1186
- task.errorMessage = '历史解析任务缺少目标智能体,无法自动恢复';
1187
- task.completedAt = new Date();
1378
+ await this.applyImportTaskFailure(manager, snapshot, task, 'FAILED', 'TARGET_ASSISTANT_MISSING', '历史解析任务缺少目标智能体,无法自动恢复');
1188
1379
  await repo.save(task);
1189
1380
  });
1190
1381
  }
@@ -1213,10 +1404,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1213
1404
  return task;
1214
1405
  if (task.claimAttemptToken !== attemptToken)
1215
1406
  return task;
1216
- task.status = 'TIMED_OUT';
1217
- task.errorCode = 'AI_PARSING_TIMEOUT';
1218
- task.errorMessage = '解析任务超过 20 分钟未完成';
1219
- task.completedAt = new Date();
1407
+ await this.applyImportTaskFailure(manager, scope, task, 'TIMED_OUT', 'AI_PARSING_TIMEOUT', '解析任务超过 20 分钟未完成');
1220
1408
  return repo.save(task);
1221
1409
  });
1222
1410
  }
@@ -1328,10 +1516,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1328
1516
  return task;
1329
1517
  if (task.status === 'FAILED' || task.status === 'TIMED_OUT')
1330
1518
  return task;
1331
- task.status = 'FAILED';
1332
- task.errorCode = 'AGENT_HANDOFF_RESULT_MISSING';
1333
- task.errorMessage = '智能体已结束,但解析工具未保存业务结果';
1334
- task.completedAt = new Date();
1519
+ await this.applyImportTaskFailure(manager, scope, task, 'FAILED', 'AGENT_HANDOFF_RESULT_MISSING', '智能体已结束,但解析工具未保存业务结果');
1335
1520
  return repo.save(task);
1336
1521
  });
1337
1522
  }
@@ -1355,17 +1540,11 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1355
1540
  if (input.kind === 'complete' &&
1356
1541
  task.status !== 'AWAITING_REVIEW' && task.status !== 'SUCCEEDED' &&
1357
1542
  task.status !== 'FAILED' && task.status !== 'TIMED_OUT') {
1358
- task.status = 'FAILED';
1359
- task.errorCode = 'AGENT_HANDOFF_RESULT_MISSING';
1360
- task.errorMessage = '智能体已结束,但解析工具未保存业务结果';
1361
- task.completedAt = new Date();
1543
+ await this.applyImportTaskFailure(manager, scope, task, 'FAILED', 'AGENT_HANDOFF_RESULT_MISSING', '智能体已结束,但解析工具未保存业务结果');
1362
1544
  }
1363
1545
  else if (input.kind === 'error' &&
1364
1546
  !TERMINAL_IMPORT_TASK_STATUSES.includes(task.status)) {
1365
- task.status = 'FAILED';
1366
- task.errorCode = requiredId(input.errorCode, '错误代码');
1367
- task.errorMessage = requiredId(input.errorMessage, '错误信息');
1368
- task.completedAt = new Date();
1547
+ await this.applyImportTaskFailure(manager, scope, task, 'FAILED', requiredId(input.errorCode, '错误代码'), requiredId(input.errorMessage, '错误信息'));
1369
1548
  }
1370
1549
  await repo.save(task);
1371
1550
  return { accepted: true, sequence: input.sequence };
@@ -1377,10 +1556,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1377
1556
  const task = await required(repo.findOne({ where: { ...activeScope(scope), id }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
1378
1557
  if (TERMINAL_IMPORT_TASK_STATUSES.includes(task.status))
1379
1558
  return task;
1380
- task.status = 'FAILED';
1381
- task.errorCode = failure.errorCode;
1382
- task.errorMessage = failure.errorMessage;
1383
- task.completedAt = new Date();
1559
+ await this.applyImportTaskFailure(manager, scope, task, 'FAILED', failure.errorCode, failure.errorMessage);
1384
1560
  return repo.save(task);
1385
1561
  });
1386
1562
  }
@@ -1397,10 +1573,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1397
1573
  return task;
1398
1574
  if (!snapshotMissingRecoveryMetadataStillMissing(snapshot, task))
1399
1575
  return task;
1400
- task.status = 'FAILED';
1401
- task.errorCode = failure.errorCode;
1402
- task.errorMessage = failure.errorMessage;
1403
- task.completedAt = new Date();
1576
+ await this.applyImportTaskFailure(manager, scope, task, 'FAILED', failure.errorCode, failure.errorMessage);
1404
1577
  return repo.save(task);
1405
1578
  });
1406
1579
  }
@@ -2470,10 +2643,33 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
2470
2643
  .orWhere("COALESCE(row.generationSnapshot #>> '{request,fileNumber}', '') ILIKE :generatedFileName")), { generatedFileName: `%${String(fileName).trim()}%` });
2471
2644
  }
2472
2645
  if (hasQueryValue(customerName))
2473
- qb.andWhere("COALESCE(row.generationSnapshot #>> '{customer,name}', '') ILIKE :generatedFileCustomerName", { generatedFileCustomerName: `%${String(customerName).trim()}%` });
2474
- }
2475
- });
2476
- return { ...page, items: page.items.map(item => ({ ...item, customerName: generatedFileCustomerName(item.generationSnapshot) })) };
2646
+ qb.andWhere(`(
2647
+ COALESCE(row.generationSnapshot #>> '{customer,name}', '') ILIKE :generatedFileCustomerName
2648
+ OR EXISTS (
2649
+ SELECT 1
2650
+ FROM plugin_trade_compliance_customer_contract legacy_contract_filter
2651
+ INNER JOIN plugin_trade_compliance_customer legacy_customer_filter
2652
+ ON legacy_customer_filter.id = legacy_contract_filter."customerId"
2653
+ AND legacy_customer_filter."tenantId" = legacy_contract_filter."tenantId"
2654
+ AND legacy_customer_filter."organizationId" = legacy_contract_filter."organizationId"
2655
+ AND legacy_customer_filter."deletedAt" IS NULL
2656
+ WHERE legacy_contract_filter.id = row."customerContractId"
2657
+ AND legacy_contract_filter."tenantId" = row."tenantId"
2658
+ AND legacy_contract_filter."organizationId" = row."organizationId"
2659
+ AND legacy_contract_filter."deletedAt" IS NULL
2660
+ AND legacy_customer_filter.name ILIKE :generatedFileCustomerName
2661
+ )
2662
+ )`, { generatedFileCustomerName: `%${String(customerName).trim()}%` });
2663
+ }
2664
+ });
2665
+ const snapshotNames = new Map(page.items.map(item => [item.id, generatedFileCustomerName(item.generationSnapshot)]));
2666
+ const fallbackContractIds = [...new Set(page.items.filter(item => !snapshotNames.get(item.id)).map(item => item.customerContractId).filter((id) => Boolean(id)))];
2667
+ const contracts = fallbackContractIds.length ? await this.dataSource.getRepository(CustomerContract).find({ where: { ...activeScope(scope), id: In(fallbackContractIds), deletedAt: IsNull() } }) : [];
2668
+ const customerIds = [...new Set(contracts.map(contract => contract.customerId).filter((id) => Boolean(id)))];
2669
+ const customers = customerIds.length ? await this.dataSource.getRepository(Customer).find({ where: { ...activeScope(scope), id: In(customerIds), deletedAt: IsNull() } }) : [];
2670
+ const contractCustomerIds = new Map(contracts.map(contract => [contract.id, contract.customerId]));
2671
+ const customerNames = new Map(customers.map(customer => [customer.id, customer.name]));
2672
+ return { ...page, items: page.items.map(item => ({ ...item, customerName: snapshotNames.get(item.id) ?? customerNames.get(contractCustomerIds.get(item.customerContractId) ?? '') ?? null })) };
2477
2673
  }
2478
2674
  async listMonthlyRates(scope, query = {}) {
2479
2675
  const page = Math.max(1, Math.trunc(Number(query.page)) || 1);
@@ -2695,8 +2891,9 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
2695
2891
  const statement = analyticsAggregationStatement(scope, query, currentYear, 'PAGE');
2696
2892
  const rawRows = await source.query(statement.sql, statement.parameters);
2697
2893
  const total = Number(rawRows[0]?.total ?? rawRows.length);
2698
- if (total > ANALYTICS_EXPORT_MAX_ROWS)
2699
- throw new DomainError(DomainErrorCodes.INVALID_INPUT, `分析分组最多支持 ${ANALYTICS_EXPORT_MAX_ROWS} 条`, { total });
2894
+ const maxRows = analyticsExportMaxRows();
2895
+ if (total > maxRows)
2896
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, `分析分组最多支持 ${maxRows} 条`, { total });
2700
2897
  const pageCount = Math.max(1, Math.ceil(total / query.pageSize));
2701
2898
  const page = Math.min(query.page, pageCount);
2702
2899
  const pageStart = (page - 1) * query.pageSize + 1;
@@ -2724,11 +2921,12 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
2724
2921
  sheet.getRow(1).font = { bold: true };
2725
2922
  sheet.getRow(1).commit();
2726
2923
  const statement = analyticsAggregationStatement(scope, query, shanghaiCurrentYear(), 'EXPORT');
2924
+ const maxRows = analyticsExportMaxRows();
2727
2925
  let count = 0;
2728
2926
  await this.withAnalyticsSnapshot(statement, async (raw) => {
2729
2927
  count += 1;
2730
- if (count > ANALYTICS_EXPORT_MAX_ROWS)
2731
- throw new DomainError(DomainErrorCodes.INVALID_INPUT, `分析导出最多支持 ${ANALYTICS_EXPORT_MAX_ROWS} 条明细`, { total: count });
2928
+ if (count > maxRows)
2929
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, `分析导出最多支持 ${maxRows} 条明细`, { total: count });
2732
2930
  const row = analyticsDetailFromRaw(raw, query);
2733
2931
  sheet.addRow(query.dimension === 'PRODUCT'
2734
2932
  ? [row.label, row.orderCount, row.productQuantity, row.amountCny]
@@ -3083,10 +3281,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
3083
3281
  const lockedStartedAt = locked.startedAt ?? locked.queuedAt ?? locked.createdAt;
3084
3282
  if (!lockedStartedAt || !importTaskTimedOut(lockedStartedAt, now))
3085
3283
  return;
3086
- locked.status = 'TIMED_OUT';
3087
- locked.errorCode = DomainErrorCodes.IMPORT_TIMED_OUT;
3088
- locked.errorMessage = '文件解析超过 20 分钟,请重新上传';
3089
- locked.completedAt = now;
3284
+ await this.applyImportTaskFailure(manager, scope, locked, 'TIMED_OUT', DomainErrorCodes.IMPORT_TIMED_OUT, '文件解析超过 20 分钟,请重新上传', now);
3090
3285
  locked.machineElapsedMs = now.getTime() - lockedStartedAt.getTime();
3091
3286
  await lockedRepo.save(locked);
3092
3287
  });
@@ -3327,6 +3522,7 @@ function reanalysisClauseInput(value) {
3327
3522
  return { clauseType: clause.clauseType, extractionStatus: clause.extractionStatus, sourceText: optionalText('sourceText'), reviewedText: optionalText('reviewedText'), sourceLocation: optionalText('sourceLocation'), sourceContent: optionalText('sourceContent') };
3328
3523
  }
3329
3524
  function importIssueExportLimit() { const configured = Number(process.env.TRADE_COMPLIANCE_IMPORT_ISSUE_EXPORT_MAX_ROWS); return Math.min(HARD_IMPORT_ISSUE_EXPORT_MAX_ROWS, Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : DEFAULT_IMPORT_ISSUE_EXPORT_MAX_ROWS); }
3525
+ function analyticsExportMaxRows() { const configured = Number(process.env.TRADE_COMPLIANCE_ANALYTICS_EXPORT_MAX_ROWS); return Number.isInteger(configured) && configured > 0 ? configured : DEFAULT_ANALYTICS_EXPORT_MAX_ROWS; }
3330
3526
  function hasQueryValue(value) { return value !== undefined && value !== null && String(value).trim() !== ''; }
3331
3527
  function generatedFileCustomerName(snapshot) {
3332
3528
  const customer = snapshot?.customer;
@@ -3523,7 +3719,7 @@ function optionalString(value) {
3523
3719
  const normalized = String(value ?? '').trim();
3524
3720
  return normalized || null;
3525
3721
  }
3526
- function initialFastCatalogProgress(totalCandidates) {
3722
+ function initialFastCatalogProgress(totalCandidates, inferenceMetadata) {
3527
3723
  return {
3528
3724
  version: 1,
3529
3725
  mode: 'FAST_PIPELINE',
@@ -3533,6 +3729,8 @@ function initialFastCatalogProgress(totalCandidates) {
3533
3729
  successCount: 0,
3534
3730
  duplicateCount: 0,
3535
3731
  invalidCount: 0,
3732
+ skippedCount: 0,
3733
+ inferenceMetadata,
3536
3734
  currentBatch: 0,
3537
3735
  totalBatches: totalCandidates > 0 ? Math.ceil(totalCandidates / 100) : 0,
3538
3736
  committedCursor: 0,
@@ -3551,6 +3749,7 @@ function candidateToControlledRows(candidate) {
3551
3749
  : [undefined];
3552
3750
  return hsCodes.map(hsCode => ({
3553
3751
  productName: optionalString(candidate.values.productName) ?? undefined,
3752
+ originalProductName: optionalString(candidate.values.originalProductName) ?? undefined,
3554
3753
  hsCode: typeof hsCode === 'string' ? hsCode : undefined,
3555
3754
  controlDescription: optionalString(candidate.values.controlDescription) ?? undefined,
3556
3755
  controlCode: optionalString(candidate.values.controlCode) ?? undefined,
@@ -3558,7 +3757,9 @@ function candidateToControlledRows(candidate) {
3558
3757
  sourceOrdinal: optionalString(candidate.values.sourceOrdinal) ?? String(candidate.ordinal),
3559
3758
  sourceLocation: evidence?.sourceLocation,
3560
3759
  sourceContent: evidence?.sourceContent,
3561
- translationSource: 'SOURCE_CHINESE'
3760
+ translationSource: candidate.values.translationRequired || (!candidate.values.productName && candidate.values.originalProductName)
3761
+ ? 'AGENT_TRANSLATED'
3762
+ : 'SOURCE_CHINESE'
3562
3763
  }));
3563
3764
  }
3564
3765
  function candidateAliases(candidate) {
@@ -3713,7 +3914,7 @@ function analyticsAggregationStatement(scope, query, currentYear, purpose) {
3713
3914
  FROM sales_orders orders GROUP BY orders."customerId"
3714
3915
  )`;
3715
3916
  }
3716
- const bounded = `${sourceSql}, bounded AS (SELECT * FROM grouped ORDER BY "amountCny" DESC, key ASC LIMIT ${ANALYTICS_EXPORT_MAX_ROWS + 1})`;
3917
+ const bounded = `${sourceSql}, bounded AS (SELECT * FROM grouped ORDER BY "amountCny" DESC, key ASC LIMIT ${analyticsExportMaxRows() + 1})`;
3717
3918
  if (purpose === 'EXPORT')
3718
3919
  return { sql: `WITH ${bounded} SELECT * FROM bounded ORDER BY "amountCny" DESC, key ASC`, parameters };
3719
3920
  const metricColumns = query.dimension === 'PRODUCT'