@chenchaolong/plugin-trade-compliance-workbench 1.0.51 → 1.0.53

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 (130) hide show
  1. package/README.md +2 -2
  2. package/dist/lib/analytics-export.d.ts.map +1 -1
  3. package/dist/lib/analytics-export.js +1 -3
  4. package/dist/lib/analytics-export.js.map +1 -1
  5. package/dist/lib/analytics-query.d.ts.map +1 -1
  6. package/dist/lib/analytics-query.js +13 -20
  7. package/dist/lib/analytics-query.js.map +1 -1
  8. package/dist/lib/catalog-import/confidence.d.ts +1 -0
  9. package/dist/lib/catalog-import/confidence.d.ts.map +1 -1
  10. package/dist/lib/catalog-import/confidence.js +12 -0
  11. package/dist/lib/catalog-import/confidence.js.map +1 -1
  12. package/dist/lib/catalog-import/docx.parser.d.ts.map +1 -1
  13. package/dist/lib/catalog-import/docx.parser.js +7 -6
  14. package/dist/lib/catalog-import/docx.parser.js.map +1 -1
  15. package/dist/lib/catalog-import/pdf-layout.parser.d.ts.map +1 -1
  16. package/dist/lib/catalog-import/pdf-layout.parser.js +8 -7
  17. package/dist/lib/catalog-import/pdf-layout.parser.js.map +1 -1
  18. package/dist/lib/catalog-import/spreadsheet.parser.d.ts.map +1 -1
  19. package/dist/lib/catalog-import/spreadsheet.parser.js +12 -13
  20. package/dist/lib/catalog-import/spreadsheet.parser.js.map +1 -1
  21. package/dist/lib/constants.d.ts +1 -0
  22. package/dist/lib/constants.d.ts.map +1 -1
  23. package/dist/lib/constants.js +1 -0
  24. package/dist/lib/constants.js.map +1 -1
  25. package/dist/lib/demo-data.definitions.js +1 -1
  26. package/dist/lib/demo-data.definitions.js.map +1 -1
  27. package/dist/lib/entities/catalog.entity.d.ts +7 -1
  28. package/dist/lib/entities/catalog.entity.d.ts.map +1 -1
  29. package/dist/lib/entities/catalog.entity.js +26 -2
  30. package/dist/lib/entities/catalog.entity.js.map +1 -1
  31. package/dist/lib/entities/customer.entity.js.map +1 -1
  32. package/dist/lib/entities/procurement.entity.js.map +1 -1
  33. package/dist/lib/import-jobs/import-command.builder.js +2 -2
  34. package/dist/lib/import-jobs/import-command.builder.js.map +1 -1
  35. package/dist/lib/import-jobs/import-job-callback.processor.d.ts.map +1 -1
  36. package/dist/lib/import-jobs/import-job-callback.processor.js +2 -0
  37. package/dist/lib/import-jobs/import-job-callback.processor.js.map +1 -1
  38. package/dist/lib/remote-components/trade-compliance-workbench/app.css +1 -1
  39. package/dist/lib/remote-components/trade-compliance-workbench/app.js +8 -8
  40. package/dist/lib/remote-components/trade-compliance-workbench/editor-source.mjs +17 -1
  41. package/dist/lib/remote-components/trade-compliance-workbench/editor.js +100 -100
  42. package/dist/lib/remote-ui/action-execution.d.ts +3 -0
  43. package/dist/lib/remote-ui/action-execution.d.ts.map +1 -1
  44. package/dist/lib/remote-ui/action-execution.js +21 -1
  45. package/dist/lib/remote-ui/action-execution.js.map +1 -1
  46. package/dist/lib/remote-ui/app-source.js +178 -153
  47. package/dist/lib/remote-ui/app-source.js.map +1 -1
  48. package/dist/lib/remote-ui/bridge.d.ts.map +1 -1
  49. package/dist/lib/remote-ui/components/forms.d.ts +5 -2
  50. package/dist/lib/remote-ui/components/forms.d.ts.map +1 -1
  51. package/dist/lib/remote-ui/components/forms.js +4 -4
  52. package/dist/lib/remote-ui/components/forms.js.map +1 -1
  53. package/dist/lib/remote-ui/components/layout.d.ts.map +1 -1
  54. package/dist/lib/remote-ui/components/layout.js +31 -4
  55. package/dist/lib/remote-ui/components/layout.js.map +1 -1
  56. package/dist/lib/remote-ui/components/task-status.js +2 -2
  57. package/dist/lib/remote-ui/components/task-status.js.map +1 -1
  58. package/dist/lib/remote-ui/contract-import.d.ts +1 -0
  59. package/dist/lib/remote-ui/contract-import.d.ts.map +1 -1
  60. package/dist/lib/remote-ui/contract-import.js +3 -1
  61. package/dist/lib/remote-ui/contract-import.js.map +1 -1
  62. package/dist/lib/remote-ui/contracts.d.ts +21 -0
  63. package/dist/lib/remote-ui/contracts.d.ts.map +1 -1
  64. package/dist/lib/remote-ui/import-task-status.d.ts +5 -2
  65. package/dist/lib/remote-ui/import-task-status.d.ts.map +1 -1
  66. package/dist/lib/remote-ui/import-task-status.js +13 -8
  67. package/dist/lib/remote-ui/import-task-status.js.map +1 -1
  68. package/dist/lib/remote-ui/pages/analytics-detail-list.d.ts.map +1 -1
  69. package/dist/lib/remote-ui/pages/analytics-detail-list.js.map +1 -1
  70. package/dist/lib/remote-ui/pages/catalog-import-history.d.ts +63 -0
  71. package/dist/lib/remote-ui/pages/catalog-import-history.d.ts.map +1 -0
  72. package/dist/lib/remote-ui/pages/catalog-import-history.js +158 -0
  73. package/dist/lib/remote-ui/pages/catalog-import-history.js.map +1 -0
  74. package/dist/lib/remote-ui/pages/catalogs.css +164 -0
  75. package/dist/lib/remote-ui/pages/catalogs.d.ts +12 -6
  76. package/dist/lib/remote-ui/pages/catalogs.d.ts.map +1 -1
  77. package/dist/lib/remote-ui/pages/catalogs.js +79 -23
  78. package/dist/lib/remote-ui/pages/catalogs.js.map +1 -1
  79. package/dist/lib/remote-ui/pages/contracts.d.ts.map +1 -1
  80. package/dist/lib/remote-ui/pages/contracts.js +0 -16
  81. package/dist/lib/remote-ui/pages/contracts.js.map +1 -1
  82. package/dist/lib/remote-ui/pages/files-prototype.css +2 -2
  83. package/dist/lib/remote-ui/pages/files.d.ts +1 -1
  84. package/dist/lib/remote-ui/pages/files.d.ts.map +1 -1
  85. package/dist/lib/remote-ui/pages/files.js +6 -6
  86. package/dist/lib/remote-ui/pages/files.js.map +1 -1
  87. package/dist/lib/remote-ui/pages/home.d.ts.map +1 -1
  88. package/dist/lib/remote-ui/pages/home.js +2 -2
  89. package/dist/lib/remote-ui/pages/home.js.map +1 -1
  90. package/dist/lib/remote-ui/pages/procurement.d.ts +6 -1
  91. package/dist/lib/remote-ui/pages/procurement.d.ts.map +1 -1
  92. package/dist/lib/remote-ui/pages/procurement.js +77 -16
  93. package/dist/lib/remote-ui/pages/procurement.js.map +1 -1
  94. package/dist/lib/remote-ui/pages/sales.d.ts +4 -1
  95. package/dist/lib/remote-ui/pages/sales.d.ts.map +1 -1
  96. package/dist/lib/remote-ui/pages/sales.js +9 -4
  97. package/dist/lib/remote-ui/pages/sales.js.map +1 -1
  98. package/dist/lib/remote-ui/procurement-contracts.d.ts +7 -1
  99. package/dist/lib/remote-ui/procurement-contracts.d.ts.map +1 -1
  100. package/dist/lib/remote-ui/procurement-contracts.js.map +1 -1
  101. package/dist/lib/remote-ui/sidebar-state.d.ts +1 -0
  102. package/dist/lib/remote-ui/sidebar-state.d.ts.map +1 -1
  103. package/dist/lib/remote-ui/sidebar-state.js +3 -0
  104. package/dist/lib/remote-ui/sidebar-state.js.map +1 -1
  105. package/dist/lib/remote-ui/styles.css +1 -1
  106. package/dist/lib/sales-file.service.d.ts.map +1 -1
  107. package/dist/lib/sales-file.service.js +33 -1
  108. package/dist/lib/sales-file.service.js.map +1 -1
  109. package/dist/lib/schema-maintenance.d.ts.map +1 -1
  110. package/dist/lib/schema-maintenance.js +125 -0
  111. package/dist/lib/schema-maintenance.js.map +1 -1
  112. package/dist/lib/types.d.ts +3 -0
  113. package/dist/lib/types.d.ts.map +1 -1
  114. package/dist/lib/types.js +1 -0
  115. package/dist/lib/types.js.map +1 -1
  116. package/dist/lib/workbench-action-contract.d.ts +1 -1
  117. package/dist/lib/workbench-action-contract.d.ts.map +1 -1
  118. package/dist/lib/workbench-action-contract.js +3 -1
  119. package/dist/lib/workbench-action-contract.js.map +1 -1
  120. package/dist/lib/workbench-view.provider.d.ts +19 -9
  121. package/dist/lib/workbench-view.provider.d.ts.map +1 -1
  122. package/dist/lib/workbench-view.provider.js +43 -7
  123. package/dist/lib/workbench-view.provider.js.map +1 -1
  124. package/dist/lib/workbench.middleware.js +8 -8
  125. package/dist/lib/workbench.middleware.js.map +1 -1
  126. package/dist/lib/workbench.service.d.ts +44 -3
  127. package/dist/lib/workbench.service.d.ts.map +1 -1
  128. package/dist/lib/workbench.service.js +788 -152
  129. package/dist/lib/workbench.service.js.map +1 -1
  130. package/package.json +2 -1
@@ -9,9 +9,10 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
  import { finished } from 'node:stream/promises';
12
- import { randomUUID } from 'node:crypto';
12
+ import { createHash, randomUUID } from 'node:crypto';
13
13
  import { BankAccountSettings, CatalogReviewBlock, CompanySanctionMatch, CompanySettings, ControlledCatalogBatch, ControlledCatalogImportIssue, ControlledGoodsRecord, Customer, CustomerContract, CustomerContractAnalysisRun, CustomerContractClause, CustomerContractClauseAnalysis, DerivedSalesOrderManualData, GeneratedSalesFile, ImportTask, ManagedAsset, MonthlySafeExchangeRate, ProductComplianceMatch, ProfitRuleSettings, PurchaseOrder, PurchaseOrderLine, SanctionCatalogBatch, SanctionCatalogImportIssue, SanctionedCompanyAlias, SanctionedCompanyRecord, SettingsHistory, Supplier } from './entities/index.js';
14
- import { CatalogTypes, ClauseTypes, CustomerProfitRateStatuses, DomainError, DomainErrorCodes, RefundValidationMethods, SalesPaymentStatuses, SanctionHitStatuses } from './types.js';
14
+ import { CATALOG_REVIEW_BATCH_LIMIT } from './constants.js';
15
+ import { CatalogTypes, ClauseTypes, CustomerProfitRateStatuses, DomainError, DomainErrorCodes, RefundValidationMethods, SalesPaymentStatuses, SanctionHitStatuses, StalledCatalogImportDecisions } from './types.js';
15
16
  import { deriveSalesLine, assertReceivedAmount } from './domain/finance.js';
16
17
  import { parseCustomerContractNo, parsePurchaseContractNo } from './domain/contract-number.js';
17
18
  import { isProductNameMatch, normalizeExactName, normalizeForProductMatch, normalizeHsCode, normalizeSanctionSource } from './domain/normalization.js';
@@ -23,7 +24,7 @@ import { TradeComplianceFileStorage } from './adapters/file-storage.js';
23
24
  import { TranslationProvider } from './adapters/translation.provider.js';
24
25
  import { parseBankAccountType, parseExchangeRateInput, parseExpectedRevision, validateBankSettingsInput, validateProfitSettingsInput, validatePurchaseLineDiscriminators, validateTaxRates } from './runtime-validation.js';
25
26
  import { publicCustomerContractReanalysisDto, publicImportTaskDto } from './public-dto.js';
26
- import { classifyCandidate } from './catalog-import/confidence.js';
27
+ import { classifyCandidate, normalizeCatalogCandidate } from './catalog-import/confidence.js';
27
28
  import { catalogTerminalOutcome } from './catalog-import/outcome.js';
28
29
  import { analyticsBusinessDetailFromRaw, analyticsDashboardFromRaw, analyticsDashboardStatement, analyticsDetailStatement, analyticsDetailTotalFromRaw, analyticsExportComplianceFromRaw, analyticsExportCustomersFromRaw, analyticsExportFundRisksFromRaw, analyticsExportSalesFileReviewsFromRaw, analyticsExportStatements, analyticsExportSuppliersFromRaw, resolveAnalyticsPeriod } from './analytics-query.js';
29
30
  import { analyticsExportMaxRows, renderAnalyticsExportWorkbook } from './analytics-export.js';
@@ -209,17 +210,18 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
209
210
  return { ...transactionResult, sourceFileRemoved };
210
211
  }
211
212
  async deleteImportTaskTransientRows(manager, scope, task) {
213
+ await this.failCatalogDraft(manager, scope, task);
212
214
  await manager.getRepository(CatalogReviewBlock).delete({ ...activeScope(scope), importTaskId: task.id });
213
215
  await this.deleteEmptyImportCatalogBatch(manager, scope, task.id, ControlledCatalogBatch, ControlledGoodsRecord, ControlledCatalogImportIssue);
214
216
  await this.deleteEmptyImportCatalogBatch(manager, scope, task.id, SanctionCatalogBatch, SanctionedCompanyRecord, SanctionCatalogImportIssue);
215
217
  }
216
218
  async deleteEmptyImportCatalogBatch(manager, scope, importTaskId, batchEntity, recordEntity, issueEntity) {
217
219
  const batchRepo = manager.getRepository(batchEntity);
218
- const batch = await batchRepo.findOne({ where: { ...activeScope(scope), importTaskId } });
220
+ const batch = await batchRepo.findOne({ where: { ...activeScope(scope), importTaskId }, withDeleted: true });
219
221
  if (!batch)
220
222
  return;
221
223
  const committedRows = await manager.getRepository(recordEntity).count({
222
- where: { ...activeScope(scope), batchId: batch.id }
224
+ where: { ...activeScope(scope), batchId: batch.id }, withDeleted: true
223
225
  });
224
226
  if (committedRows > 0)
225
227
  return;
@@ -230,8 +232,8 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
230
232
  const where = { ...activeScope(scope), sourceFileId: task.sourceFileId };
231
233
  const [otherTasks, controlledBatches, sanctionBatches, contracts, orders] = await Promise.all([
232
234
  manager.getRepository(ImportTask).count({ where: { ...where, id: Not(task.id) } }),
233
- manager.getRepository(ControlledCatalogBatch).count({ where }),
234
- manager.getRepository(SanctionCatalogBatch).count({ where }),
235
+ manager.getRepository(ControlledCatalogBatch).count({ where, withDeleted: true }),
236
+ manager.getRepository(SanctionCatalogBatch).count({ where, withDeleted: true }),
235
237
  manager.getRepository(CustomerContract).count({ where }),
236
238
  manager.getRepository(PurchaseOrder).count({ where })
237
239
  ]);
@@ -290,21 +292,27 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
290
292
  return { run: publicCustomerContractReanalysisDto(run), consumed };
291
293
  });
292
294
  }
293
- async listControlledBatches(scope, query = {}) { return this.pagedQuery(ControlledCatalogBatch, scope, query, ['sourceFileName'], { catalogType: query.parameters?.catalogType }); }
294
- async listSanctionBatches(scope, query = {}) { return this.pagedQuery(SanctionCatalogBatch, scope, query, ['sourceFileName']); }
295
+ async listControlledBatches(scope, query = {}) { return this.pagedQuery(ControlledCatalogBatch, scope, query, ['sourceFileName'], { lifecycleStatus: 'ACTIVE', catalogType: query.parameters?.catalogType }); }
296
+ async listSanctionBatches(scope, query = {}) { return this.pagedQuery(SanctionCatalogBatch, scope, query, ['sourceFileName'], { lifecycleStatus: 'ACTIVE' }); }
297
+ async listControlledBatchHistory(scope, query = {}) { return this.pagedBatchHistoryQuery(ControlledCatalogBatch, scope, query, { catalogType: query.parameters?.catalogType }); }
298
+ async listSanctionBatchHistory(scope, query = {}) { return this.pagedBatchHistoryQuery(SanctionCatalogBatch, scope, query); }
295
299
  async saveControlledGoods(scope, input) {
296
- const repo = this.dataSource.getRepository(ControlledGoodsRecord);
297
- const productName = input.productName.trim();
298
- const normalizedProductName = productName.trim();
299
- const normalizedHsCode = String(input.hsCode ?? '').replace(/\s/g, '');
300
- const duplicate = await repo.findOne({ where: { ...activeScope(scope), catalogType: input.catalogType, normalizedProductName, normalizedHsCode, deletedAt: IsNull() } });
301
- if (duplicate)
302
- throw new DomainError(DomainErrorCodes.DUPLICATE_CATALOG_RECORD, '相同目录类型、商品名称和海关编码的记录已存在', { id: duplicate.id });
303
- return controlledGoodsDto(await repo.save(repo.create({ ...auditScope(scope), sourceType: input.sourceType ?? 'MANUAL_ENTRY', catalogType: input.catalogType, productName, normalizedProductName, hsCode: input.hsCode ?? null, normalizedHsCode, controlDescription: input.controlDescription ?? null, sourceLocation: input.sourceLocation ?? null, sourceContent: input.sourceContent ?? null })));
300
+ const persist = async (manager) => {
301
+ await lockCatalogMutation(manager, scope, this.dataSource.options?.type === 'postgres');
302
+ const repo = manager.getRepository(ControlledGoodsRecord);
303
+ const productName = input.productName.trim();
304
+ const normalizedProductName = productName.trim();
305
+ const normalizedHsCode = String(input.hsCode ?? '').replace(/\s/g, '');
306
+ const duplicate = await repo.findOne({ where: { ...activeScope(scope), lifecycleStatus: 'ACTIVE', catalogType: input.catalogType, normalizedProductName, normalizedHsCode, deletedAt: IsNull() } });
307
+ if (duplicate)
308
+ throw new DomainError(DomainErrorCodes.DUPLICATE_CATALOG_RECORD, '相同目录类型、商品名称和海关编码的记录已存在', { id: duplicate.id });
309
+ return controlledGoodsDto(await repo.save(repo.create({ ...auditScope(scope), lifecycleStatus: 'ACTIVE', sourceType: input.sourceType ?? 'MANUAL_ENTRY', catalogType: input.catalogType, productName, normalizedProductName, hsCode: input.hsCode ?? null, normalizedHsCode, controlDescription: input.controlDescription ?? null, sourceLocation: input.sourceLocation ?? null, sourceContent: input.sourceContent ?? null })));
310
+ };
311
+ return this.dataSource.options?.type === 'postgres' ? this.dataSource.transaction(persist) : persist(this.dataSource);
304
312
  }
305
313
  async updateControlledGoods(scope, id, input) {
306
314
  const repo = this.dataSource.getRepository(ControlledGoodsRecord);
307
- const item = await required(repo.findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() } }), '管控商品不存在');
315
+ const item = await required(repo.findOne({ where: { ...activeScope(scope), id, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() } }), '管控商品不存在');
308
316
  Object.assign(item, pick(input, ['catalogType', 'productName', 'hsCode', 'controlDescription']));
309
317
  item.normalizedProductName = item.productName.trim();
310
318
  item.normalizedHsCode = String(item.hsCode ?? '').replace(/\s/g, '');
@@ -447,6 +455,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
447
455
  }
448
456
  async persistControlledCatalog(scope, input, localizedRows, claimId) {
449
457
  return this.dataSource.transaction(async (manager) => {
458
+ await lockCatalogMutation(manager, scope, this.dataSource.options?.type === 'postgres');
450
459
  const batchRepo = manager.getRepository(ControlledCatalogBatch);
451
460
  const recordRepo = manager.getRepository(ControlledGoodsRecord);
452
461
  const taskRepo = manager.getRepository(ImportTask);
@@ -460,7 +469,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
460
469
  await taskRepo.save(task);
461
470
  const now = new Date();
462
471
  if (input.replaceBatchId) {
463
- const old = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, deletedAt: IsNull() } }), '被覆盖目录批次不存在');
472
+ const old = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() } }), '被覆盖目录批次不存在');
464
473
  old.deletedAt = now;
465
474
  old.deletedById = scope.userId ?? null;
466
475
  old.deleteOperation = 'CATALOG_REPLACE';
@@ -468,7 +477,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
468
477
  await batchRepo.save(old);
469
478
  await recordRepo.update({ ...activeScope(scope), batchId: old.id, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'CATALOG_REPLACE' });
470
479
  }
471
- const batch = await batchRepo.save(batchRepo.create({ ...auditScope(scope), catalogType: input.catalogType, sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName, importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null, totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0, importTaskId: input.importTaskId, riskRecalculatedAt: null }));
480
+ const batch = await batchRepo.save(batchRepo.create({ ...auditScope(scope), lifecycleStatus: 'ACTIVE', catalogType: input.catalogType, sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName, importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null, totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0, importTaskId: input.importTaskId, riskRecalculatedAt: null }));
472
481
  await this.appendControlledCatalogRows(manager, scope, batch, input.rows, localizedRows);
473
482
  await batchRepo.save(batch);
474
483
  task.status = 'SUCCEEDED';
@@ -482,6 +491,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
482
491
  }
483
492
  async appendCompletedControlledCatalog(scope, input, localizedRows, expectedBatchId) {
484
493
  return this.dataSource.transaction(async (manager) => {
494
+ await lockCatalogMutation(manager, scope, this.dataSource.options?.type === 'postgres');
485
495
  const task = await required(manager.getRepository(ImportTask).findOne({
486
496
  where: { ...activeScope(scope), id: input.importTaskId },
487
497
  lock: { mode: 'pessimistic_write' }
@@ -534,9 +544,11 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
534
544
  await issueRepo.save(issueRepo.create({ ...createdScope(scope), batchId: batch.id, issueType: 'INVALID', sourceSequence: batch.totalCount, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null, parsedPayload: row, message: localized.error }));
535
545
  continue;
536
546
  }
537
- const duplicate = await recordRepo.findOne({ where: { ...activeScope(scope), catalogType: batch.catalogType, normalizedProductName: productName, normalizedHsCode, deletedAt: IsNull() } });
538
- if (duplicate?.batchId === batch.id)
539
- continue;
547
+ const duplicate = await recordRepo.findOne({ where: [
548
+ { ...activeScope(scope), batchId: batch.id, lifecycleStatus: batch.lifecycleStatus, catalogType: batch.catalogType, normalizedProductName: productName, normalizedHsCode, deletedAt: IsNull() },
549
+ ...(batch.replacedBatchId ? [{ ...activeScope(scope), batchId: IsNull(), lifecycleStatus: 'ACTIVE', catalogType: batch.catalogType, normalizedProductName: productName, normalizedHsCode, deletedAt: IsNull() }] : []),
550
+ { ...activeScope(scope), lifecycleStatus: 'ACTIVE', catalogType: batch.catalogType, normalizedProductName: productName, normalizedHsCode, deletedAt: IsNull(), ...(batch.replacedBatchId ? { batchId: Not(batch.replacedBatchId) } : {}) }
551
+ ] });
540
552
  if (duplicate && await this.catalogIssueExists(issueRepo, scope, batch.id, 'DUPLICATE', row))
541
553
  continue;
542
554
  changed = true;
@@ -546,7 +558,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
546
558
  await issueRepo.save(issueRepo.create({ ...createdScope(scope), batchId: batch.id, issueType: 'DUPLICATE', sourceSequence: batch.totalCount, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null, parsedPayload: row, message: '目录中已存在相同商品名称和海关编码' }));
547
559
  continue;
548
560
  }
549
- await recordRepo.save(recordRepo.create({ ...auditScope(scope), batchId: batch.id, sourceType: 'FILE_IMPORT', catalogType: batch.catalogType, productName, normalizedProductName: productName, hsCode: row.hsCode ?? null, normalizedHsCode, controlDescription: localized.controlDescription, originalProductName: localized.originalProductName, originalControlDescription: localized.originalControlDescription, translationSource: localized.translationSource, controlCode: row.controlCode ?? null, sourceSection: row.sourceSection ?? null, sourceOrdinal: row.sourceOrdinal ?? null, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null }));
561
+ await recordRepo.save(recordRepo.create({ ...auditScope(scope), batchId: batch.id, lifecycleStatus: batch.lifecycleStatus, sourceType: 'FILE_IMPORT', catalogType: batch.catalogType, productName, normalizedProductName: productName, hsCode: row.hsCode ?? null, normalizedHsCode, controlDescription: localized.controlDescription, originalProductName: localized.originalProductName, originalControlDescription: localized.originalControlDescription, translationSource: localized.translationSource, controlCode: row.controlCode ?? null, sourceSection: row.sourceSection ?? null, sourceOrdinal: row.sourceOrdinal ?? null, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null }));
550
562
  batch.successCount += 1;
551
563
  batch.riskRecalculatedAt = null;
552
564
  }
@@ -693,20 +705,24 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
693
705
  return { fileName: `${batch.sourceFileName}-导入问题明细.xlsx`, buffer: await renderImportIssuesWorkbook(issues) };
694
706
  }
695
707
  async saveSanctionedCompany(scope, input) {
696
- const repo = this.dataSource.getRepository(SanctionedCompanyRecord);
697
- const companyName = input.companyName.trim();
698
- const normalizedCompanyName = normalizeExactName(companyName);
699
- const normalizedSanctionListSource = normalizeSanctionSource(input.sanctionListSource);
700
- const duplicate = await repo.findOne({ where: { ...activeScope(scope), normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull() } });
701
- if (duplicate)
702
- throw new DomainError(DomainErrorCodes.DUPLICATE_CATALOG_RECORD, '相同公司名称和制裁名单来源的记录已存在', { id: duplicate.id });
703
- const saved = await repo.save(repo.create({ ...auditScope(scope), sourceType: input.sourceType ?? 'MANUAL_ENTRY', companyName, normalizedCompanyName, countryOrRegion: input.countryOrRegion ?? null, sanctionListSource: input.sanctionListSource ?? null, normalizedSanctionListSource, sanctionReason: input.sanctionReason ?? null, effectiveDate: input.effectiveDate ?? null, remarks: input.remarks ?? null, sourceLocation: input.sourceLocation ?? null, sourceContent: input.sourceContent ?? null }));
708
+ const persist = async (manager) => {
709
+ await lockCatalogMutation(manager, scope, this.dataSource.options?.type === 'postgres');
710
+ const repo = manager.getRepository(SanctionedCompanyRecord);
711
+ const companyName = input.companyName.trim();
712
+ const normalizedCompanyName = normalizeExactName(companyName);
713
+ const normalizedSanctionListSource = normalizeSanctionSource(input.sanctionListSource);
714
+ const duplicate = await repo.findOne({ where: { ...activeScope(scope), lifecycleStatus: 'ACTIVE', normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull() } });
715
+ if (duplicate)
716
+ throw new DomainError(DomainErrorCodes.DUPLICATE_CATALOG_RECORD, '相同公司名称和制裁名单来源的记录已存在', { id: duplicate.id });
717
+ return repo.save(repo.create({ ...auditScope(scope), lifecycleStatus: 'ACTIVE', sourceType: input.sourceType ?? 'MANUAL_ENTRY', companyName, normalizedCompanyName, countryOrRegion: input.countryOrRegion ?? null, sanctionListSource: input.sanctionListSource ?? null, normalizedSanctionListSource, sanctionReason: input.sanctionReason ?? null, effectiveDate: input.effectiveDate ?? null, remarks: input.remarks ?? null, sourceLocation: input.sourceLocation ?? null, sourceContent: input.sourceContent ?? null }));
718
+ };
719
+ const saved = await (this.dataSource.options?.type === 'postgres' ? this.dataSource.transaction(persist) : persist(this.dataSource));
704
720
  await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
705
721
  return sanctionedCompanyDto(saved);
706
722
  }
707
723
  async updateSanctionedCompany(scope, id, input) {
708
724
  const repo = this.dataSource.getRepository(SanctionedCompanyRecord);
709
- const item = await required(repo.findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() } }), '制裁公司不存在');
725
+ const item = await required(repo.findOne({ where: { ...activeScope(scope), id, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() } }), '制裁公司不存在');
710
726
  Object.assign(item, pick(input, ['companyName', 'countryOrRegion', 'sanctionListSource', 'sanctionReason', 'effectiveDate', 'remarks']));
711
727
  item.normalizedCompanyName = normalizeExactName(item.companyName);
712
728
  item.normalizedSanctionListSource = normalizeSanctionSource(item.sanctionListSource);
@@ -717,6 +733,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
717
733
  }
718
734
  async importSanctionCatalog(scope, input) {
719
735
  const batch = await this.dataSource.transaction(async (manager) => {
736
+ await lockCatalogMutation(manager, scope, this.dataSource.options?.type === 'postgres');
720
737
  const batchRepo = manager.getRepository(SanctionCatalogBatch);
721
738
  const recordRepo = manager.getRepository(SanctionedCompanyRecord);
722
739
  const issueRepo = manager.getRepository(SanctionCatalogImportIssue);
@@ -733,7 +750,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
733
750
  await taskRepo.save(task);
734
751
  const now = new Date();
735
752
  if (input.replaceBatchId) {
736
- const old = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, deletedAt: IsNull() } }), '被覆盖制裁批次不存在');
753
+ const old = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() } }), '被覆盖制裁批次不存在');
737
754
  old.deletedAt = now;
738
755
  old.deletedById = scope.userId ?? null;
739
756
  old.deleteOperation = 'SANCTION_CATALOG_REPLACE';
@@ -741,7 +758,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
741
758
  await batchRepo.save(old);
742
759
  await recordRepo.update({ ...activeScope(scope), batchId: old.id, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SANCTION_CATALOG_REPLACE' });
743
760
  }
744
- result = await batchRepo.save(batchRepo.create({ ...auditScope(scope), sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName, importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null, totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0, importTaskId: input.importTaskId }));
761
+ result = await batchRepo.save(batchRepo.create({ ...auditScope(scope), lifecycleStatus: 'ACTIVE', sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName, importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null, totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0, importTaskId: input.importTaskId, riskRecalculatedAt: null }));
745
762
  }
746
763
  let changed = false;
747
764
  for (let index = 0; index < input.rows.length; index += 1) {
@@ -758,9 +775,11 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
758
775
  }
759
776
  const normalizedCompanyName = normalizeExactName(companyName);
760
777
  const normalizedSanctionListSource = normalizeSanctionSource(row.sanctionListSource);
761
- const duplicate = await recordRepo.findOne({ where: { ...activeScope(scope), normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull() } });
762
- if (duplicate?.batchId === result.id)
763
- continue;
778
+ const duplicate = await recordRepo.findOne({ where: [
779
+ { ...activeScope(scope), batchId: result.id, lifecycleStatus: result.lifecycleStatus, normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull() },
780
+ ...(result.replacedBatchId ? [{ ...activeScope(scope), batchId: IsNull(), lifecycleStatus: 'ACTIVE', normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull() }] : []),
781
+ { ...activeScope(scope), lifecycleStatus: 'ACTIVE', normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull(), ...(result.replacedBatchId ? { batchId: Not(result.replacedBatchId) } : {}) }
782
+ ] });
764
783
  if (duplicate && await this.catalogIssueExists(issueRepo, scope, result.id, 'DUPLICATE', row))
765
784
  continue;
766
785
  changed = true;
@@ -770,9 +789,11 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
770
789
  await issueRepo.save(issueRepo.create({ ...createdScope(scope), batchId: result.id, issueType: 'DUPLICATE', sourceSequence: result.totalCount, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null, parsedPayload: row, message: '制裁目录中已存在相同公司和名单来源' }));
771
790
  continue;
772
791
  }
773
- await recordRepo.save(recordRepo.create({ ...auditScope(scope), batchId: result.id, sourceType: 'FILE_IMPORT', companyName, normalizedCompanyName, countryOrRegion: row.countryOrRegion ?? null, sanctionListSource: row.sanctionListSource ?? null, normalizedSanctionListSource, sanctionReason: row.sanctionReason ?? null, effectiveDate: row.effectiveDate ?? null, remarks: row.remarks ?? null, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null }));
792
+ await recordRepo.save(recordRepo.create({ ...auditScope(scope), batchId: result.id, lifecycleStatus: result.lifecycleStatus, sourceType: 'FILE_IMPORT', companyName, normalizedCompanyName, countryOrRegion: row.countryOrRegion ?? null, sanctionListSource: row.sanctionListSource ?? null, normalizedSanctionListSource, sanctionReason: row.sanctionReason ?? null, effectiveDate: row.effectiveDate ?? null, remarks: row.remarks ?? null, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null }));
774
793
  result.successCount += 1;
775
794
  }
795
+ if (changed)
796
+ result.riskRecalculatedAt = null;
776
797
  const saved = !appending || changed ? await batchRepo.save(result) : result;
777
798
  if (!appending) {
778
799
  task.status = 'SUCCEEDED';
@@ -782,7 +803,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
782
803
  }
783
804
  return saved;
784
805
  });
785
- await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
806
+ await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED', batch.id);
786
807
  return batch;
787
808
  }
788
809
  async beginFastCatalogImport(scope, input) {
@@ -799,25 +820,17 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
799
820
  return { committedCursor: task.progress.committedCursor, batchId: task.resultEntityId };
800
821
  }
801
822
  assertImportTaskResultWritable(task);
802
- const now = new Date();
803
823
  let batchId;
804
824
  if (task.documentType === 'CONTROLLED_CATALOG') {
805
825
  const catalogType = input.catalogType;
806
826
  if (!catalogType)
807
827
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '管控目录缺少目录类型');
808
828
  const batchRepo = manager.getRepository(ControlledCatalogBatch);
809
- const recordRepo = manager.getRepository(ControlledGoodsRecord);
810
829
  if (input.replaceBatchId) {
811
- const old = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, deletedAt: IsNull() } }), '被覆盖目录批次不存在');
812
- old.deletedAt = now;
813
- old.deletedById = scope.userId ?? null;
814
- old.deleteOperation = 'CATALOG_REPLACE';
815
- old.updatedById = scope.userId ?? null;
816
- await batchRepo.save(old);
817
- await recordRepo.update({ ...activeScope(scope), batchId: old.id, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'CATALOG_REPLACE' });
830
+ await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() } }), '被覆盖目录批次不存在');
818
831
  }
819
832
  const batch = await batchRepo.save(batchRepo.create({
820
- ...auditScope(scope), catalogType, sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName,
833
+ ...auditScope(scope), lifecycleStatus: 'DRAFT', catalogType, sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName,
821
834
  importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null,
822
835
  totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0, skippedCount: 0,
823
836
  inferenceMetadata: input.inferenceMetadata ?? null,
@@ -827,21 +840,14 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
827
840
  }
828
841
  else {
829
842
  const batchRepo = manager.getRepository(SanctionCatalogBatch);
830
- const recordRepo = manager.getRepository(SanctionedCompanyRecord);
831
843
  if (input.replaceBatchId) {
832
- const old = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, deletedAt: IsNull() } }), '被覆盖制裁目录批次不存在');
833
- old.deletedAt = now;
834
- old.deletedById = scope.userId ?? null;
835
- old.deleteOperation = 'SANCTION_CATALOG_REPLACE';
836
- old.updatedById = scope.userId ?? null;
837
- await batchRepo.save(old);
838
- await recordRepo.update({ ...activeScope(scope), batchId: old.id, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SANCTION_CATALOG_REPLACE' });
844
+ await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() } }), '被覆盖制裁目录批次不存在');
839
845
  }
840
846
  const batch = await batchRepo.save(batchRepo.create({
841
- ...auditScope(scope), sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName,
847
+ ...auditScope(scope), lifecycleStatus: 'DRAFT', sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName,
842
848
  importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null,
843
849
  totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0, skippedCount: 0,
844
- inferenceMetadata: input.inferenceMetadata ?? null, importTaskId: task.id
850
+ inferenceMetadata: input.inferenceMetadata ?? null, importTaskId: task.id, riskRecalculatedAt: null
845
851
  }));
846
852
  batchId = batch.id;
847
853
  }
@@ -854,43 +860,47 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
854
860
  });
855
861
  }
856
862
  async appendFastCatalogBatch(scope, input) {
863
+ const preflight = await this.dataSource.transaction(async (manager) => {
864
+ const locked = await this.lockFastCatalogAppend(manager, scope, input.taskId);
865
+ if (locked.task.progress.committedCursor >= input.endCursor && input.endCursor > input.startCursor) {
866
+ return locked.task.progress;
867
+ }
868
+ if (locked.task.progress.committedCursor !== input.startCursor) {
869
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入游标不一致');
870
+ }
871
+ return null;
872
+ });
873
+ if (preflight)
874
+ return preflight;
857
875
  const controlledRows = input.candidates.flatMap(candidate => candidate.kind === 'CONTROLLED'
858
876
  ? candidateToControlledRows(candidate)
859
877
  : []);
860
878
  const localizedRows = controlledRows.length ? await this.localizeControlledCatalogRows(controlledRows, scope.organizationId) : [];
861
879
  return this.dataSource.transaction(async (manager) => {
880
+ const { task, controlledBatch, sanctionBatch } = await this.lockFastCatalogAppend(manager, scope, input.taskId);
862
881
  const taskRepo = manager.getRepository(ImportTask);
863
- const task = await required(taskRepo.findOne({
864
- where: { ...activeScope(scope), id: input.taskId },
865
- lock: { mode: 'pessimistic_write' }
866
- }), '解析任务不存在');
867
- if (!task.progress || task.progress.mode !== 'FAST_PIPELINE' || !task.resultEntityId) {
868
- throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入尚未初始化');
869
- }
870
882
  if (task.progress.committedCursor >= input.endCursor && input.endCursor > input.startCursor)
871
883
  return task.progress;
872
884
  if (task.progress.committedCursor !== input.startCursor) {
873
885
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入游标不一致');
874
886
  }
875
887
  if (task.documentType === 'CONTROLLED_CATALOG') {
876
- const batchRepo = manager.getRepository(ControlledCatalogBatch);
877
- const batch = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId } }), '管控目录批次不存在');
878
- await this.appendControlledCatalogRows(manager, scope, batch, controlledRows, localizedRows);
879
- await this.appendCatalogSkippedSources(manager.getRepository(ControlledCatalogImportIssue), scope, batch, input.skipped);
880
- await batchRepo.save(batch);
881
- task.progress.successCount = batch.successCount;
882
- task.progress.duplicateCount = batch.duplicateCount;
883
- task.progress.invalidCount = batch.invalidCount;
884
- task.progress.skippedCount = batch.skippedCount;
888
+ await this.appendControlledCatalogRows(manager, scope, controlledBatch, controlledRows, localizedRows);
889
+ await this.appendCatalogSkippedSources(manager.getRepository(ControlledCatalogImportIssue), scope, controlledBatch, input.skipped);
890
+ await manager.getRepository(ControlledCatalogBatch).save(controlledBatch);
891
+ task.progress.successCount = controlledBatch.successCount;
892
+ task.progress.duplicateCount = controlledBatch.duplicateCount;
893
+ task.progress.invalidCount = controlledBatch.invalidCount;
894
+ task.progress.skippedCount = controlledBatch.skippedCount;
885
895
  }
886
896
  else {
887
- const batch = await this.appendFastSanctionCandidates(manager, scope, task.resultEntityId, input.candidates);
888
- await this.appendCatalogSkippedSources(manager.getRepository(SanctionCatalogImportIssue), scope, batch, input.skipped);
889
- await manager.getRepository(SanctionCatalogBatch).save(batch);
890
- task.progress.successCount = batch.successCount;
891
- task.progress.duplicateCount = batch.duplicateCount;
892
- task.progress.invalidCount = batch.invalidCount;
893
- task.progress.skippedCount = batch.skippedCount;
897
+ const savedBatch = await this.appendFastSanctionCandidates(manager, scope, task.resultEntityId, input.candidates, sanctionBatch);
898
+ await this.appendCatalogSkippedSources(manager.getRepository(SanctionCatalogImportIssue), scope, savedBatch, input.skipped);
899
+ await manager.getRepository(SanctionCatalogBatch).save(savedBatch);
900
+ task.progress.successCount = savedBatch.successCount;
901
+ task.progress.duplicateCount = savedBatch.duplicateCount;
902
+ task.progress.invalidCount = savedBatch.invalidCount;
903
+ task.progress.skippedCount = savedBatch.skippedCount;
894
904
  }
895
905
  let insertedReviewCount = 0;
896
906
  if (input.reviewBlocks.length) {
@@ -907,7 +917,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
907
917
  confidence: block.confidence, status: 'PENDING', ordinal: block.ordinal,
908
918
  sourceLocation: block.sourceLocation || null, sourceContent: block.sourceContent || null,
909
919
  payload: block.payload, reasons: block.reasons,
910
- leaseToken: null, leaseExpiresAt: null, resolvedAt: null
920
+ leaseToken: null, leaseExpiresAt: null, resolvedAt: null, resolutionFingerprint: null
911
921
  }));
912
922
  insertedReviewCount += 1;
913
923
  }
@@ -923,6 +933,33 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
923
933
  return task.progress;
924
934
  });
925
935
  }
936
+ async lockFastCatalogAppend(manager, scope, taskId) {
937
+ const task = await required(manager.getRepository(ImportTask).findOne({
938
+ where: { ...activeScope(scope), id: taskId }, lock: { mode: 'pessimistic_write' }
939
+ }), '解析任务不存在');
940
+ if (task.status !== 'PERSISTING' || !task.progress || task.progress.mode !== 'FAST_PIPELINE' || !task.resultEntityId) {
941
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入状态无效');
942
+ }
943
+ const controlledBatch = task.documentType === 'CONTROLLED_CATALOG'
944
+ ? await required(manager.getRepository(ControlledCatalogBatch).findOne({
945
+ where: { ...activeScope(scope), id: task.resultEntityId }, lock: { mode: 'pessimistic_write' }
946
+ }), '管控目录批次不存在')
947
+ : null;
948
+ const sanctionBatch = task.documentType === 'SANCTION_CATALOG'
949
+ ? await required(manager.getRepository(SanctionCatalogBatch).findOne({
950
+ where: { ...activeScope(scope), id: task.resultEntityId }, lock: { mode: 'pessimistic_write' }
951
+ }), '制裁目录批次不存在')
952
+ : null;
953
+ const batch = controlledBatch ?? sanctionBatch;
954
+ if (!batch || batch.importTaskId !== task.id || (batch.lifecycleStatus !== 'DRAFT' && batch.lifecycleStatus !== 'REVIEWING')) {
955
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入状态无效');
956
+ }
957
+ return {
958
+ task: task,
959
+ controlledBatch,
960
+ sanctionBatch
961
+ };
962
+ }
926
963
  async finalizeFastCatalogImport(scope, taskId) {
927
964
  const task = await this.dataSource.transaction(async (manager) => {
928
965
  const repo = manager.getRepository(ImportTask);
@@ -932,7 +969,12 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
932
969
  if (!current.progress || current.progress.mode !== 'FAST_PIPELINE') {
933
970
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入进度不存在');
934
971
  }
972
+ if (current.status === 'SUCCEEDED') {
973
+ await this.assertCatalogBatchPublished(manager, scope, current);
974
+ return current;
975
+ }
935
976
  if (current.progress.reviewPendingCount > 0) {
977
+ await this.markCatalogDraftReviewing(manager, scope, current);
936
978
  current.status = 'AWAITING_REVIEW';
937
979
  current.progress.stage = 'REVIEWING';
938
980
  current.progress.observerStatus = 'PENDING';
@@ -950,6 +992,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
950
992
  await this.applyImportTaskFailure(manager, scope, current, 'FAILED', outcome.errorCode, outcome.errorMessage);
951
993
  }
952
994
  else {
995
+ await this.publishCatalogDraft(manager, scope, current);
953
996
  current.status = 'SUCCEEDED';
954
997
  current.progress.stage = 'SUCCEEDED';
955
998
  current.progress.observerStatus = 'COMPLETED';
@@ -966,10 +1009,125 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
966
1009
  if (task.documentType === 'CONTROLLED_CATALOG')
967
1010
  await this.recalculateProductRisks(scope, 'CATALOG_CHANGED', task.resultEntityId ?? undefined);
968
1011
  else
969
- await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
1012
+ await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED', task.resultEntityId ?? undefined);
970
1013
  }
971
1014
  return task;
972
1015
  }
1016
+ async resolveStalledCatalogImport(scope, taskId, decision) {
1017
+ if (!StalledCatalogImportDecisions.includes(decision)) {
1018
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录恢复决定无效');
1019
+ }
1020
+ const result = await this.dataSource.transaction(async (manager) => {
1021
+ const taskRepo = manager.getRepository(ImportTask);
1022
+ const unfinishedTaskWhere = { ...activeScope(scope), id: taskId, deletedAt: IsNull(), completedAt: IsNull() };
1023
+ const task = await required(taskRepo.findOne({
1024
+ where: decision === 'PUBLISH_VALID_ROWS'
1025
+ ? [unfinishedTaskWhere, { ...activeScope(scope), id: taskId, deletedAt: IsNull(), status: 'SUCCEEDED' }]
1026
+ : unfinishedTaskWhere,
1027
+ lock: { mode: 'pessimistic_write' }
1028
+ }), '解析任务不存在');
1029
+ if (task.deletedAt != null ||
1030
+ (task.documentType !== 'CONTROLLED_CATALOG' && task.documentType !== 'SANCTION_CATALOG') ||
1031
+ task.progress?.mode !== 'FAST_PIPELINE' ||
1032
+ !task.resultEntityId) {
1033
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '仅待复核的快速目录导入任务支持显式恢复');
1034
+ }
1035
+ const progress = task.progress;
1036
+ const batchId = task.resultEntityId;
1037
+ const publishedRetry = decision === 'PUBLISH_VALID_ROWS' && task.status === 'SUCCEEDED' && task.completedAt != null;
1038
+ const awaitingRecovery = task.status === 'AWAITING_REVIEW' && task.completedAt == null;
1039
+ if (!publishedRetry && !awaitingRecovery)
1040
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '仅待复核的快速目录导入任务支持显式恢复');
1041
+ const batch = task.documentType === 'CONTROLLED_CATALOG'
1042
+ ? await required(manager.getRepository(ControlledCatalogBatch).findOne({
1043
+ where: { ...activeScope(scope), id: batchId, importTaskId: task.id, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' }
1044
+ }), '管控目录批次不存在')
1045
+ : await required(manager.getRepository(SanctionCatalogBatch).findOne({
1046
+ where: { ...activeScope(scope), id: batchId, importTaskId: task.id, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' }
1047
+ }), '制裁目录批次不存在');
1048
+ if (batch.importTaskId !== task.id || batch.deletedAt ||
1049
+ (publishedRetry ? batch.lifecycleStatus !== 'ACTIVE' : !['DRAFT', 'REVIEWING', 'ACTIVE'].includes(batch.lifecycleStatus))) {
1050
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录恢复批次状态无效');
1051
+ }
1052
+ if (publishedRetry) {
1053
+ return {
1054
+ task: publicImportTaskDto(task),
1055
+ batchCounts: {
1056
+ totalCount: batch.totalCount, successCount: batch.successCount, duplicateCount: batch.duplicateCount,
1057
+ invalidCount: batch.invalidCount, skippedCount: batch.skippedCount ?? 0
1058
+ }
1059
+ };
1060
+ }
1061
+ const reviewRepo = manager.getRepository(CatalogReviewBlock);
1062
+ const unresolved = await reviewRepo.find({ where: {
1063
+ ...activeScope(scope), importTaskId: task.id, status: In(['PENDING', 'LEASED'])
1064
+ }, order: { ordinal: 'ASC', id: 'ASC' } });
1065
+ const resolvedAt = new Date();
1066
+ for (const block of unresolved) {
1067
+ block.status = 'REJECTED';
1068
+ block.leaseExpiresAt = null;
1069
+ block.resolvedAt = resolvedAt;
1070
+ }
1071
+ if (decision === 'PUBLISH_VALID_ROWS') {
1072
+ await this.appendStalledCatalogReviewIssues(manager, scope, task, batch, unresolved);
1073
+ }
1074
+ if (unresolved.length > 0)
1075
+ await reviewRepo.save(unresolved);
1076
+ await this.reconcileCatalogReviewPendingCount(reviewRepo, scope, task);
1077
+ if (progress.reviewPendingCount !== 0) {
1078
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录恢复后仍存在待复核块');
1079
+ }
1080
+ progress.reviewRejectedCount += unresolved.length;
1081
+ progress.successCount = batch.successCount;
1082
+ progress.duplicateCount = batch.duplicateCount;
1083
+ progress.invalidCount = batch.invalidCount;
1084
+ progress.skippedCount = batch.skippedCount ?? 0;
1085
+ progress.progressVersion += 1;
1086
+ progress.lastProgressAt = new Date().toISOString();
1087
+ if (decision === 'PUBLISH_VALID_ROWS') {
1088
+ const outcome = catalogTerminalOutcome(batch);
1089
+ progress.outcome = outcome.outcome;
1090
+ if (outcome.status === 'FAILED') {
1091
+ await this.applyImportTaskFailure(manager, scope, task, 'FAILED', outcome.errorCode, outcome.errorMessage);
1092
+ }
1093
+ else {
1094
+ if (batch.lifecycleStatus === 'DRAFT' || batch.lifecycleStatus === 'REVIEWING') {
1095
+ await this.publishCatalogDraft(manager, scope, task);
1096
+ }
1097
+ task.status = 'SUCCEEDED';
1098
+ progress.stage = 'SUCCEEDED';
1099
+ progress.observerStatus = 'COMPLETED';
1100
+ task.errorCode = null;
1101
+ task.errorMessage = null;
1102
+ task.completedAt = new Date();
1103
+ }
1104
+ }
1105
+ else {
1106
+ task.status = 'FAILED';
1107
+ progress.stage = 'FAILED';
1108
+ progress.observerStatus = 'FAILED';
1109
+ task.errorCode = 'STALLED_CATALOG_DISCARDED';
1110
+ task.errorMessage = '用户已明确放弃停滞目录批次,请重新导入源文件';
1111
+ task.completedAt = new Date();
1112
+ await this.failCatalogDraft(manager, scope, task, true);
1113
+ }
1114
+ await taskRepo.save(task);
1115
+ return {
1116
+ task: publicImportTaskDto(task),
1117
+ batchCounts: {
1118
+ totalCount: batch.totalCount, successCount: batch.successCount, duplicateCount: batch.duplicateCount,
1119
+ invalidCount: batch.invalidCount, skippedCount: batch.skippedCount ?? 0
1120
+ }
1121
+ };
1122
+ });
1123
+ if (decision === 'PUBLISH_VALID_ROWS' && result.task.status === 'SUCCEEDED' && result.batchCounts.successCount > 0) {
1124
+ if (result.task.documentType === 'CONTROLLED_CATALOG')
1125
+ await this.recalculateProductRisks(scope, 'CATALOG_CHANGED', result.task.resultEntityId ?? undefined);
1126
+ else
1127
+ await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED', result.task.resultEntityId ?? undefined);
1128
+ }
1129
+ return result;
1130
+ }
973
1131
  async waitImportProgress(scope, taskId, afterProgressVersion = 0, waitMs = 10_000) {
974
1132
  const deadline = Date.now() + Math.max(0, Math.min(waitMs, 10_000));
975
1133
  while (true) {
@@ -986,8 +1144,8 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
986
1144
  await new Promise(resolve => setTimeout(resolve, Math.min(250, remaining)));
987
1145
  }
988
1146
  }
989
- async nextCatalogReviewBatch(scope, taskId, requestedLimit = 20) {
990
- const limit = Math.max(1, Math.min(requestedLimit, 20));
1147
+ async nextCatalogReviewBatch(scope, taskId, requestedLimit = CATALOG_REVIEW_BATCH_LIMIT) {
1148
+ const limit = Math.max(1, Math.min(requestedLimit, CATALOG_REVIEW_BATCH_LIMIT));
991
1149
  return this.dataSource.transaction(async (manager) => {
992
1150
  const task = await required(manager.getRepository(ImportTask).findOne({
993
1151
  where: { ...activeScope(scope), id: taskId }, lock: { mode: 'pessimistic_write' }
@@ -999,6 +1157,13 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
999
1157
  await repo.update({
1000
1158
  ...activeScope(scope), importTaskId: taskId, status: 'LEASED', leaseExpiresAt: LessThanOrEqual(new Date())
1001
1159
  }, { status: 'PENDING', leaseToken: null, leaseExpiresAt: null });
1160
+ const previousPendingCount = task.progress?.reviewPendingCount;
1161
+ const pendingCount = await this.reconcileCatalogReviewPendingCount(repo, scope, task);
1162
+ if (task.progress && previousPendingCount !== pendingCount) {
1163
+ task.progress.progressVersion += 1;
1164
+ task.progress.lastProgressAt = new Date().toISOString();
1165
+ await manager.getRepository(ImportTask).save(task);
1166
+ }
1002
1167
  const blocks = await repo.createQueryBuilder('block')
1003
1168
  .setLock('pessimistic_write')
1004
1169
  .setOnLocked('skip_locked')
@@ -1031,10 +1196,10 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1031
1196
  });
1032
1197
  }
1033
1198
  async submitCatalogReviewBatch(scope, taskId, leaseToken, items) {
1034
- if (!leaseToken.trim() || items.length === 0 || items.length > 20) {
1199
+ if (!leaseToken.trim() || items.length === 0 || items.length > CATALOG_REVIEW_BATCH_LIMIT) {
1035
1200
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '复核提交参数无效');
1036
1201
  }
1037
- return this.dataSource.transaction(async (manager) => {
1202
+ const result = await this.dataSource.transaction(async (manager) => {
1038
1203
  const taskRepo = manager.getRepository(ImportTask);
1039
1204
  const task = await required(taskRepo.findOne({
1040
1205
  where: { ...activeScope(scope), id: taskId }, lock: { mode: 'pessimistic_write' }
@@ -1042,6 +1207,30 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1042
1207
  if (!task.progress || !task.resultEntityId)
1043
1208
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录复核任务状态无效');
1044
1209
  const reviewRepo = manager.getRepository(CatalogReviewBlock);
1210
+ if (task.status === 'SUCCEEDED')
1211
+ await this.assertCatalogBatchPublished(manager, scope, task);
1212
+ const hasPriorReviewDecisions = task.progress.reviewResolvedCount > 0 || task.progress.reviewRejectedCount > 0;
1213
+ const resolvedBlocks = task.status === 'SUCCEEDED' || hasPriorReviewDecisions
1214
+ ? await reviewRepo.find({ where: {
1215
+ ...activeScope(scope), importTaskId: taskId, id: In(items.map(item => item.blockId)),
1216
+ status: In(['RESOLVED', 'REJECTED']), leaseToken
1217
+ } })
1218
+ : [];
1219
+ if (resolvedBlocks.length > 0 || task.status === 'SUCCEEDED') {
1220
+ const matchingRetry = catalogReviewRetryMatches(resolvedBlocks, items);
1221
+ if (!matchingRetry)
1222
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '复核块租约无效或已过期');
1223
+ const remaining = await this.reconcileCatalogReviewPendingCount(reviewRepo, scope, task);
1224
+ if (task.status === 'SUCCEEDED' && remaining !== 0)
1225
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '已发布目录仍存在待复核块');
1226
+ return {
1227
+ resolved: items.filter(item => item.decision === 'ACCEPT').length,
1228
+ rejected: items.filter(item => item.decision === 'REJECT').length,
1229
+ remaining,
1230
+ errors: [],
1231
+ task: publicImportTaskDto(task)
1232
+ };
1233
+ }
1045
1234
  const blocks = await reviewRepo.find({ where: {
1046
1235
  ...activeScope(scope), importTaskId: taskId, id: In(items.map(item => item.blockId)),
1047
1236
  status: 'LEASED', leaseToken
@@ -1051,6 +1240,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1051
1240
  }
1052
1241
  const acceptedCandidates = [];
1053
1242
  const rejectedBlocks = [];
1243
+ const errors = [];
1054
1244
  let resolved = 0;
1055
1245
  let rejected = 0;
1056
1246
  for (const item of items) {
@@ -1059,22 +1249,35 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1059
1249
  block.status = 'REJECTED';
1060
1250
  rejectedBlocks.push(block);
1061
1251
  rejected += 1;
1252
+ block.resolvedAt = new Date();
1253
+ block.leaseExpiresAt = null;
1254
+ block.resolutionFingerprint = catalogReviewResolutionFingerprint(item);
1062
1255
  }
1063
1256
  else {
1064
- const payload = item.payload ?? block.payload;
1065
- const candidate = reviewCandidate(task.documentType, block, payload);
1066
- const decision = classifyCandidate(candidate);
1067
- if (decision.confidence === 'LOW') {
1068
- throw new DomainError(DomainErrorCodes.INVALID_INPUT, `复核结果未通过代码校验:${decision.reasons.join(',')}`);
1257
+ const evaluation = evaluateCatalogReviewAccept(task.documentType, block, item.payload);
1258
+ if (!evaluation.candidate) {
1259
+ restorePendingReviewBlock(block);
1260
+ errors.push({ blockId: block.id, reasons: evaluation.reasons });
1261
+ continue;
1262
+ }
1263
+ let resolutionFingerprint;
1264
+ try {
1265
+ resolutionFingerprint = catalogReviewResolutionFingerprint(item);
1266
+ }
1267
+ catch {
1268
+ restorePendingReviewBlock(block);
1269
+ errors.push({ blockId: block.id, reasons: ['REVIEW_PAYLOAD_INVALID'] });
1270
+ continue;
1069
1271
  }
1272
+ const candidate = evaluation.candidate;
1070
1273
  acceptedCandidates.push(candidate);
1071
- block.payload = payload;
1274
+ block.payload = candidate.values;
1072
1275
  block.status = 'RESOLVED';
1073
1276
  resolved += 1;
1277
+ block.resolvedAt = new Date();
1278
+ block.leaseExpiresAt = null;
1279
+ block.resolutionFingerprint = resolutionFingerprint;
1074
1280
  }
1075
- block.resolvedAt = new Date();
1076
- block.leaseToken = null;
1077
- block.leaseExpiresAt = null;
1078
1281
  }
1079
1282
  if (task.documentType === 'CONTROLLED_CATALOG' && acceptedCandidates.length) {
1080
1283
  const batchRepo = manager.getRepository(ControlledCatalogBatch);
@@ -1103,7 +1306,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1103
1306
  await reviewRepo.save(blocks);
1104
1307
  task.progress.reviewResolvedCount += resolved;
1105
1308
  task.progress.reviewRejectedCount += rejected;
1106
- task.progress.reviewPendingCount = Math.max(0, task.progress.reviewPendingCount - resolved - rejected);
1309
+ await this.reconcileCatalogReviewPendingCount(reviewRepo, scope, task);
1107
1310
  task.progress.progressVersion += 1;
1108
1311
  task.progress.lastProgressAt = new Date().toISOString();
1109
1312
  if (task.progress.reviewPendingCount === 0) {
@@ -1118,6 +1321,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1118
1321
  await this.applyImportTaskFailure(manager, scope, task, 'FAILED', outcome.errorCode, outcome.errorMessage);
1119
1322
  }
1120
1323
  else {
1324
+ await this.publishCatalogDraft(manager, scope, task);
1121
1325
  task.status = 'SUCCEEDED';
1122
1326
  task.progress.stage = 'SUCCEEDED';
1123
1327
  task.progress.observerStatus = 'COMPLETED';
@@ -1127,12 +1331,28 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1127
1331
  }
1128
1332
  }
1129
1333
  else {
1334
+ await this.markCatalogDraftReviewing(manager, scope, task);
1130
1335
  task.status = 'AWAITING_REVIEW';
1131
1336
  task.progress.stage = 'REVIEWING';
1132
1337
  }
1133
1338
  await taskRepo.save(task);
1134
- return { resolved, rejected, remaining: task.progress.reviewPendingCount, task: publicImportTaskDto(task) };
1339
+ return { resolved, rejected, remaining: task.progress.reviewPendingCount, errors, task: publicImportTaskDto(task) };
1135
1340
  });
1341
+ if (result.task.status === 'SUCCEEDED' && (result.task.progress?.successCount ?? 0) > 0) {
1342
+ if (result.task.documentType === 'CONTROLLED_CATALOG')
1343
+ await this.recalculateProductRisks(scope, 'CATALOG_CHANGED', result.task.resultEntityId ?? undefined);
1344
+ else
1345
+ await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED', result.task.resultEntityId ?? undefined);
1346
+ }
1347
+ return result;
1348
+ }
1349
+ async reconcileCatalogReviewPendingCount(reviewRepo, scope, task) {
1350
+ const pendingCount = await reviewRepo.count({ where: {
1351
+ ...activeScope(scope), importTaskId: task.id, status: In(['PENDING', 'LEASED'])
1352
+ } });
1353
+ if (task.progress)
1354
+ task.progress.reviewPendingCount = pendingCount;
1355
+ return pendingCount;
1136
1356
  }
1137
1357
  async setImportObserverConversation(scope, taskId, conversationId) {
1138
1358
  const normalizedConversationId = requiredId(conversationId, '观察会话标识');
@@ -1153,12 +1373,12 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1153
1373
  return repo.save(task);
1154
1374
  });
1155
1375
  }
1156
- async appendFastSanctionCandidates(manager, scope, batchId, candidates) {
1376
+ async appendFastSanctionCandidates(manager, scope, batchId, candidates, validatedBatch) {
1157
1377
  const batchRepo = manager.getRepository(SanctionCatalogBatch);
1158
1378
  const recordRepo = manager.getRepository(SanctionedCompanyRecord);
1159
1379
  const aliasRepo = manager.getRepository(SanctionedCompanyAlias);
1160
1380
  const issueRepo = manager.getRepository(SanctionCatalogImportIssue);
1161
- const batch = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: batchId } }), '制裁目录批次不存在');
1381
+ const batch = validatedBatch ?? await required(batchRepo.findOne({ where: { ...activeScope(scope), id: batchId } }), '制裁目录批次不存在');
1162
1382
  for (const candidate of candidates.filter(value => value.kind === 'SANCTION')) {
1163
1383
  const companyName = String(candidate.values.companyName ?? '').trim();
1164
1384
  const sourceLocation = candidate.evidence[0]?.sourceLocation ?? null;
@@ -1168,7 +1388,11 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1168
1388
  const normalizedCompanyName = normalizeExactName(companyName);
1169
1389
  const sanctionListSource = optionalString(candidate.values.sanctionListSource);
1170
1390
  const normalizedSanctionListSource = normalizeSanctionSource(sanctionListSource);
1171
- const duplicate = await recordRepo.findOne({ where: { ...activeScope(scope), normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull() } });
1391
+ const duplicate = await recordRepo.findOne({ where: [
1392
+ { ...activeScope(scope), batchId: batch.id, lifecycleStatus: batch.lifecycleStatus, normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull() },
1393
+ ...(batch.replacedBatchId ? [{ ...activeScope(scope), batchId: IsNull(), lifecycleStatus: 'ACTIVE', normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull() }] : []),
1394
+ { ...activeScope(scope), lifecycleStatus: 'ACTIVE', normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull(), ...(batch.replacedBatchId ? { batchId: Not(batch.replacedBatchId) } : {}) }
1395
+ ] });
1172
1396
  batch.totalCount += 1;
1173
1397
  if (duplicate) {
1174
1398
  batch.duplicateCount += 1;
@@ -1180,7 +1404,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1180
1404
  continue;
1181
1405
  }
1182
1406
  const record = await recordRepo.save(recordRepo.create({
1183
- ...auditScope(scope), batchId: batch.id, sourceType: 'FILE_IMPORT', companyName, normalizedCompanyName,
1407
+ ...auditScope(scope), batchId: batch.id, lifecycleStatus: batch.lifecycleStatus, sourceType: 'FILE_IMPORT', companyName, normalizedCompanyName,
1184
1408
  countryOrRegion: optionalString(candidate.values.countryOrRegion), sanctionListSource,
1185
1409
  normalizedSanctionListSource, sanctionReason: optionalString(candidate.values.sanctionReason),
1186
1410
  effectiveDate: optionalString(candidate.values.effectiveDate), remarks: optionalString(candidate.values.remarks),
@@ -1245,6 +1469,34 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1245
1469
  }
1246
1470
  return batchRepo.save(batch);
1247
1471
  }
1472
+ async appendStalledCatalogReviewIssues(manager, scope, task, batch, blocks) {
1473
+ const issueRepo = (task.documentType === 'CONTROLLED_CATALOG'
1474
+ ? manager.getRepository(ControlledCatalogImportIssue)
1475
+ : manager.getRepository(SanctionCatalogImportIssue));
1476
+ for (const block of blocks) {
1477
+ const parsedPayload = {
1478
+ reviewBlockId: block.id, reviewDecision: 'STALLED_RECOVERY_REJECT',
1479
+ payload: block.payload, reasons: block.reasons
1480
+ };
1481
+ const existing = await issueRepo.findOne({ where: {
1482
+ ...activeScope(scope), batchId: batch.id, issueType: 'INVALID', parsedPayload
1483
+ } });
1484
+ if (existing)
1485
+ continue;
1486
+ batch.totalCount += 1;
1487
+ batch.invalidCount += 1;
1488
+ batch.skippedCount += 1;
1489
+ await issueRepo.save(issueRepo.create({
1490
+ ...createdScope(scope), batchId: batch.id, issueType: 'INVALID', sourceSequence: batch.totalCount,
1491
+ sourceLocation: block.sourceLocation, sourceContent: block.sourceContent, parsedPayload,
1492
+ message: `停滞目录恢复时跳过待复核来源行:${block.reasons.join(', ') || '未提供原因'}`
1493
+ }));
1494
+ }
1495
+ if (task.documentType === 'CONTROLLED_CATALOG')
1496
+ await manager.getRepository(ControlledCatalogBatch).save(batch);
1497
+ else
1498
+ await manager.getRepository(SanctionCatalogBatch).save(batch);
1499
+ }
1248
1500
  async catalogBatchCounts(manager, scope, task) {
1249
1501
  if (!task.resultEntityId)
1250
1502
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录导入批次不存在');
@@ -1262,7 +1514,224 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1262
1514
  skippedCount: batch.skippedCount ?? 0
1263
1515
  };
1264
1516
  }
1265
- async rollbackFailedCatalogReplacement(manager, scope, task) {
1517
+ async assertCatalogBatchPublished(manager, scope, task) {
1518
+ if (!task.resultEntityId)
1519
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录导入批次不存在');
1520
+ if (task.documentType === 'CONTROLLED_CATALOG') {
1521
+ await required(manager.getRepository(ControlledCatalogBatch).findOne({
1522
+ where: { ...activeScope(scope), id: task.resultEntityId, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() },
1523
+ lock: { mode: 'pessimistic_write' }
1524
+ }), '已发布管控目录批次不存在');
1525
+ return;
1526
+ }
1527
+ if (task.documentType !== 'SANCTION_CATALOG')
1528
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '仅目录任务支持发布');
1529
+ await required(manager.getRepository(SanctionCatalogBatch).findOne({
1530
+ where: { ...activeScope(scope), id: task.resultEntityId, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() },
1531
+ lock: { mode: 'pessimistic_write' }
1532
+ }), '已发布制裁目录批次不存在');
1533
+ }
1534
+ async markCatalogDraftReviewing(manager, scope, task) {
1535
+ if (!task.resultEntityId)
1536
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录导入批次不存在');
1537
+ if (task.documentType === 'CONTROLLED_CATALOG') {
1538
+ const batchRepo = manager.getRepository(ControlledCatalogBatch);
1539
+ const batch = await required(batchRepo.findOne({
1540
+ where: { ...activeScope(scope), id: task.resultEntityId }, lock: { mode: 'pessimistic_write' }
1541
+ }), '管控目录批次不存在');
1542
+ if (batch.lifecycleStatus !== 'DRAFT' && batch.lifecycleStatus !== 'REVIEWING') {
1543
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '管控目录草稿状态无效');
1544
+ }
1545
+ const previousStatus = batch.lifecycleStatus;
1546
+ batch.lifecycleStatus = 'REVIEWING';
1547
+ batch.updatedById = scope.userId ?? null;
1548
+ await batchRepo.save(batch);
1549
+ await manager.getRepository(ControlledGoodsRecord).update({
1550
+ ...activeScope(scope), batchId: batch.id, lifecycleStatus: previousStatus, deletedAt: IsNull()
1551
+ }, { lifecycleStatus: 'REVIEWING', updatedById: scope.userId ?? null });
1552
+ return;
1553
+ }
1554
+ const batchRepo = manager.getRepository(SanctionCatalogBatch);
1555
+ const batch = await required(batchRepo.findOne({
1556
+ where: { ...activeScope(scope), id: task.resultEntityId }, lock: { mode: 'pessimistic_write' }
1557
+ }), '制裁目录批次不存在');
1558
+ if (batch.lifecycleStatus !== 'DRAFT' && batch.lifecycleStatus !== 'REVIEWING') {
1559
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '制裁目录草稿状态无效');
1560
+ }
1561
+ const previousStatus = batch.lifecycleStatus;
1562
+ batch.lifecycleStatus = 'REVIEWING';
1563
+ batch.updatedById = scope.userId ?? null;
1564
+ await batchRepo.save(batch);
1565
+ await manager.getRepository(SanctionedCompanyRecord).update({
1566
+ ...activeScope(scope), batchId: batch.id, lifecycleStatus: previousStatus, deletedAt: IsNull()
1567
+ }, { lifecycleStatus: 'REVIEWING', updatedById: scope.userId ?? null });
1568
+ }
1569
+ async publishCatalogDraft(manager, scope, task) {
1570
+ if (!task.resultEntityId)
1571
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录导入批次不存在');
1572
+ await lockCatalogMutation(manager, scope, this.dataSource.options?.type === 'postgres');
1573
+ const now = new Date();
1574
+ if (task.documentType === 'CONTROLLED_CATALOG') {
1575
+ const batchRepo = manager.getRepository(ControlledCatalogBatch);
1576
+ const recordRepo = manager.getRepository(ControlledGoodsRecord);
1577
+ const batch = await required(batchRepo.findOne({
1578
+ where: { ...activeScope(scope), id: task.resultEntityId }, lock: { mode: 'pessimistic_write' }
1579
+ }), '管控目录批次不存在');
1580
+ if (batch.lifecycleStatus !== 'DRAFT' && batch.lifecycleStatus !== 'REVIEWING') {
1581
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '管控目录草稿状态无效');
1582
+ }
1583
+ await this.reclassifyControlledPublishConflicts(manager, scope, batch, now);
1584
+ if (batch.replacedBatchId) {
1585
+ const replaced = await required(batchRepo.findOne({
1586
+ where: { ...activeScope(scope), id: batch.replacedBatchId, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() },
1587
+ lock: { mode: 'pessimistic_write' }
1588
+ }), '被覆盖目录批次不存在');
1589
+ replaced.deletedAt = now;
1590
+ replaced.deletedById = scope.userId ?? null;
1591
+ replaced.deleteOperation = 'CATALOG_REPLACE';
1592
+ replaced.updatedById = scope.userId ?? null;
1593
+ await batchRepo.save(replaced);
1594
+ await recordRepo.update({
1595
+ ...activeScope(scope), batchId: replaced.id, lifecycleStatus: 'ACTIVE', deletedAt: IsNull()
1596
+ }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'CATALOG_REPLACE', updatedById: scope.userId ?? null });
1597
+ }
1598
+ const previousStatus = batch.lifecycleStatus;
1599
+ batch.lifecycleStatus = 'ACTIVE';
1600
+ batch.updatedById = scope.userId ?? null;
1601
+ await batchRepo.save(batch);
1602
+ await recordRepo.update({
1603
+ ...activeScope(scope), batchId: batch.id, lifecycleStatus: previousStatus, deletedAt: IsNull()
1604
+ }, { lifecycleStatus: 'ACTIVE', updatedById: scope.userId ?? null });
1605
+ this.synchronizeCatalogTaskCounts(task, batch);
1606
+ return;
1607
+ }
1608
+ if (task.documentType !== 'SANCTION_CATALOG')
1609
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '仅目录任务支持发布');
1610
+ const batchRepo = manager.getRepository(SanctionCatalogBatch);
1611
+ const recordRepo = manager.getRepository(SanctionedCompanyRecord);
1612
+ const aliasRepo = manager.getRepository(SanctionedCompanyAlias);
1613
+ const batch = await required(batchRepo.findOne({
1614
+ where: { ...activeScope(scope), id: task.resultEntityId }, lock: { mode: 'pessimistic_write' }
1615
+ }), '制裁目录批次不存在');
1616
+ if (batch.lifecycleStatus !== 'DRAFT' && batch.lifecycleStatus !== 'REVIEWING') {
1617
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '制裁目录草稿状态无效');
1618
+ }
1619
+ await this.reclassifySanctionPublishConflicts(manager, scope, batch, now);
1620
+ if (batch.replacedBatchId) {
1621
+ const replaced = await required(batchRepo.findOne({
1622
+ where: { ...activeScope(scope), id: batch.replacedBatchId, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() },
1623
+ lock: { mode: 'pessimistic_write' }
1624
+ }), '被覆盖制裁目录批次不存在');
1625
+ replaced.deletedAt = now;
1626
+ replaced.deletedById = scope.userId ?? null;
1627
+ replaced.deleteOperation = 'SANCTION_CATALOG_REPLACE';
1628
+ replaced.updatedById = scope.userId ?? null;
1629
+ await batchRepo.save(replaced);
1630
+ const replacedRecords = await recordRepo.find({
1631
+ where: { ...activeScope(scope), batchId: replaced.id, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() },
1632
+ select: { id: true }
1633
+ });
1634
+ if (replacedRecords.length > 0) {
1635
+ await aliasRepo.update({
1636
+ ...activeScope(scope), sanctionedCompanyId: In(replacedRecords.map(record => record.id)), deletedAt: IsNull()
1637
+ }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SANCTION_CATALOG_REPLACE', updatedById: scope.userId ?? null });
1638
+ }
1639
+ await recordRepo.update({
1640
+ ...activeScope(scope), batchId: replaced.id, lifecycleStatus: 'ACTIVE', deletedAt: IsNull()
1641
+ }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SANCTION_CATALOG_REPLACE', updatedById: scope.userId ?? null });
1642
+ }
1643
+ const previousStatus = batch.lifecycleStatus;
1644
+ batch.lifecycleStatus = 'ACTIVE';
1645
+ batch.updatedById = scope.userId ?? null;
1646
+ await batchRepo.save(batch);
1647
+ await recordRepo.update({
1648
+ ...activeScope(scope), batchId: batch.id, lifecycleStatus: previousStatus, deletedAt: IsNull()
1649
+ }, { lifecycleStatus: 'ACTIVE', updatedById: scope.userId ?? null });
1650
+ this.synchronizeCatalogTaskCounts(task, batch);
1651
+ }
1652
+ synchronizeCatalogTaskCounts(task, batch) {
1653
+ if (!task.progress)
1654
+ return;
1655
+ task.progress.successCount = batch.successCount;
1656
+ task.progress.duplicateCount = batch.duplicateCount;
1657
+ task.progress.invalidCount = batch.invalidCount;
1658
+ task.progress.skippedCount = batch.skippedCount;
1659
+ }
1660
+ async reclassifyControlledPublishConflicts(manager, scope, batch, now) {
1661
+ const recordRepo = manager.getRepository(ControlledGoodsRecord);
1662
+ const issueRepo = manager.getRepository(ControlledCatalogImportIssue);
1663
+ const drafts = await recordRepo.find({
1664
+ where: { ...activeScope(scope), batchId: batch.id, lifecycleStatus: In(['DRAFT', 'REVIEWING']), deletedAt: IsNull() }
1665
+ });
1666
+ for (const record of drafts) {
1667
+ const conflictWhere = {
1668
+ ...activeScope(scope), lifecycleStatus: 'ACTIVE', catalogType: record.catalogType,
1669
+ normalizedProductName: record.normalizedProductName, normalizedHsCode: record.normalizedHsCode,
1670
+ deletedAt: IsNull()
1671
+ };
1672
+ const conflict = await recordRepo.findOne({ where: batch.replacedBatchId
1673
+ ? [{ ...conflictWhere, batchId: IsNull() }, { ...conflictWhere, batchId: Not(batch.replacedBatchId) }]
1674
+ : conflictWhere });
1675
+ if (!conflict)
1676
+ continue;
1677
+ batch.successCount = Math.max(0, batch.successCount - 1);
1678
+ batch.duplicateCount += 1;
1679
+ await issueRepo.save(issueRepo.create({
1680
+ ...createdScope(scope), batchId: batch.id, issueType: 'DUPLICATE',
1681
+ sourceSequence: Math.max(1, batch.totalCount - batch.successCount),
1682
+ sourceLocation: record.sourceLocation, sourceContent: record.sourceContent,
1683
+ parsedPayload: {
1684
+ catalogType: record.catalogType, productName: record.productName, hsCode: record.hsCode
1685
+ },
1686
+ message: '发布前检测到相同商品名称和海关编码的活动记录'
1687
+ }));
1688
+ await recordRepo.update({ ...activeScope(scope), id: record.id }, {
1689
+ lifecycleStatus: 'FAILED', deletedAt: now, deletedById: scope.userId ?? null,
1690
+ deleteOperation: 'IMPORT_FAILED', updatedById: scope.userId ?? null
1691
+ });
1692
+ }
1693
+ }
1694
+ async reclassifySanctionPublishConflicts(manager, scope, batch, now) {
1695
+ const recordRepo = manager.getRepository(SanctionedCompanyRecord);
1696
+ const issueRepo = manager.getRepository(SanctionCatalogImportIssue);
1697
+ const aliasRepo = manager.getRepository(SanctionedCompanyAlias);
1698
+ const drafts = await recordRepo.find({
1699
+ where: { ...activeScope(scope), batchId: batch.id, lifecycleStatus: In(['DRAFT', 'REVIEWING']), deletedAt: IsNull() }
1700
+ });
1701
+ for (const record of drafts) {
1702
+ const conflictWhere = {
1703
+ ...activeScope(scope), lifecycleStatus: 'ACTIVE', normalizedCompanyName: record.normalizedCompanyName,
1704
+ normalizedSanctionListSource: record.normalizedSanctionListSource, deletedAt: IsNull()
1705
+ };
1706
+ const conflict = await recordRepo.findOne({ where: batch.replacedBatchId
1707
+ ? [{ ...conflictWhere, batchId: IsNull() }, { ...conflictWhere, batchId: Not(batch.replacedBatchId) }]
1708
+ : conflictWhere });
1709
+ if (!conflict)
1710
+ continue;
1711
+ batch.successCount = Math.max(0, batch.successCount - 1);
1712
+ batch.duplicateCount += 1;
1713
+ await issueRepo.save(issueRepo.create({
1714
+ ...createdScope(scope), batchId: batch.id, issueType: 'DUPLICATE',
1715
+ sourceSequence: Math.max(1, batch.totalCount - batch.successCount),
1716
+ sourceLocation: record.sourceLocation, sourceContent: record.sourceContent,
1717
+ parsedPayload: {
1718
+ companyName: record.companyName, sanctionListSource: record.sanctionListSource
1719
+ },
1720
+ message: '发布前检测到相同公司和名单来源的活动记录'
1721
+ }));
1722
+ await aliasRepo.update({
1723
+ ...activeScope(scope), sanctionedCompanyId: record.id, deletedAt: IsNull()
1724
+ }, {
1725
+ deletedAt: now, deletedById: scope.userId ?? null,
1726
+ deleteOperation: 'IMPORT_FAILED', updatedById: scope.userId ?? null
1727
+ });
1728
+ await recordRepo.update({ ...activeScope(scope), id: record.id }, {
1729
+ lifecycleStatus: 'FAILED', deletedAt: now, deletedById: scope.userId ?? null,
1730
+ deleteOperation: 'IMPORT_FAILED', updatedById: scope.userId ?? null
1731
+ });
1732
+ }
1733
+ }
1734
+ async failCatalogDraft(manager, scope, task, allowActive = false) {
1266
1735
  if (!task.resultEntityId || (task.documentType !== 'CONTROLLED_CATALOG' && task.documentType !== 'SANCTION_CATALOG'))
1267
1736
  return;
1268
1737
  const now = new Date();
@@ -1270,11 +1739,9 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1270
1739
  const batchRepo = manager.getRepository(ControlledCatalogBatch);
1271
1740
  const recordRepo = manager.getRepository(ControlledGoodsRecord);
1272
1741
  const current = await batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId } });
1273
- if (!current?.replacedBatchId)
1274
- return;
1275
- const replaced = await batchRepo.findOne({ where: { ...activeScope(scope), id: current.replacedBatchId } });
1276
- if (!replaced || replaced.deleteOperation !== 'CATALOG_REPLACE')
1742
+ if (!current || (current.lifecycleStatus !== 'DRAFT' && current.lifecycleStatus !== 'REVIEWING' && (!allowActive || current.lifecycleStatus !== 'ACTIVE')))
1277
1743
  return;
1744
+ current.lifecycleStatus = 'FAILED';
1278
1745
  current.deletedAt = now;
1279
1746
  current.deletedById = scope.userId ?? null;
1280
1747
  current.deleteOperation = 'IMPORT_FAILED';
@@ -1282,26 +1749,16 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1282
1749
  await batchRepo.save(current);
1283
1750
  await recordRepo.update({
1284
1751
  ...activeScope(scope), batchId: current.id, deletedAt: IsNull()
1285
- }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'IMPORT_FAILED', updatedById: scope.userId ?? null });
1286
- replaced.deletedAt = null;
1287
- replaced.deletedById = null;
1288
- replaced.deleteOperation = null;
1289
- replaced.updatedById = scope.userId ?? null;
1290
- await batchRepo.save(replaced);
1291
- await recordRepo.update({
1292
- ...activeScope(scope), batchId: replaced.id, deleteOperation: 'CATALOG_REPLACE'
1293
- }, { deletedAt: null, deletedById: null, deleteOperation: null, updatedById: scope.userId ?? null });
1752
+ }, { lifecycleStatus: 'FAILED', deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'IMPORT_FAILED', updatedById: scope.userId ?? null });
1294
1753
  return;
1295
1754
  }
1296
1755
  const batchRepo = manager.getRepository(SanctionCatalogBatch);
1297
1756
  const recordRepo = manager.getRepository(SanctionedCompanyRecord);
1298
1757
  const aliasRepo = manager.getRepository(SanctionedCompanyAlias);
1299
1758
  const current = await batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId } });
1300
- if (!current?.replacedBatchId)
1301
- return;
1302
- const replaced = await batchRepo.findOne({ where: { ...activeScope(scope), id: current.replacedBatchId } });
1303
- if (!replaced || replaced.deleteOperation !== 'SANCTION_CATALOG_REPLACE')
1759
+ if (!current || (current.lifecycleStatus !== 'DRAFT' && current.lifecycleStatus !== 'REVIEWING' && (!allowActive || current.lifecycleStatus !== 'ACTIVE')))
1304
1760
  return;
1761
+ current.lifecycleStatus = 'FAILED';
1305
1762
  current.deletedAt = now;
1306
1763
  current.deletedById = scope.userId ?? null;
1307
1764
  current.deleteOperation = 'IMPORT_FAILED';
@@ -1317,15 +1774,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1317
1774
  }
1318
1775
  await recordRepo.update({
1319
1776
  ...activeScope(scope), batchId: current.id, deletedAt: IsNull()
1320
- }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'IMPORT_FAILED', updatedById: scope.userId ?? null });
1321
- replaced.deletedAt = null;
1322
- replaced.deletedById = null;
1323
- replaced.deleteOperation = null;
1324
- replaced.updatedById = scope.userId ?? null;
1325
- await batchRepo.save(replaced);
1326
- await recordRepo.update({
1327
- ...activeScope(scope), batchId: replaced.id, deleteOperation: 'SANCTION_CATALOG_REPLACE'
1328
- }, { deletedAt: null, deletedById: null, deleteOperation: null, updatedById: scope.userId ?? null });
1777
+ }, { lifecycleStatus: 'FAILED', deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'IMPORT_FAILED', updatedById: scope.userId ?? null });
1329
1778
  }
1330
1779
  async applyImportTaskFailure(manager, scope, task, status, errorCode, errorMessage, completedAt = new Date()) {
1331
1780
  task.status = status;
@@ -1335,7 +1784,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1335
1784
  if (task.progress?.mode === 'FAST_PIPELINE') {
1336
1785
  task.progress.stage = 'FAILED';
1337
1786
  task.progress.observerStatus = 'FAILED';
1338
- await this.rollbackFailedCatalogReplacement(manager, scope, task);
1787
+ await this.failCatalogDraft(manager, scope, task);
1339
1788
  }
1340
1789
  }
1341
1790
  async createImportTask(scope, input) {
@@ -1767,7 +2216,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1767
2216
  const lineRepo = manager.getRepository(PurchaseOrderLine);
1768
2217
  for (let index = 0; index < draft.lines.length; index += 1) {
1769
2218
  const line = draft.lines[index];
1770
- await lineRepo.save(lineRepo.create({ ...auditScope(scope), purchaseOrderId: entity.id, lineNumber: line.lineNumber ?? index + 1, sourceType: line.sourceType ?? 'CONTRACT_EXTRACTED', productName: line.productName.trim(), normalizedProductName: normalizeForProductMatch(line.productName), englishName: line.englishName ?? null, englishNameSource: line.englishNameSource ?? null, productDescription: line.productDescription ?? null, modelSpecification: line.modelSpecification ?? null, unit: line.unit ?? null, quantity: defaultDecimalText(line.quantity), unitPriceTaxIncluded: defaultDecimalText(line.unitPriceTaxIncluded), lineAmountTaxIncluded: defaultDecimalText(line.lineAmountTaxIncluded), vatRate: optionalString(line.vatRate), vatRateSource: line.vatRateSource ?? null, hsCode: line.hsCode ?? null, hsCodeStatus: line.hsCodeStatus ?? 'UNCONFIRMED', hsCodeSource: line.hsCodeSource ?? null, hsCodeReferenceSnapshotId: line.hsCodeReferenceSnapshotId ?? null, originCountry: line.originCountry ?? null, domesticSourceLocation: line.domesticSourceLocation ?? null, refundRate: optionalString(line.refundRate), refundRateSource: line.refundRateSource ?? null, refundPolicyReference: line.refundPolicyReference ?? null, refundPolicyDate: optionalString(line.refundPolicyDate), refundValidationMethod: line.refundValidationMethod ?? null, refundConfirmedAt: confirmedAt, refundConfirmedById: confirmedById, amountMismatchConfirmedAt: line.amountMismatch === true ? confirmedAt : null, amountMismatchConfirmedById: line.amountMismatch === true ? confirmedById : null, sourceLocation: line.sourceLocation ?? null, sourceContent: line.sourceContent ?? null }));
2219
+ await lineRepo.save(lineRepo.create({ ...auditScope(scope), purchaseOrderId: entity.id, lineNumber: line.lineNumber ?? index + 1, sourceType: line.sourceType ?? 'CONTRACT_EXTRACTED', productName: line.productName.trim(), normalizedProductName: normalizeForProductMatch(line.productName), englishName: line.englishName ?? null, englishNameSource: line.englishNameSource ?? null, productDescription: line.productDescription ?? null, modelSpecification: line.modelSpecification ?? null, unit: line.unit ?? null, quantity: defaultDecimalText(line.quantity), unitPriceTaxIncluded: defaultDecimalText(line.unitPriceTaxIncluded), lineAmountTaxIncluded: purchaseLineAmountText(line), vatRate: optionalString(line.vatRate), vatRateSource: line.vatRateSource ?? null, hsCode: line.hsCode ?? null, hsCodeStatus: line.hsCodeStatus ?? 'UNCONFIRMED', hsCodeSource: line.hsCodeSource ?? null, hsCodeReferenceSnapshotId: line.hsCodeReferenceSnapshotId ?? null, originCountry: line.originCountry ?? null, domesticSourceLocation: line.domesticSourceLocation ?? null, refundRate: optionalString(line.refundRate), refundRateSource: line.refundRateSource ?? null, refundPolicyReference: line.refundPolicyReference ?? null, refundPolicyDate: optionalString(line.refundPolicyDate), refundValidationMethod: line.refundValidationMethod ?? null, refundConfirmedAt: confirmedAt, refundConfirmedById: confirmedById, amountMismatchConfirmedAt: line.amountMismatch === true ? confirmedAt : null, amountMismatchConfirmedById: line.amountMismatch === true ? confirmedById : null, sourceLocation: line.sourceLocation ?? null, sourceContent: line.sourceContent ?? null }));
1771
2220
  }
1772
2221
  task.status = 'SUCCEEDED';
1773
2222
  task.resultEntityId = entity.id;
@@ -1842,7 +2291,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1842
2291
  const currentOrders = orders.filter(order => String(order.orderDate ?? '').startsWith(`${currentYear}-`));
1843
2292
  const top = new Map();
1844
2293
  for (const line of lines)
1845
- top.set(line.productName, (top.get(line.productName) ?? new Decimal(0)).plus(line.lineAmountTaxIncluded));
2294
+ top.set(line.productName, (top.get(line.productName) ?? new Decimal(0)).plus(purchaseLineMetricAmount(line)));
1846
2295
  const orderBalances = {};
1847
2296
  for (const order of orders) {
1848
2297
  const orderLines = lines.filter(line => line.purchaseOrderId === order.id);
@@ -1893,6 +2342,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1893
2342
  const saved = await this.withLockedPurchaseLines(scope, existing.purchaseOrderId, async ({ lineRepo, lines, contracts, customers, manuals }) => {
1894
2343
  const item = await required(Promise.resolve(lines.find(line => line.id === id) ?? null), '采购商品不存在');
1895
2344
  Object.assign(item, pick(input, ['productName', 'englishName', 'englishNameSource', 'productDescription', 'modelSpecification', 'unit', 'quantity', 'unitPriceTaxIncluded', 'lineAmountTaxIncluded', 'vatRate', 'vatRateSource', 'hsCode', 'hsCodeStatus', 'hsCodeSource', 'originCountry', 'domesticSourceLocation', 'refundRate', 'refundRateSource', 'refundPolicyReference', 'refundPolicyDate', 'refundValidationMethod']));
2345
+ item.lineAmountTaxIncluded = purchaseLineAmountText(item);
1896
2346
  validatePurchaseLineDiscriminators(item);
1897
2347
  const rates = validateTaxRates(item, { purchaseOrderLineId: item.id, lineNumber: item.lineNumber, productName: item.productName });
1898
2348
  item.vatRate = rates.vatRate;
@@ -1915,7 +2365,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1915
2365
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '采购商品数量必须大于 0');
1916
2366
  const saved = await this.withLockedPurchaseLines(scope, purchaseOrderId, async ({ lineRepo, lines, contracts, customers, manuals }) => {
1917
2367
  const lineNumber = lines.reduce((maximum, line) => Math.max(maximum, line.lineNumber), 0) + 1;
1918
- const item = lineRepo.create({ ...auditScope(scope), purchaseOrderId, lineNumber, sourceType: discriminators.sourceType ?? 'MANUAL_ENTRY', productName: input.productName.trim(), normalizedProductName: normalizeForProductMatch(input.productName), englishName: input.englishName ?? null, englishNameSource: discriminators.englishNameSource ?? null, productDescription: input.productDescription ?? null, modelSpecification: input.modelSpecification ?? null, unit: input.unit ?? null, quantity: input.quantity, unitPriceTaxIncluded: input.unitPriceTaxIncluded, lineAmountTaxIncluded: input.lineAmountTaxIncluded, vatRate: rates.vatRate, vatRateSource: discriminators.vatRateSource ?? 'MANUAL', hsCode: input.hsCode ?? null, hsCodeStatus: discriminators.hsCodeStatus ?? 'UNCONFIRMED', hsCodeSource: discriminators.hsCodeSource ?? null, hsCodeReferenceSnapshotId: null, originCountry: input.originCountry ?? null, domesticSourceLocation: input.domesticSourceLocation ?? null, refundRate: rates.refundRate, refundRateSource: discriminators.refundRateSource ?? null, refundPolicyReference: input.refundPolicyReference ?? null, refundPolicyDate: input.refundPolicyDate ?? null, refundValidationMethod: discriminators.refundValidationMethod ?? null, refundConfirmedAt: null, refundConfirmedById: null, amountMismatchConfirmedAt: null, amountMismatchConfirmedById: null, sourceLocation: null, sourceContent: null });
2368
+ const item = lineRepo.create({ ...auditScope(scope), purchaseOrderId, lineNumber, sourceType: discriminators.sourceType ?? 'MANUAL_ENTRY', productName: input.productName.trim(), normalizedProductName: normalizeForProductMatch(input.productName), englishName: input.englishName ?? null, englishNameSource: discriminators.englishNameSource ?? null, productDescription: input.productDescription ?? null, modelSpecification: input.modelSpecification ?? null, unit: input.unit ?? null, quantity: input.quantity, unitPriceTaxIncluded: input.unitPriceTaxIncluded, lineAmountTaxIncluded: purchaseLineAmountText(input), vatRate: rates.vatRate, vatRateSource: discriminators.vatRateSource ?? 'MANUAL', hsCode: input.hsCode ?? null, hsCodeStatus: discriminators.hsCodeStatus ?? 'UNCONFIRMED', hsCodeSource: discriminators.hsCodeSource ?? null, hsCodeReferenceSnapshotId: null, originCountry: input.originCountry ?? null, domesticSourceLocation: input.domesticSourceLocation ?? null, refundRate: rates.refundRate, refundRateSource: discriminators.refundRateSource ?? null, refundPolicyReference: input.refundPolicyReference ?? null, refundPolicyDate: input.refundPolicyDate ?? null, refundValidationMethod: discriminators.refundValidationMethod ?? null, refundConfirmedAt: null, refundConfirmedById: null, amountMismatchConfirmedAt: null, amountMismatchConfirmedById: null, sourceLocation: null, sourceContent: null });
1919
2369
  this.assertLockedPurchaseReceipts([...lines, item], contracts, customers, manuals);
1920
2370
  return lineRepo.save(item);
1921
2371
  });
@@ -2524,7 +2974,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
2524
2974
  const linesByPurchase = groupBy(lines, line => line.purchaseOrderId);
2525
2975
  const riskRows = lines.length ? await this.dataSource.getRepository(ProductComplianceMatch).find({ where: { ...activeScope(scope), purchaseOrderLineId: In(lines.map(line => line.id)), deletedAt: IsNull() } }) : [];
2526
2976
  const controlledGoodsIds = [...new Set(riskRows.map(match => match.controlledGoodsId))];
2527
- const controlledGoods = controlledGoodsIds.length ? await this.dataSource.getRepository(ControlledGoodsRecord).find({ where: { ...activeScope(scope), id: In(controlledGoodsIds), deletedAt: IsNull() } }) : [];
2977
+ const controlledGoods = controlledGoodsIds.length ? await this.dataSource.getRepository(ControlledGoodsRecord).find({ where: { ...activeScope(scope), id: In(controlledGoodsIds), lifecycleStatus: 'ACTIVE', deletedAt: IsNull() } }) : [];
2528
2978
  const controlledGoodsById = new Map(controlledGoods.map(item => [item.id, item]));
2529
2979
  const risksByLine = groupBy(riskRows, match => match.purchaseOrderLineId);
2530
2980
  const purchasesByBaseCode = groupBy(purchases, purchase => parsePurchaseContractNo(purchase.contractNo).baseCode);
@@ -2715,7 +3165,8 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
2715
3165
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '供应商存在有效采购订单,不能删除', { orderCount: count });
2716
3166
  }
2717
3167
  const repo = manager.getRepository(entity);
2718
- const target = await required(repo.findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } }), '记录不存在');
3168
+ const lifecycle = entity === ControlledGoodsRecord || entity === SanctionedCompanyRecord ? { lifecycleStatus: 'ACTIVE' } : {};
3169
+ const target = await required(repo.findOne({ where: { ...activeScope(scope), id, ...lifecycle, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } }), '记录不存在');
2719
3170
  const now = new Date();
2720
3171
  target.deletedAt = now;
2721
3172
  target.deletedById = scope.userId ?? null;
@@ -3091,7 +3542,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
3091
3542
  });
3092
3543
  await matchRepo.update({ ...activeScope(scope), deletedAt: IsNull() }, { deletedAt: now, invalidationReason: reason });
3093
3544
  const goods = await manager.getRepository(ControlledGoodsRecord).find({
3094
- where: { ...activeScope(scope), deletedAt: IsNull() },
3545
+ where: { ...activeScope(scope), lifecycleStatus: 'ACTIVE', deletedAt: IsNull() },
3095
3546
  order: { id: 'ASC' },
3096
3547
  lock: { mode: 'pessimistic_read' }
3097
3548
  });
@@ -3115,17 +3566,28 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
3115
3566
  return { scanned: lines.length, matches };
3116
3567
  });
3117
3568
  }
3118
- async recalculateCompanyRisks(scope, reason) {
3569
+ async recalculateCompanyRisks(scope, reason, sanctionBatchId) {
3119
3570
  return this.dataSource.transaction(async (manager) => {
3120
3571
  const repo = manager.getRepository(CompanySanctionMatch);
3121
3572
  const supplierRepo = manager.getRepository(Supplier);
3122
3573
  const customerRepo = manager.getRepository(Customer);
3123
3574
  const now = new Date();
3575
+ if (this.dataSource.options?.type === 'postgres') {
3576
+ await manager.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [`trade-compliance:company-risk:${scope.tenantId}:${scope.organizationId}`, 'recalculate']);
3577
+ }
3578
+ const sanctionBatch = sanctionBatchId
3579
+ ? await required(manager.getRepository(SanctionCatalogBatch).findOne({
3580
+ where: { ...activeScope(scope), id: sanctionBatchId }, lock: { mode: 'pessimistic_write' }
3581
+ }), '制裁目录批次不存在')
3582
+ : null;
3583
+ if (sanctionBatch?.riskRecalculatedAt) {
3584
+ return { scanned: 0, matches: 0, riskRecalculatedAt: sanctionBatch.riskRecalculatedAt };
3585
+ }
3124
3586
  await repo.update({ ...activeScope(scope), deletedAt: IsNull() }, { deletedAt: now, invalidationReason: reason });
3125
3587
  const [suppliers, customers, sanctions, aliases] = await Promise.all([
3126
3588
  supplierRepo.find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
3127
3589
  customerRepo.find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
3128
- manager.getRepository(SanctionedCompanyRecord).find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
3590
+ manager.getRepository(SanctionedCompanyRecord).find({ where: { ...activeScope(scope), lifecycleStatus: 'ACTIVE', deletedAt: IsNull() } }),
3129
3591
  manager.getRepository(SanctionedCompanyAlias).find({ where: { ...activeScope(scope), deletedAt: IsNull() } })
3130
3592
  ]);
3131
3593
  const sanctionsById = new Map(sanctions.map(item => [item.id, item]));
@@ -3147,7 +3609,14 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
3147
3609
  await repo.save(repo.create({ ...createdScope(scope), subjectType: 'CUSTOMER', supplierId: null, customerId: customer.id, sanctionedCompanyId: item.id, matchedNormalizedName: customer.normalizedName, checkedAt: now, deletedAt: null, invalidationReason: null }));
3148
3610
  await customerRepo.update({ ...activeScope(scope), id: customer.id, deletedAt: IsNull() }, { complianceCheckStatus: 'CHECKED', complianceCheckedAt: now, updatedById: scope.userId ?? null, updatedAt: now });
3149
3611
  }
3150
- return { scanned: suppliers.length + customers.length, matches: await repo.count({ where: { ...activeScope(scope), deletedAt: IsNull() } }) };
3612
+ const matches = await repo.count({ where: { ...activeScope(scope), deletedAt: IsNull() } });
3613
+ if (sanctionBatch) {
3614
+ sanctionBatch.riskRecalculatedAt = now;
3615
+ sanctionBatch.updatedById = scope.userId ?? null;
3616
+ await manager.getRepository(SanctionCatalogBatch).save(sanctionBatch);
3617
+ return { scanned: suppliers.length + customers.length, matches, riskRecalculatedAt: now };
3618
+ }
3619
+ return { scanned: suppliers.length + customers.length, matches };
3151
3620
  });
3152
3621
  }
3153
3622
  async pagedQuery(entity, scope, query, searchFields, exactFilters = {}, filters = {}) {
@@ -3181,8 +3650,12 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
3181
3650
  const pageSize = Math.min(200, Math.max(1, Number(query.pageSize ?? 20)));
3182
3651
  const qb = this.dataSource.getRepository(entity).createQueryBuilder('row')
3183
3652
  .leftJoin(batchEntity, 'batch', 'batch.id = row.batchId AND batch.tenantId = :tenantId AND batch.organizationId = :organizationId', activeScope(scope))
3184
- .where('row.tenantId = :tenantId AND row.organizationId = :organizationId', activeScope(scope))
3185
- .andWhere('row.deletedAt IS NULL');
3653
+ .where('row.tenantId = :tenantId AND row.organizationId = :organizationId', activeScope(scope));
3654
+ const catalogBatchId = String(query.parameters?.batchId ?? '').trim();
3655
+ if (catalogBatchId)
3656
+ qb.andWhere('row.batchId = :catalogBatchId', { catalogBatchId });
3657
+ else
3658
+ qb.andWhere('row.deletedAt IS NULL').andWhere("row.lifecycleStatus = 'ACTIVE'");
3186
3659
  for (const [key, value] of Object.entries(filters.exact ?? {}))
3187
3660
  if (hasQueryValue(value))
3188
3661
  qb.andWhere(`row.${key} = :exact_${key}`, { [`exact_${key}`]: value });
@@ -3204,6 +3677,21 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
3204
3677
  const fileNames = new Map(batches.map(batch => [batch.id, batch.sourceFileName]));
3205
3678
  return { items: items.map(item => ({ ...item, sourceFileName: item.batchId ? fileNames.get(item.batchId) : undefined })), total, page, pageSize };
3206
3679
  }
3680
+ async pagedBatchHistoryQuery(entity, scope, query, exactFilters = {}) {
3681
+ const page = Math.max(1, Number(query.page ?? 1));
3682
+ const pageSize = Math.min(200, Math.max(1, Number(query.pageSize ?? 20)));
3683
+ const qb = this.dataSource.getRepository(entity).createQueryBuilder('row')
3684
+ .where('row.tenantId = :tenantId AND row.organizationId = :organizationId', activeScope(scope));
3685
+ const search = String(query.search ?? '').trim();
3686
+ if (search)
3687
+ qb.andWhere('CAST(row.sourceFileName AS TEXT) ILIKE :search', { search: `%${search}%` });
3688
+ for (const [key, value] of Object.entries(exactFilters)) {
3689
+ if (value !== undefined && value !== null && value !== '')
3690
+ qb.andWhere(`row.${key} = :filter_${key}`, { [`filter_${key}`]: value });
3691
+ }
3692
+ const [items, total] = await qb.orderBy('row.updatedAt', 'DESC').addOrderBy('row.id', 'DESC').skip((page - 1) * pageSize).take(pageSize).getManyAndCount();
3693
+ return { items, total, page, pageSize };
3694
+ }
3207
3695
  async upsertSupplier(manager, scope, input) {
3208
3696
  const repo = manager.getRepository(Supplier);
3209
3697
  const normalizedName = normalizeExactName(input.name);
@@ -3437,6 +3925,14 @@ function handoffCallbackIdentityMatches(input, expectedSourceMessageId, persiste
3437
3925
  async function lockOrganizationTaskClaim(manager, scope) {
3438
3926
  await manager.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [scope.tenantId, scope.organizationId]);
3439
3927
  }
3928
+ async function lockCatalogMutation(manager, scope, enabled) {
3929
+ if (!enabled)
3930
+ return;
3931
+ await manager.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [
3932
+ `trade-compliance:catalog:${scope.tenantId}:${scope.organizationId}`,
3933
+ 'mutate'
3934
+ ]);
3935
+ }
3440
3936
  async function organizationHasActiveParsing(manager, scope) {
3441
3937
  const activeImport = await manager.getRepository(ImportTask).findOne({
3442
3938
  where: { ...activeScope(scope), status: In([...RUNNING_IMPORT_TASK_STATUSES]), deletedAt: IsNull() },
@@ -3654,10 +4150,6 @@ function assertOptionalEnum(value, allowed, label) { if (hasQueryValue(value) &&
3654
4150
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, `${label}无效`); }
3655
4151
  function fuzzyValue(value, filter) { return !hasQueryValue(filter) || String(value ?? '').toLocaleLowerCase().includes(String(filter).trim().toLocaleLowerCase()); }
3656
4152
  function pageRows(rows, query) { const pageSize = Math.min(200, Math.max(1, Number(query.pageSize ?? 20))); const pageCount = Math.max(1, Math.ceil(rows.length / pageSize)); const page = Math.min(pageCount, Math.max(1, Number(query.page ?? 1))); return { items: rows.slice((page - 1) * pageSize, page * pageSize), total: rows.length, page, pageSize }; }
3657
- function filterSalesOrders(rows, parameters, includeParties = false) {
3658
- const date = parameters.orderDate && typeof parameters.orderDate === 'object' && !Array.isArray(parameters.orderDate) ? parameters.orderDate : {};
3659
- return rows.filter(row => fuzzyValue(row.salesOrderNo, parameters.salesOrderNo) && fuzzyValue(row.customerContractNo, parameters.customerContractNo) && fuzzyValue(row.purchaseContractNo, parameters.purchaseContractNo) && (!includeParties || fuzzyValue(row.supplierName, parameters.supplierName) && fuzzyValue(row.customerName, parameters.customerName)) && (!hasQueryValue(parameters.paymentStatus) || row.paymentStatus === parameters.paymentStatus) && (!hasQueryValue(date.from) || String(row.orderDate ?? '') >= String(date.from)) && (!hasQueryValue(date.to) || String(row.orderDate ?? '') <= String(date.to)));
3660
- }
3661
4153
  function salesMetrics(rows) {
3662
4154
  const currentYear = String(new Date().getFullYear());
3663
4155
  const currentYearRows = rows.filter(row => String(row.orderDate ?? '').slice(0, 4) === currentYear);
@@ -3763,7 +4255,7 @@ function assertPurchaseDraft(draft) {
3763
4255
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, `${prefix}名称为必填项`);
3764
4256
  const quantity = optionalDecimalInput(line.quantity, `${prefix}数量必须为有效数值`);
3765
4257
  const unitPrice = optionalDecimalInput(line.unitPriceTaxIncluded, `${prefix}含税单价必须为有效金额`);
3766
- const lineAmount = optionalDecimalInput(line.lineAmountTaxIncluded, `${prefix}含税金额必须为有效金额`);
4258
+ const lineAmount = optionalDecimalInput(purchaseLineAmountText(line), `${prefix}含税金额必须为有效金额`);
3767
4259
  validateOptionalTaxRate(line.vatRate, 'vatRate', '增值税率', { lineNumber: index + 1, productName: line.productName });
3768
4260
  const refundRate = validateOptionalTaxRate(line.refundRate, 'refundRate', '退税率', { lineNumber: index + 1, productName: line.productName });
3769
4261
  if (hasQueryValue(line.refundPolicyDate) && hasQueryValue(draft.order.orderDate) && String(line.refundPolicyDate) > String(draft.order.orderDate))
@@ -3806,6 +4298,21 @@ function optionalDecimalInput(value, message) {
3806
4298
  function defaultDecimalText(value) {
3807
4299
  return optionalDecimalInput(value, '数值必须是有限金额')?.toString() ?? '0';
3808
4300
  }
4301
+ function purchaseLineAmountText(line) {
4302
+ if (hasQueryValue(line.lineAmountTaxIncluded))
4303
+ return defaultDecimalText(line.lineAmountTaxIncluded);
4304
+ const quantity = optionalDecimalInput(line.quantity, '采购商品数量必须为有效数值');
4305
+ const unitPrice = optionalDecimalInput(line.unitPriceTaxIncluded, '采购商品含税单价必须为有效金额');
4306
+ return quantity && unitPrice ? quantity.mul(unitPrice).toString() : '0';
4307
+ }
4308
+ function purchaseLineMetricAmount(line) {
4309
+ const lineAmount = optionalDecimalInput(line.lineAmountTaxIncluded, '采购商品含税金额必须为有效金额') ?? new Decimal(0);
4310
+ if (lineAmount.gt(0))
4311
+ return lineAmount;
4312
+ const quantity = optionalDecimalInput(line.quantity, '采购商品数量必须为有效数值');
4313
+ const unitPrice = optionalDecimalInput(line.unitPriceTaxIncluded, '采购商品含税单价必须为有效金额');
4314
+ return quantity && unitPrice ? quantity.mul(unitPrice) : lineAmount;
4315
+ }
3809
4316
  function validateOptionalTaxRate(value, field, label, context) {
3810
4317
  if (!hasQueryValue(value))
3811
4318
  return null;
@@ -3864,6 +4371,11 @@ function candidateToControlledRows(candidate) {
3864
4371
  const hsCodes = Array.isArray(candidate.values.hsCodes) && candidate.values.hsCodes.length
3865
4372
  ? candidate.values.hsCodes
3866
4373
  : [undefined];
4374
+ const translationSource = candidate.values.translationSource === 'AGENT_TRANSLATED' || candidate.values.translationSource === 'SOURCE_CHINESE'
4375
+ ? candidate.values.translationSource
4376
+ : candidate.values.translationRequired || (!candidate.values.productName && candidate.values.originalProductName)
4377
+ ? 'AGENT_TRANSLATED'
4378
+ : 'SOURCE_CHINESE';
3867
4379
  return hsCodes.map(hsCode => ({
3868
4380
  productName: optionalString(candidate.values.productName) ?? undefined,
3869
4381
  originalProductName: optionalString(candidate.values.originalProductName) ?? undefined,
@@ -3874,9 +4386,7 @@ function candidateToControlledRows(candidate) {
3874
4386
  sourceOrdinal: optionalString(candidate.values.sourceOrdinal) ?? String(candidate.ordinal),
3875
4387
  sourceLocation: evidence?.sourceLocation,
3876
4388
  sourceContent: evidence?.sourceContent,
3877
- translationSource: candidate.values.translationRequired || (!candidate.values.productName && candidate.values.originalProductName)
3878
- ? 'AGENT_TRANSLATED'
3879
- : 'SOURCE_CHINESE'
4389
+ translationSource
3880
4390
  }));
3881
4391
  }
3882
4392
  function candidateAliases(candidate) {
@@ -3905,6 +4415,133 @@ function reviewCandidate(documentType, block, payload) {
3905
4415
  warnings: block.reasons
3906
4416
  };
3907
4417
  }
4418
+ function mergeReviewPayload(base, patch) {
4419
+ const merged = { ...base };
4420
+ for (const [key, value] of Object.entries(patch ?? {})) {
4421
+ if (value === null)
4422
+ delete merged[key];
4423
+ else
4424
+ merged[key] = value;
4425
+ }
4426
+ return merged;
4427
+ }
4428
+ function restorePendingReviewBlock(block) {
4429
+ block.status = 'PENDING';
4430
+ block.leaseToken = null;
4431
+ block.leaseExpiresAt = null;
4432
+ block.resolvedAt = null;
4433
+ block.resolutionFingerprint = null;
4434
+ }
4435
+ function catalogReviewRetryMatches(blocks, items) {
4436
+ if (blocks.length !== items.length)
4437
+ return false;
4438
+ return items.every(item => {
4439
+ const block = blocks.find(value => value.id === item.blockId);
4440
+ if (!block || block.status !== (item.decision === 'ACCEPT' ? 'RESOLVED' : 'REJECTED'))
4441
+ return false;
4442
+ if (!block.resolutionFingerprint)
4443
+ return false;
4444
+ try {
4445
+ return block.resolutionFingerprint === catalogReviewResolutionFingerprint(item);
4446
+ }
4447
+ catch {
4448
+ return false;
4449
+ }
4450
+ });
4451
+ }
4452
+ function catalogReviewResolutionFingerprint(item) {
4453
+ const resolution = item.decision === 'REJECT'
4454
+ ? { decision: item.decision }
4455
+ : Object.prototype.hasOwnProperty.call(item, 'payload')
4456
+ ? { decision: item.decision, payload: item.payload, payloadPresent: true }
4457
+ : { decision: item.decision, payloadPresent: false };
4458
+ return createHash('sha256').update(canonicalJson(resolution)).digest('hex');
4459
+ }
4460
+ function canonicalJson(value) {
4461
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
4462
+ return JSON.stringify(value);
4463
+ if (typeof value === 'number') {
4464
+ if (!Number.isFinite(value))
4465
+ throw new TypeError('Resolution payload is not JSON serializable');
4466
+ return JSON.stringify(value);
4467
+ }
4468
+ if (Array.isArray(value))
4469
+ return `[${value.map(item => item === undefined ? 'null' : canonicalJson(item)).join(',')}]`;
4470
+ if (value && typeof value === 'object') {
4471
+ const entries = Object.entries(value)
4472
+ .filter(([, item]) => item !== undefined)
4473
+ .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
4474
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}`;
4475
+ }
4476
+ throw new TypeError('Resolution payload is not JSON serializable');
4477
+ }
4478
+ function evaluateCatalogReviewAccept(documentType, block, patch) {
4479
+ try {
4480
+ const payload = mergeReviewPayload(block.payload, patch);
4481
+ let candidate = reviewCandidate(documentType, block, payload);
4482
+ if (!validCatalogReviewCandidateValues(candidate))
4483
+ return { candidate: null, reasons: ['REVIEW_PAYLOAD_INVALID'] };
4484
+ candidate = normalizeCatalogCandidate(candidate);
4485
+ const decision = classifyCandidate(candidate);
4486
+ if (decision.confidence === 'LOW')
4487
+ return { candidate: null, reasons: decision.reasons };
4488
+ if (candidate.kind === 'SANCTION' && typeof candidate.values.effectiveDate === 'string' && !isRealIsoDate(candidate.values.effectiveDate)) {
4489
+ return { candidate: null, reasons: ['EFFECTIVE_DATE_INVALID'] };
4490
+ }
4491
+ return { candidate, reasons: [] };
4492
+ }
4493
+ catch {
4494
+ return { candidate: null, reasons: ['REVIEW_PAYLOAD_INVALID'] };
4495
+ }
4496
+ }
4497
+ const CATALOG_REVIEW_VARCHAR_MAX = 255;
4498
+ const CATALOG_REVIEW_TEXT_MAX = 100_000;
4499
+ const CATALOG_REVIEW_ARRAY_MAX = 100;
4500
+ function validCatalogReviewCandidateValues(candidate) {
4501
+ const values = candidate.values;
4502
+ const varcharFields = candidate.kind === 'CONTROLLED'
4503
+ ? ['productName', 'hsCode', 'controlCode', 'sourceSection', 'sourceOrdinal']
4504
+ : ['companyName', 'countryOrRegion', 'sanctionListSource', 'effectiveDate'];
4505
+ const textFields = candidate.kind === 'CONTROLLED'
4506
+ ? ['originalProductName', 'controlDescription', 'originalControlDescription']
4507
+ : ['sanctionReason', 'remarks'];
4508
+ if (!validOptionalStrings(values, varcharFields, CATALOG_REVIEW_VARCHAR_MAX))
4509
+ return false;
4510
+ if (!validOptionalStrings(values, textFields, CATALOG_REVIEW_TEXT_MAX))
4511
+ return false;
4512
+ if (values.translationRequired !== undefined && typeof values.translationRequired !== 'boolean')
4513
+ return false;
4514
+ if (values.translationSource !== undefined && values.translationSource !== 'SOURCE_CHINESE' && values.translationSource !== 'AGENT_TRANSLATED')
4515
+ return false;
4516
+ if (values.hsCodes !== undefined && (!Array.isArray(values.hsCodes) || values.hsCodes.length > CATALOG_REVIEW_ARRAY_MAX ||
4517
+ values.hsCodes.some(value => typeof value !== 'string' || value.length > CATALOG_REVIEW_VARCHAR_MAX)))
4518
+ return false;
4519
+ if (values.aliases !== undefined && (!Array.isArray(values.aliases) || values.aliases.length > CATALOG_REVIEW_ARRAY_MAX ||
4520
+ values.aliases.some(value => !validCatalogReviewAlias(value))))
4521
+ return false;
4522
+ return true;
4523
+ }
4524
+ function validOptionalStrings(values, fields, maxLength) {
4525
+ return fields.every(field => values[field] === undefined || (typeof values[field] === 'string' && values[field].length <= maxLength));
4526
+ }
4527
+ function validCatalogReviewAlias(value) {
4528
+ if (!value || typeof value !== 'object' || Array.isArray(value))
4529
+ return false;
4530
+ const alias = value.alias;
4531
+ const aliasType = value.aliasType;
4532
+ return typeof alias === 'string' && alias.length > 0 && alias.length <= CATALOG_REVIEW_VARCHAR_MAX &&
4533
+ (aliasType === 'AKA' || aliasType === 'LOCAL_LANGUAGE');
4534
+ }
4535
+ function isRealIsoDate(value) {
4536
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
4537
+ if (!match)
4538
+ return false;
4539
+ const year = Number(match[1]);
4540
+ const month = Number(match[2]);
4541
+ const day = Number(match[3]);
4542
+ const date = new Date(Date.UTC(year, month - 1, day));
4543
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
4544
+ }
3908
4545
  function appendMapValue(map, key, value) {
3909
4546
  const values = map.get(key);
3910
4547
  if (values) {
@@ -3929,7 +4566,6 @@ function assertCompleteClauseAnalyses(analyses) {
3929
4566
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析结果必须完整包含七类关键条款');
3930
4567
  }
3931
4568
  function decimalSum(rows, read) { return rows.reduce((value, row) => value.plus(read(row)), new Decimal(0)).toNumber(); }
3932
- function fuzzyMatches(value, filter) { return !filter || String(value ?? '').trim().toLocaleLowerCase().includes(filter.trim().toLocaleLowerCase()); }
3933
4569
  function shanghaiCurrentYear(now = new Date()) {
3934
4570
  return Number(new Intl.DateTimeFormat('en-US', { timeZone: 'Asia/Shanghai', year: 'numeric' }).format(now));
3935
4571
  }