@chenchaolong/plugin-trade-compliance-workbench 1.0.52 → 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 (112) 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/demo-data.definitions.js +1 -1
  22. package/dist/lib/demo-data.definitions.js.map +1 -1
  23. package/dist/lib/entities/catalog.entity.d.ts +7 -1
  24. package/dist/lib/entities/catalog.entity.d.ts.map +1 -1
  25. package/dist/lib/entities/catalog.entity.js +26 -2
  26. package/dist/lib/entities/catalog.entity.js.map +1 -1
  27. package/dist/lib/entities/customer.entity.js.map +1 -1
  28. package/dist/lib/entities/procurement.entity.js.map +1 -1
  29. package/dist/lib/remote-components/trade-compliance-workbench/app.css +1 -1
  30. package/dist/lib/remote-components/trade-compliance-workbench/app.js +8 -8
  31. package/dist/lib/remote-ui/action-execution.d.ts +3 -0
  32. package/dist/lib/remote-ui/action-execution.d.ts.map +1 -1
  33. package/dist/lib/remote-ui/action-execution.js +21 -1
  34. package/dist/lib/remote-ui/action-execution.js.map +1 -1
  35. package/dist/lib/remote-ui/app-source.js +168 -151
  36. package/dist/lib/remote-ui/app-source.js.map +1 -1
  37. package/dist/lib/remote-ui/bridge.d.ts.map +1 -1
  38. package/dist/lib/remote-ui/components/forms.d.ts +5 -2
  39. package/dist/lib/remote-ui/components/forms.d.ts.map +1 -1
  40. package/dist/lib/remote-ui/components/forms.js +4 -4
  41. package/dist/lib/remote-ui/components/forms.js.map +1 -1
  42. package/dist/lib/remote-ui/components/layout.d.ts.map +1 -1
  43. package/dist/lib/remote-ui/components/layout.js +31 -4
  44. package/dist/lib/remote-ui/components/layout.js.map +1 -1
  45. package/dist/lib/remote-ui/components/task-status.js +2 -2
  46. package/dist/lib/remote-ui/components/task-status.js.map +1 -1
  47. package/dist/lib/remote-ui/contract-import.d.ts +1 -0
  48. package/dist/lib/remote-ui/contract-import.d.ts.map +1 -1
  49. package/dist/lib/remote-ui/contract-import.js.map +1 -1
  50. package/dist/lib/remote-ui/contracts.d.ts +11 -0
  51. package/dist/lib/remote-ui/contracts.d.ts.map +1 -1
  52. package/dist/lib/remote-ui/import-task-status.d.ts +5 -2
  53. package/dist/lib/remote-ui/import-task-status.d.ts.map +1 -1
  54. package/dist/lib/remote-ui/import-task-status.js +13 -8
  55. package/dist/lib/remote-ui/import-task-status.js.map +1 -1
  56. package/dist/lib/remote-ui/pages/analytics-detail-list.d.ts.map +1 -1
  57. package/dist/lib/remote-ui/pages/analytics-detail-list.js.map +1 -1
  58. package/dist/lib/remote-ui/pages/catalog-import-history.d.ts +47 -2
  59. package/dist/lib/remote-ui/pages/catalog-import-history.d.ts.map +1 -1
  60. package/dist/lib/remote-ui/pages/catalog-import-history.js +128 -8
  61. package/dist/lib/remote-ui/pages/catalog-import-history.js.map +1 -1
  62. package/dist/lib/remote-ui/pages/catalogs.css +55 -1
  63. package/dist/lib/remote-ui/pages/catalogs.d.ts +7 -4
  64. package/dist/lib/remote-ui/pages/catalogs.d.ts.map +1 -1
  65. package/dist/lib/remote-ui/pages/catalogs.js +40 -19
  66. package/dist/lib/remote-ui/pages/catalogs.js.map +1 -1
  67. package/dist/lib/remote-ui/pages/contracts.d.ts.map +1 -1
  68. package/dist/lib/remote-ui/pages/contracts.js +0 -16
  69. package/dist/lib/remote-ui/pages/contracts.js.map +1 -1
  70. package/dist/lib/remote-ui/pages/files-prototype.css +2 -2
  71. package/dist/lib/remote-ui/pages/files.d.ts +1 -1
  72. package/dist/lib/remote-ui/pages/files.d.ts.map +1 -1
  73. package/dist/lib/remote-ui/pages/files.js +6 -6
  74. package/dist/lib/remote-ui/pages/files.js.map +1 -1
  75. package/dist/lib/remote-ui/pages/home.d.ts.map +1 -1
  76. package/dist/lib/remote-ui/pages/home.js +2 -2
  77. package/dist/lib/remote-ui/pages/home.js.map +1 -1
  78. package/dist/lib/remote-ui/pages/procurement.d.ts +6 -1
  79. package/dist/lib/remote-ui/pages/procurement.d.ts.map +1 -1
  80. package/dist/lib/remote-ui/pages/procurement.js +77 -16
  81. package/dist/lib/remote-ui/pages/procurement.js.map +1 -1
  82. package/dist/lib/remote-ui/procurement-contracts.d.ts +7 -1
  83. package/dist/lib/remote-ui/procurement-contracts.d.ts.map +1 -1
  84. package/dist/lib/remote-ui/procurement-contracts.js.map +1 -1
  85. package/dist/lib/remote-ui/sidebar-state.d.ts +1 -0
  86. package/dist/lib/remote-ui/sidebar-state.d.ts.map +1 -1
  87. package/dist/lib/remote-ui/sidebar-state.js +3 -0
  88. package/dist/lib/remote-ui/sidebar-state.js.map +1 -1
  89. package/dist/lib/sales-file.service.js +1 -1
  90. package/dist/lib/sales-file.service.js.map +1 -1
  91. package/dist/lib/schema-maintenance.d.ts.map +1 -1
  92. package/dist/lib/schema-maintenance.js +125 -0
  93. package/dist/lib/schema-maintenance.js.map +1 -1
  94. package/dist/lib/types.d.ts +3 -0
  95. package/dist/lib/types.d.ts.map +1 -1
  96. package/dist/lib/types.js +1 -0
  97. package/dist/lib/types.js.map +1 -1
  98. package/dist/lib/workbench-action-contract.d.ts +1 -1
  99. package/dist/lib/workbench-action-contract.d.ts.map +1 -1
  100. package/dist/lib/workbench-action-contract.js +3 -1
  101. package/dist/lib/workbench-action-contract.js.map +1 -1
  102. package/dist/lib/workbench-view.provider.d.ts +12 -9
  103. package/dist/lib/workbench-view.provider.d.ts.map +1 -1
  104. package/dist/lib/workbench-view.provider.js +38 -6
  105. package/dist/lib/workbench-view.provider.js.map +1 -1
  106. package/dist/lib/workbench.middleware.js +7 -7
  107. package/dist/lib/workbench.middleware.js.map +1 -1
  108. package/dist/lib/workbench.service.d.ts +31 -3
  109. package/dist/lib/workbench.service.d.ts.map +1 -1
  110. package/dist/lib/workbench.service.js +756 -145
  111. package/dist/lib/workbench.service.js.map +1 -1
  112. package/package.json +2 -1
@@ -9,10 +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
14
  import { CATALOG_REVIEW_BATCH_LIMIT } from './constants.js';
15
- import { CatalogTypes, ClauseTypes, CustomerProfitRateStatuses, DomainError, DomainErrorCodes, RefundValidationMethods, SalesPaymentStatuses, SanctionHitStatuses } from './types.js';
15
+ import { CatalogTypes, ClauseTypes, CustomerProfitRateStatuses, DomainError, DomainErrorCodes, RefundValidationMethods, SalesPaymentStatuses, SanctionHitStatuses, StalledCatalogImportDecisions } from './types.js';
16
16
  import { deriveSalesLine, assertReceivedAmount } from './domain/finance.js';
17
17
  import { parseCustomerContractNo, parsePurchaseContractNo } from './domain/contract-number.js';
18
18
  import { isProductNameMatch, normalizeExactName, normalizeForProductMatch, normalizeHsCode, normalizeSanctionSource } from './domain/normalization.js';
@@ -24,7 +24,7 @@ import { TradeComplianceFileStorage } from './adapters/file-storage.js';
24
24
  import { TranslationProvider } from './adapters/translation.provider.js';
25
25
  import { parseBankAccountType, parseExchangeRateInput, parseExpectedRevision, validateBankSettingsInput, validateProfitSettingsInput, validatePurchaseLineDiscriminators, validateTaxRates } from './runtime-validation.js';
26
26
  import { publicCustomerContractReanalysisDto, publicImportTaskDto } from './public-dto.js';
27
- import { classifyCandidate } from './catalog-import/confidence.js';
27
+ import { classifyCandidate, normalizeCatalogCandidate } from './catalog-import/confidence.js';
28
28
  import { catalogTerminalOutcome } from './catalog-import/outcome.js';
29
29
  import { analyticsBusinessDetailFromRaw, analyticsDashboardFromRaw, analyticsDashboardStatement, analyticsDetailStatement, analyticsDetailTotalFromRaw, analyticsExportComplianceFromRaw, analyticsExportCustomersFromRaw, analyticsExportFundRisksFromRaw, analyticsExportSalesFileReviewsFromRaw, analyticsExportStatements, analyticsExportSuppliersFromRaw, resolveAnalyticsPeriod } from './analytics-query.js';
30
30
  import { analyticsExportMaxRows, renderAnalyticsExportWorkbook } from './analytics-export.js';
@@ -210,17 +210,18 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
210
210
  return { ...transactionResult, sourceFileRemoved };
211
211
  }
212
212
  async deleteImportTaskTransientRows(manager, scope, task) {
213
+ await this.failCatalogDraft(manager, scope, task);
213
214
  await manager.getRepository(CatalogReviewBlock).delete({ ...activeScope(scope), importTaskId: task.id });
214
215
  await this.deleteEmptyImportCatalogBatch(manager, scope, task.id, ControlledCatalogBatch, ControlledGoodsRecord, ControlledCatalogImportIssue);
215
216
  await this.deleteEmptyImportCatalogBatch(manager, scope, task.id, SanctionCatalogBatch, SanctionedCompanyRecord, SanctionCatalogImportIssue);
216
217
  }
217
218
  async deleteEmptyImportCatalogBatch(manager, scope, importTaskId, batchEntity, recordEntity, issueEntity) {
218
219
  const batchRepo = manager.getRepository(batchEntity);
219
- const batch = await batchRepo.findOne({ where: { ...activeScope(scope), importTaskId } });
220
+ const batch = await batchRepo.findOne({ where: { ...activeScope(scope), importTaskId }, withDeleted: true });
220
221
  if (!batch)
221
222
  return;
222
223
  const committedRows = await manager.getRepository(recordEntity).count({
223
- where: { ...activeScope(scope), batchId: batch.id }
224
+ where: { ...activeScope(scope), batchId: batch.id }, withDeleted: true
224
225
  });
225
226
  if (committedRows > 0)
226
227
  return;
@@ -231,8 +232,8 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
231
232
  const where = { ...activeScope(scope), sourceFileId: task.sourceFileId };
232
233
  const [otherTasks, controlledBatches, sanctionBatches, contracts, orders] = await Promise.all([
233
234
  manager.getRepository(ImportTask).count({ where: { ...where, id: Not(task.id) } }),
234
- manager.getRepository(ControlledCatalogBatch).count({ where }),
235
- manager.getRepository(SanctionCatalogBatch).count({ where }),
235
+ manager.getRepository(ControlledCatalogBatch).count({ where, withDeleted: true }),
236
+ manager.getRepository(SanctionCatalogBatch).count({ where, withDeleted: true }),
236
237
  manager.getRepository(CustomerContract).count({ where }),
237
238
  manager.getRepository(PurchaseOrder).count({ where })
238
239
  ]);
@@ -291,23 +292,27 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
291
292
  return { run: publicCustomerContractReanalysisDto(run), consumed };
292
293
  });
293
294
  }
294
- async listControlledBatches(scope, query = {}) { return this.pagedQuery(ControlledCatalogBatch, scope, query, ['sourceFileName'], { catalogType: query.parameters?.catalogType }); }
295
- 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' }); }
296
297
  async listControlledBatchHistory(scope, query = {}) { return this.pagedBatchHistoryQuery(ControlledCatalogBatch, scope, query, { catalogType: query.parameters?.catalogType }); }
297
298
  async listSanctionBatchHistory(scope, query = {}) { return this.pagedBatchHistoryQuery(SanctionCatalogBatch, scope, query); }
298
299
  async saveControlledGoods(scope, input) {
299
- const repo = this.dataSource.getRepository(ControlledGoodsRecord);
300
- const productName = input.productName.trim();
301
- const normalizedProductName = productName.trim();
302
- const normalizedHsCode = String(input.hsCode ?? '').replace(/\s/g, '');
303
- const duplicate = await repo.findOne({ where: { ...activeScope(scope), catalogType: input.catalogType, normalizedProductName, normalizedHsCode, deletedAt: IsNull() } });
304
- if (duplicate)
305
- throw new DomainError(DomainErrorCodes.DUPLICATE_CATALOG_RECORD, '相同目录类型、商品名称和海关编码的记录已存在', { id: duplicate.id });
306
- 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);
307
312
  }
308
313
  async updateControlledGoods(scope, id, input) {
309
314
  const repo = this.dataSource.getRepository(ControlledGoodsRecord);
310
- 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() } }), '管控商品不存在');
311
316
  Object.assign(item, pick(input, ['catalogType', 'productName', 'hsCode', 'controlDescription']));
312
317
  item.normalizedProductName = item.productName.trim();
313
318
  item.normalizedHsCode = String(item.hsCode ?? '').replace(/\s/g, '');
@@ -450,6 +455,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
450
455
  }
451
456
  async persistControlledCatalog(scope, input, localizedRows, claimId) {
452
457
  return this.dataSource.transaction(async (manager) => {
458
+ await lockCatalogMutation(manager, scope, this.dataSource.options?.type === 'postgres');
453
459
  const batchRepo = manager.getRepository(ControlledCatalogBatch);
454
460
  const recordRepo = manager.getRepository(ControlledGoodsRecord);
455
461
  const taskRepo = manager.getRepository(ImportTask);
@@ -463,7 +469,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
463
469
  await taskRepo.save(task);
464
470
  const now = new Date();
465
471
  if (input.replaceBatchId) {
466
- 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() } }), '被覆盖目录批次不存在');
467
473
  old.deletedAt = now;
468
474
  old.deletedById = scope.userId ?? null;
469
475
  old.deleteOperation = 'CATALOG_REPLACE';
@@ -471,7 +477,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
471
477
  await batchRepo.save(old);
472
478
  await recordRepo.update({ ...activeScope(scope), batchId: old.id, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'CATALOG_REPLACE' });
473
479
  }
474
- 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 }));
475
481
  await this.appendControlledCatalogRows(manager, scope, batch, input.rows, localizedRows);
476
482
  await batchRepo.save(batch);
477
483
  task.status = 'SUCCEEDED';
@@ -485,6 +491,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
485
491
  }
486
492
  async appendCompletedControlledCatalog(scope, input, localizedRows, expectedBatchId) {
487
493
  return this.dataSource.transaction(async (manager) => {
494
+ await lockCatalogMutation(manager, scope, this.dataSource.options?.type === 'postgres');
488
495
  const task = await required(manager.getRepository(ImportTask).findOne({
489
496
  where: { ...activeScope(scope), id: input.importTaskId },
490
497
  lock: { mode: 'pessimistic_write' }
@@ -537,9 +544,11 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
537
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 }));
538
545
  continue;
539
546
  }
540
- const duplicate = await recordRepo.findOne({ where: { ...activeScope(scope), catalogType: batch.catalogType, normalizedProductName: productName, normalizedHsCode, deletedAt: IsNull() } });
541
- if (duplicate?.batchId === batch.id)
542
- 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
+ ] });
543
552
  if (duplicate && await this.catalogIssueExists(issueRepo, scope, batch.id, 'DUPLICATE', row))
544
553
  continue;
545
554
  changed = true;
@@ -549,7 +558,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
549
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: '目录中已存在相同商品名称和海关编码' }));
550
559
  continue;
551
560
  }
552
- 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 }));
553
562
  batch.successCount += 1;
554
563
  batch.riskRecalculatedAt = null;
555
564
  }
@@ -696,20 +705,24 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
696
705
  return { fileName: `${batch.sourceFileName}-导入问题明细.xlsx`, buffer: await renderImportIssuesWorkbook(issues) };
697
706
  }
698
707
  async saveSanctionedCompany(scope, input) {
699
- const repo = this.dataSource.getRepository(SanctionedCompanyRecord);
700
- const companyName = input.companyName.trim();
701
- const normalizedCompanyName = normalizeExactName(companyName);
702
- const normalizedSanctionListSource = normalizeSanctionSource(input.sanctionListSource);
703
- const duplicate = await repo.findOne({ where: { ...activeScope(scope), normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull() } });
704
- if (duplicate)
705
- throw new DomainError(DomainErrorCodes.DUPLICATE_CATALOG_RECORD, '相同公司名称和制裁名单来源的记录已存在', { id: duplicate.id });
706
- 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));
707
720
  await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
708
721
  return sanctionedCompanyDto(saved);
709
722
  }
710
723
  async updateSanctionedCompany(scope, id, input) {
711
724
  const repo = this.dataSource.getRepository(SanctionedCompanyRecord);
712
- 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() } }), '制裁公司不存在');
713
726
  Object.assign(item, pick(input, ['companyName', 'countryOrRegion', 'sanctionListSource', 'sanctionReason', 'effectiveDate', 'remarks']));
714
727
  item.normalizedCompanyName = normalizeExactName(item.companyName);
715
728
  item.normalizedSanctionListSource = normalizeSanctionSource(item.sanctionListSource);
@@ -720,6 +733,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
720
733
  }
721
734
  async importSanctionCatalog(scope, input) {
722
735
  const batch = await this.dataSource.transaction(async (manager) => {
736
+ await lockCatalogMutation(manager, scope, this.dataSource.options?.type === 'postgres');
723
737
  const batchRepo = manager.getRepository(SanctionCatalogBatch);
724
738
  const recordRepo = manager.getRepository(SanctionedCompanyRecord);
725
739
  const issueRepo = manager.getRepository(SanctionCatalogImportIssue);
@@ -736,7 +750,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
736
750
  await taskRepo.save(task);
737
751
  const now = new Date();
738
752
  if (input.replaceBatchId) {
739
- 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() } }), '被覆盖制裁批次不存在');
740
754
  old.deletedAt = now;
741
755
  old.deletedById = scope.userId ?? null;
742
756
  old.deleteOperation = 'SANCTION_CATALOG_REPLACE';
@@ -744,7 +758,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
744
758
  await batchRepo.save(old);
745
759
  await recordRepo.update({ ...activeScope(scope), batchId: old.id, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SANCTION_CATALOG_REPLACE' });
746
760
  }
747
- 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 }));
748
762
  }
749
763
  let changed = false;
750
764
  for (let index = 0; index < input.rows.length; index += 1) {
@@ -761,9 +775,11 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
761
775
  }
762
776
  const normalizedCompanyName = normalizeExactName(companyName);
763
777
  const normalizedSanctionListSource = normalizeSanctionSource(row.sanctionListSource);
764
- const duplicate = await recordRepo.findOne({ where: { ...activeScope(scope), normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull() } });
765
- if (duplicate?.batchId === result.id)
766
- 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
+ ] });
767
783
  if (duplicate && await this.catalogIssueExists(issueRepo, scope, result.id, 'DUPLICATE', row))
768
784
  continue;
769
785
  changed = true;
@@ -773,9 +789,11 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
773
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: '制裁目录中已存在相同公司和名单来源' }));
774
790
  continue;
775
791
  }
776
- 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 }));
777
793
  result.successCount += 1;
778
794
  }
795
+ if (changed)
796
+ result.riskRecalculatedAt = null;
779
797
  const saved = !appending || changed ? await batchRepo.save(result) : result;
780
798
  if (!appending) {
781
799
  task.status = 'SUCCEEDED';
@@ -785,7 +803,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
785
803
  }
786
804
  return saved;
787
805
  });
788
- await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
806
+ await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED', batch.id);
789
807
  return batch;
790
808
  }
791
809
  async beginFastCatalogImport(scope, input) {
@@ -802,25 +820,17 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
802
820
  return { committedCursor: task.progress.committedCursor, batchId: task.resultEntityId };
803
821
  }
804
822
  assertImportTaskResultWritable(task);
805
- const now = new Date();
806
823
  let batchId;
807
824
  if (task.documentType === 'CONTROLLED_CATALOG') {
808
825
  const catalogType = input.catalogType;
809
826
  if (!catalogType)
810
827
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '管控目录缺少目录类型');
811
828
  const batchRepo = manager.getRepository(ControlledCatalogBatch);
812
- const recordRepo = manager.getRepository(ControlledGoodsRecord);
813
829
  if (input.replaceBatchId) {
814
- const old = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, deletedAt: IsNull() } }), '被覆盖目录批次不存在');
815
- old.deletedAt = now;
816
- old.deletedById = scope.userId ?? null;
817
- old.deleteOperation = 'CATALOG_REPLACE';
818
- old.updatedById = scope.userId ?? null;
819
- await batchRepo.save(old);
820
- 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() } }), '被覆盖目录批次不存在');
821
831
  }
822
832
  const batch = await batchRepo.save(batchRepo.create({
823
- ...auditScope(scope), catalogType, sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName,
833
+ ...auditScope(scope), lifecycleStatus: 'DRAFT', catalogType, sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName,
824
834
  importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null,
825
835
  totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0, skippedCount: 0,
826
836
  inferenceMetadata: input.inferenceMetadata ?? null,
@@ -830,21 +840,14 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
830
840
  }
831
841
  else {
832
842
  const batchRepo = manager.getRepository(SanctionCatalogBatch);
833
- const recordRepo = manager.getRepository(SanctionedCompanyRecord);
834
843
  if (input.replaceBatchId) {
835
- const old = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, deletedAt: IsNull() } }), '被覆盖制裁目录批次不存在');
836
- old.deletedAt = now;
837
- old.deletedById = scope.userId ?? null;
838
- old.deleteOperation = 'SANCTION_CATALOG_REPLACE';
839
- old.updatedById = scope.userId ?? null;
840
- await batchRepo.save(old);
841
- 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() } }), '被覆盖制裁目录批次不存在');
842
845
  }
843
846
  const batch = await batchRepo.save(batchRepo.create({
844
- ...auditScope(scope), sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName,
847
+ ...auditScope(scope), lifecycleStatus: 'DRAFT', sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName,
845
848
  importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null,
846
849
  totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0, skippedCount: 0,
847
- inferenceMetadata: input.inferenceMetadata ?? null, importTaskId: task.id
850
+ inferenceMetadata: input.inferenceMetadata ?? null, importTaskId: task.id, riskRecalculatedAt: null
848
851
  }));
849
852
  batchId = batch.id;
850
853
  }
@@ -857,43 +860,47 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
857
860
  });
858
861
  }
859
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;
860
875
  const controlledRows = input.candidates.flatMap(candidate => candidate.kind === 'CONTROLLED'
861
876
  ? candidateToControlledRows(candidate)
862
877
  : []);
863
878
  const localizedRows = controlledRows.length ? await this.localizeControlledCatalogRows(controlledRows, scope.organizationId) : [];
864
879
  return this.dataSource.transaction(async (manager) => {
880
+ const { task, controlledBatch, sanctionBatch } = await this.lockFastCatalogAppend(manager, scope, input.taskId);
865
881
  const taskRepo = manager.getRepository(ImportTask);
866
- const task = await required(taskRepo.findOne({
867
- where: { ...activeScope(scope), id: input.taskId },
868
- lock: { mode: 'pessimistic_write' }
869
- }), '解析任务不存在');
870
- if (!task.progress || task.progress.mode !== 'FAST_PIPELINE' || !task.resultEntityId) {
871
- throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入尚未初始化');
872
- }
873
882
  if (task.progress.committedCursor >= input.endCursor && input.endCursor > input.startCursor)
874
883
  return task.progress;
875
884
  if (task.progress.committedCursor !== input.startCursor) {
876
885
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入游标不一致');
877
886
  }
878
887
  if (task.documentType === 'CONTROLLED_CATALOG') {
879
- const batchRepo = manager.getRepository(ControlledCatalogBatch);
880
- const batch = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId } }), '管控目录批次不存在');
881
- await this.appendControlledCatalogRows(manager, scope, batch, controlledRows, localizedRows);
882
- await this.appendCatalogSkippedSources(manager.getRepository(ControlledCatalogImportIssue), scope, batch, input.skipped);
883
- await batchRepo.save(batch);
884
- task.progress.successCount = batch.successCount;
885
- task.progress.duplicateCount = batch.duplicateCount;
886
- task.progress.invalidCount = batch.invalidCount;
887
- 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;
888
895
  }
889
896
  else {
890
- const batch = await this.appendFastSanctionCandidates(manager, scope, task.resultEntityId, input.candidates);
891
- await this.appendCatalogSkippedSources(manager.getRepository(SanctionCatalogImportIssue), scope, batch, input.skipped);
892
- await manager.getRepository(SanctionCatalogBatch).save(batch);
893
- task.progress.successCount = batch.successCount;
894
- task.progress.duplicateCount = batch.duplicateCount;
895
- task.progress.invalidCount = batch.invalidCount;
896
- 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;
897
904
  }
898
905
  let insertedReviewCount = 0;
899
906
  if (input.reviewBlocks.length) {
@@ -910,7 +917,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
910
917
  confidence: block.confidence, status: 'PENDING', ordinal: block.ordinal,
911
918
  sourceLocation: block.sourceLocation || null, sourceContent: block.sourceContent || null,
912
919
  payload: block.payload, reasons: block.reasons,
913
- leaseToken: null, leaseExpiresAt: null, resolvedAt: null
920
+ leaseToken: null, leaseExpiresAt: null, resolvedAt: null, resolutionFingerprint: null
914
921
  }));
915
922
  insertedReviewCount += 1;
916
923
  }
@@ -926,6 +933,33 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
926
933
  return task.progress;
927
934
  });
928
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
+ }
929
963
  async finalizeFastCatalogImport(scope, taskId) {
930
964
  const task = await this.dataSource.transaction(async (manager) => {
931
965
  const repo = manager.getRepository(ImportTask);
@@ -935,7 +969,12 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
935
969
  if (!current.progress || current.progress.mode !== 'FAST_PIPELINE') {
936
970
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入进度不存在');
937
971
  }
972
+ if (current.status === 'SUCCEEDED') {
973
+ await this.assertCatalogBatchPublished(manager, scope, current);
974
+ return current;
975
+ }
938
976
  if (current.progress.reviewPendingCount > 0) {
977
+ await this.markCatalogDraftReviewing(manager, scope, current);
939
978
  current.status = 'AWAITING_REVIEW';
940
979
  current.progress.stage = 'REVIEWING';
941
980
  current.progress.observerStatus = 'PENDING';
@@ -953,6 +992,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
953
992
  await this.applyImportTaskFailure(manager, scope, current, 'FAILED', outcome.errorCode, outcome.errorMessage);
954
993
  }
955
994
  else {
995
+ await this.publishCatalogDraft(manager, scope, current);
956
996
  current.status = 'SUCCEEDED';
957
997
  current.progress.stage = 'SUCCEEDED';
958
998
  current.progress.observerStatus = 'COMPLETED';
@@ -969,10 +1009,125 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
969
1009
  if (task.documentType === 'CONTROLLED_CATALOG')
970
1010
  await this.recalculateProductRisks(scope, 'CATALOG_CHANGED', task.resultEntityId ?? undefined);
971
1011
  else
972
- await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
1012
+ await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED', task.resultEntityId ?? undefined);
973
1013
  }
974
1014
  return task;
975
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
+ }
976
1131
  async waitImportProgress(scope, taskId, afterProgressVersion = 0, waitMs = 10_000) {
977
1132
  const deadline = Date.now() + Math.max(0, Math.min(waitMs, 10_000));
978
1133
  while (true) {
@@ -1002,6 +1157,13 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1002
1157
  await repo.update({
1003
1158
  ...activeScope(scope), importTaskId: taskId, status: 'LEASED', leaseExpiresAt: LessThanOrEqual(new Date())
1004
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
+ }
1005
1167
  const blocks = await repo.createQueryBuilder('block')
1006
1168
  .setLock('pessimistic_write')
1007
1169
  .setOnLocked('skip_locked')
@@ -1037,7 +1199,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1037
1199
  if (!leaseToken.trim() || items.length === 0 || items.length > CATALOG_REVIEW_BATCH_LIMIT) {
1038
1200
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '复核提交参数无效');
1039
1201
  }
1040
- return this.dataSource.transaction(async (manager) => {
1202
+ const result = await this.dataSource.transaction(async (manager) => {
1041
1203
  const taskRepo = manager.getRepository(ImportTask);
1042
1204
  const task = await required(taskRepo.findOne({
1043
1205
  where: { ...activeScope(scope), id: taskId }, lock: { mode: 'pessimistic_write' }
@@ -1045,6 +1207,30 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1045
1207
  if (!task.progress || !task.resultEntityId)
1046
1208
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录复核任务状态无效');
1047
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
+ }
1048
1234
  const blocks = await reviewRepo.find({ where: {
1049
1235
  ...activeScope(scope), importTaskId: taskId, id: In(items.map(item => item.blockId)),
1050
1236
  status: 'LEASED', leaseToken
@@ -1054,6 +1240,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1054
1240
  }
1055
1241
  const acceptedCandidates = [];
1056
1242
  const rejectedBlocks = [];
1243
+ const errors = [];
1057
1244
  let resolved = 0;
1058
1245
  let rejected = 0;
1059
1246
  for (const item of items) {
@@ -1062,22 +1249,35 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1062
1249
  block.status = 'REJECTED';
1063
1250
  rejectedBlocks.push(block);
1064
1251
  rejected += 1;
1252
+ block.resolvedAt = new Date();
1253
+ block.leaseExpiresAt = null;
1254
+ block.resolutionFingerprint = catalogReviewResolutionFingerprint(item);
1065
1255
  }
1066
1256
  else {
1067
- const payload = item.payload ? { ...block.payload, ...item.payload } : block.payload;
1068
- const candidate = reviewCandidate(task.documentType, block, payload);
1069
- const decision = classifyCandidate(candidate);
1070
- if (decision.confidence === 'LOW') {
1071
- 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;
1072
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;
1271
+ }
1272
+ const candidate = evaluation.candidate;
1073
1273
  acceptedCandidates.push(candidate);
1074
- block.payload = payload;
1274
+ block.payload = candidate.values;
1075
1275
  block.status = 'RESOLVED';
1076
1276
  resolved += 1;
1277
+ block.resolvedAt = new Date();
1278
+ block.leaseExpiresAt = null;
1279
+ block.resolutionFingerprint = resolutionFingerprint;
1077
1280
  }
1078
- block.resolvedAt = new Date();
1079
- block.leaseToken = null;
1080
- block.leaseExpiresAt = null;
1081
1281
  }
1082
1282
  if (task.documentType === 'CONTROLLED_CATALOG' && acceptedCandidates.length) {
1083
1283
  const batchRepo = manager.getRepository(ControlledCatalogBatch);
@@ -1106,7 +1306,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1106
1306
  await reviewRepo.save(blocks);
1107
1307
  task.progress.reviewResolvedCount += resolved;
1108
1308
  task.progress.reviewRejectedCount += rejected;
1109
- task.progress.reviewPendingCount = Math.max(0, task.progress.reviewPendingCount - resolved - rejected);
1309
+ await this.reconcileCatalogReviewPendingCount(reviewRepo, scope, task);
1110
1310
  task.progress.progressVersion += 1;
1111
1311
  task.progress.lastProgressAt = new Date().toISOString();
1112
1312
  if (task.progress.reviewPendingCount === 0) {
@@ -1121,6 +1321,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1121
1321
  await this.applyImportTaskFailure(manager, scope, task, 'FAILED', outcome.errorCode, outcome.errorMessage);
1122
1322
  }
1123
1323
  else {
1324
+ await this.publishCatalogDraft(manager, scope, task);
1124
1325
  task.status = 'SUCCEEDED';
1125
1326
  task.progress.stage = 'SUCCEEDED';
1126
1327
  task.progress.observerStatus = 'COMPLETED';
@@ -1130,12 +1331,28 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1130
1331
  }
1131
1332
  }
1132
1333
  else {
1334
+ await this.markCatalogDraftReviewing(manager, scope, task);
1133
1335
  task.status = 'AWAITING_REVIEW';
1134
1336
  task.progress.stage = 'REVIEWING';
1135
1337
  }
1136
1338
  await taskRepo.save(task);
1137
- return { resolved, rejected, remaining: task.progress.reviewPendingCount, task: publicImportTaskDto(task) };
1339
+ return { resolved, rejected, remaining: task.progress.reviewPendingCount, errors, task: publicImportTaskDto(task) };
1138
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;
1139
1356
  }
1140
1357
  async setImportObserverConversation(scope, taskId, conversationId) {
1141
1358
  const normalizedConversationId = requiredId(conversationId, '观察会话标识');
@@ -1156,12 +1373,12 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1156
1373
  return repo.save(task);
1157
1374
  });
1158
1375
  }
1159
- async appendFastSanctionCandidates(manager, scope, batchId, candidates) {
1376
+ async appendFastSanctionCandidates(manager, scope, batchId, candidates, validatedBatch) {
1160
1377
  const batchRepo = manager.getRepository(SanctionCatalogBatch);
1161
1378
  const recordRepo = manager.getRepository(SanctionedCompanyRecord);
1162
1379
  const aliasRepo = manager.getRepository(SanctionedCompanyAlias);
1163
1380
  const issueRepo = manager.getRepository(SanctionCatalogImportIssue);
1164
- const batch = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: batchId } }), '制裁目录批次不存在');
1381
+ const batch = validatedBatch ?? await required(batchRepo.findOne({ where: { ...activeScope(scope), id: batchId } }), '制裁目录批次不存在');
1165
1382
  for (const candidate of candidates.filter(value => value.kind === 'SANCTION')) {
1166
1383
  const companyName = String(candidate.values.companyName ?? '').trim();
1167
1384
  const sourceLocation = candidate.evidence[0]?.sourceLocation ?? null;
@@ -1171,7 +1388,11 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1171
1388
  const normalizedCompanyName = normalizeExactName(companyName);
1172
1389
  const sanctionListSource = optionalString(candidate.values.sanctionListSource);
1173
1390
  const normalizedSanctionListSource = normalizeSanctionSource(sanctionListSource);
1174
- 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
+ ] });
1175
1396
  batch.totalCount += 1;
1176
1397
  if (duplicate) {
1177
1398
  batch.duplicateCount += 1;
@@ -1183,7 +1404,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1183
1404
  continue;
1184
1405
  }
1185
1406
  const record = await recordRepo.save(recordRepo.create({
1186
- ...auditScope(scope), batchId: batch.id, sourceType: 'FILE_IMPORT', companyName, normalizedCompanyName,
1407
+ ...auditScope(scope), batchId: batch.id, lifecycleStatus: batch.lifecycleStatus, sourceType: 'FILE_IMPORT', companyName, normalizedCompanyName,
1187
1408
  countryOrRegion: optionalString(candidate.values.countryOrRegion), sanctionListSource,
1188
1409
  normalizedSanctionListSource, sanctionReason: optionalString(candidate.values.sanctionReason),
1189
1410
  effectiveDate: optionalString(candidate.values.effectiveDate), remarks: optionalString(candidate.values.remarks),
@@ -1248,6 +1469,34 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1248
1469
  }
1249
1470
  return batchRepo.save(batch);
1250
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
+ }
1251
1500
  async catalogBatchCounts(manager, scope, task) {
1252
1501
  if (!task.resultEntityId)
1253
1502
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录导入批次不存在');
@@ -1265,7 +1514,224 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1265
1514
  skippedCount: batch.skippedCount ?? 0
1266
1515
  };
1267
1516
  }
1268
- 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) {
1269
1735
  if (!task.resultEntityId || (task.documentType !== 'CONTROLLED_CATALOG' && task.documentType !== 'SANCTION_CATALOG'))
1270
1736
  return;
1271
1737
  const now = new Date();
@@ -1273,11 +1739,9 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1273
1739
  const batchRepo = manager.getRepository(ControlledCatalogBatch);
1274
1740
  const recordRepo = manager.getRepository(ControlledGoodsRecord);
1275
1741
  const current = await batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId } });
1276
- if (!current?.replacedBatchId)
1277
- return;
1278
- const replaced = await batchRepo.findOne({ where: { ...activeScope(scope), id: current.replacedBatchId } });
1279
- if (!replaced || replaced.deleteOperation !== 'CATALOG_REPLACE')
1742
+ if (!current || (current.lifecycleStatus !== 'DRAFT' && current.lifecycleStatus !== 'REVIEWING' && (!allowActive || current.lifecycleStatus !== 'ACTIVE')))
1280
1743
  return;
1744
+ current.lifecycleStatus = 'FAILED';
1281
1745
  current.deletedAt = now;
1282
1746
  current.deletedById = scope.userId ?? null;
1283
1747
  current.deleteOperation = 'IMPORT_FAILED';
@@ -1285,26 +1749,16 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1285
1749
  await batchRepo.save(current);
1286
1750
  await recordRepo.update({
1287
1751
  ...activeScope(scope), batchId: current.id, deletedAt: IsNull()
1288
- }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'IMPORT_FAILED', updatedById: scope.userId ?? null });
1289
- replaced.deletedAt = null;
1290
- replaced.deletedById = null;
1291
- replaced.deleteOperation = null;
1292
- replaced.updatedById = scope.userId ?? null;
1293
- await batchRepo.save(replaced);
1294
- await recordRepo.update({
1295
- ...activeScope(scope), batchId: replaced.id, deleteOperation: 'CATALOG_REPLACE'
1296
- }, { 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 });
1297
1753
  return;
1298
1754
  }
1299
1755
  const batchRepo = manager.getRepository(SanctionCatalogBatch);
1300
1756
  const recordRepo = manager.getRepository(SanctionedCompanyRecord);
1301
1757
  const aliasRepo = manager.getRepository(SanctionedCompanyAlias);
1302
1758
  const current = await batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId } });
1303
- if (!current?.replacedBatchId)
1304
- return;
1305
- const replaced = await batchRepo.findOne({ where: { ...activeScope(scope), id: current.replacedBatchId } });
1306
- if (!replaced || replaced.deleteOperation !== 'SANCTION_CATALOG_REPLACE')
1759
+ if (!current || (current.lifecycleStatus !== 'DRAFT' && current.lifecycleStatus !== 'REVIEWING' && (!allowActive || current.lifecycleStatus !== 'ACTIVE')))
1307
1760
  return;
1761
+ current.lifecycleStatus = 'FAILED';
1308
1762
  current.deletedAt = now;
1309
1763
  current.deletedById = scope.userId ?? null;
1310
1764
  current.deleteOperation = 'IMPORT_FAILED';
@@ -1320,15 +1774,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1320
1774
  }
1321
1775
  await recordRepo.update({
1322
1776
  ...activeScope(scope), batchId: current.id, deletedAt: IsNull()
1323
- }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'IMPORT_FAILED', updatedById: scope.userId ?? null });
1324
- replaced.deletedAt = null;
1325
- replaced.deletedById = null;
1326
- replaced.deleteOperation = null;
1327
- replaced.updatedById = scope.userId ?? null;
1328
- await batchRepo.save(replaced);
1329
- await recordRepo.update({
1330
- ...activeScope(scope), batchId: replaced.id, deleteOperation: 'SANCTION_CATALOG_REPLACE'
1331
- }, { 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 });
1332
1778
  }
1333
1779
  async applyImportTaskFailure(manager, scope, task, status, errorCode, errorMessage, completedAt = new Date()) {
1334
1780
  task.status = status;
@@ -1338,7 +1784,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1338
1784
  if (task.progress?.mode === 'FAST_PIPELINE') {
1339
1785
  task.progress.stage = 'FAILED';
1340
1786
  task.progress.observerStatus = 'FAILED';
1341
- await this.rollbackFailedCatalogReplacement(manager, scope, task);
1787
+ await this.failCatalogDraft(manager, scope, task);
1342
1788
  }
1343
1789
  }
1344
1790
  async createImportTask(scope, input) {
@@ -1770,7 +2216,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1770
2216
  const lineRepo = manager.getRepository(PurchaseOrderLine);
1771
2217
  for (let index = 0; index < draft.lines.length; index += 1) {
1772
2218
  const line = draft.lines[index];
1773
- 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 }));
1774
2220
  }
1775
2221
  task.status = 'SUCCEEDED';
1776
2222
  task.resultEntityId = entity.id;
@@ -1845,7 +2291,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1845
2291
  const currentOrders = orders.filter(order => String(order.orderDate ?? '').startsWith(`${currentYear}-`));
1846
2292
  const top = new Map();
1847
2293
  for (const line of lines)
1848
- 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)));
1849
2295
  const orderBalances = {};
1850
2296
  for (const order of orders) {
1851
2297
  const orderLines = lines.filter(line => line.purchaseOrderId === order.id);
@@ -1896,6 +2342,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1896
2342
  const saved = await this.withLockedPurchaseLines(scope, existing.purchaseOrderId, async ({ lineRepo, lines, contracts, customers, manuals }) => {
1897
2343
  const item = await required(Promise.resolve(lines.find(line => line.id === id) ?? null), '采购商品不存在');
1898
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);
1899
2346
  validatePurchaseLineDiscriminators(item);
1900
2347
  const rates = validateTaxRates(item, { purchaseOrderLineId: item.id, lineNumber: item.lineNumber, productName: item.productName });
1901
2348
  item.vatRate = rates.vatRate;
@@ -1918,7 +2365,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
1918
2365
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '采购商品数量必须大于 0');
1919
2366
  const saved = await this.withLockedPurchaseLines(scope, purchaseOrderId, async ({ lineRepo, lines, contracts, customers, manuals }) => {
1920
2367
  const lineNumber = lines.reduce((maximum, line) => Math.max(maximum, line.lineNumber), 0) + 1;
1921
- 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 });
1922
2369
  this.assertLockedPurchaseReceipts([...lines, item], contracts, customers, manuals);
1923
2370
  return lineRepo.save(item);
1924
2371
  });
@@ -2527,7 +2974,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
2527
2974
  const linesByPurchase = groupBy(lines, line => line.purchaseOrderId);
2528
2975
  const riskRows = lines.length ? await this.dataSource.getRepository(ProductComplianceMatch).find({ where: { ...activeScope(scope), purchaseOrderLineId: In(lines.map(line => line.id)), deletedAt: IsNull() } }) : [];
2529
2976
  const controlledGoodsIds = [...new Set(riskRows.map(match => match.controlledGoodsId))];
2530
- 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() } }) : [];
2531
2978
  const controlledGoodsById = new Map(controlledGoods.map(item => [item.id, item]));
2532
2979
  const risksByLine = groupBy(riskRows, match => match.purchaseOrderLineId);
2533
2980
  const purchasesByBaseCode = groupBy(purchases, purchase => parsePurchaseContractNo(purchase.contractNo).baseCode);
@@ -2718,7 +3165,8 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
2718
3165
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '供应商存在有效采购订单,不能删除', { orderCount: count });
2719
3166
  }
2720
3167
  const repo = manager.getRepository(entity);
2721
- 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' } }), '记录不存在');
2722
3170
  const now = new Date();
2723
3171
  target.deletedAt = now;
2724
3172
  target.deletedById = scope.userId ?? null;
@@ -3094,7 +3542,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
3094
3542
  });
3095
3543
  await matchRepo.update({ ...activeScope(scope), deletedAt: IsNull() }, { deletedAt: now, invalidationReason: reason });
3096
3544
  const goods = await manager.getRepository(ControlledGoodsRecord).find({
3097
- where: { ...activeScope(scope), deletedAt: IsNull() },
3545
+ where: { ...activeScope(scope), lifecycleStatus: 'ACTIVE', deletedAt: IsNull() },
3098
3546
  order: { id: 'ASC' },
3099
3547
  lock: { mode: 'pessimistic_read' }
3100
3548
  });
@@ -3118,17 +3566,28 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
3118
3566
  return { scanned: lines.length, matches };
3119
3567
  });
3120
3568
  }
3121
- async recalculateCompanyRisks(scope, reason) {
3569
+ async recalculateCompanyRisks(scope, reason, sanctionBatchId) {
3122
3570
  return this.dataSource.transaction(async (manager) => {
3123
3571
  const repo = manager.getRepository(CompanySanctionMatch);
3124
3572
  const supplierRepo = manager.getRepository(Supplier);
3125
3573
  const customerRepo = manager.getRepository(Customer);
3126
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
+ }
3127
3586
  await repo.update({ ...activeScope(scope), deletedAt: IsNull() }, { deletedAt: now, invalidationReason: reason });
3128
3587
  const [suppliers, customers, sanctions, aliases] = await Promise.all([
3129
3588
  supplierRepo.find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
3130
3589
  customerRepo.find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
3131
- manager.getRepository(SanctionedCompanyRecord).find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
3590
+ manager.getRepository(SanctionedCompanyRecord).find({ where: { ...activeScope(scope), lifecycleStatus: 'ACTIVE', deletedAt: IsNull() } }),
3132
3591
  manager.getRepository(SanctionedCompanyAlias).find({ where: { ...activeScope(scope), deletedAt: IsNull() } })
3133
3592
  ]);
3134
3593
  const sanctionsById = new Map(sanctions.map(item => [item.id, item]));
@@ -3150,7 +3609,14 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
3150
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 }));
3151
3610
  await customerRepo.update({ ...activeScope(scope), id: customer.id, deletedAt: IsNull() }, { complianceCheckStatus: 'CHECKED', complianceCheckedAt: now, updatedById: scope.userId ?? null, updatedAt: now });
3152
3611
  }
3153
- 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 };
3154
3620
  });
3155
3621
  }
3156
3622
  async pagedQuery(entity, scope, query, searchFields, exactFilters = {}, filters = {}) {
@@ -3189,7 +3655,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
3189
3655
  if (catalogBatchId)
3190
3656
  qb.andWhere('row.batchId = :catalogBatchId', { catalogBatchId });
3191
3657
  else
3192
- qb.andWhere('row.deletedAt IS NULL');
3658
+ qb.andWhere('row.deletedAt IS NULL').andWhere("row.lifecycleStatus = 'ACTIVE'");
3193
3659
  for (const [key, value] of Object.entries(filters.exact ?? {}))
3194
3660
  if (hasQueryValue(value))
3195
3661
  qb.andWhere(`row.${key} = :exact_${key}`, { [`exact_${key}`]: value });
@@ -3459,6 +3925,14 @@ function handoffCallbackIdentityMatches(input, expectedSourceMessageId, persiste
3459
3925
  async function lockOrganizationTaskClaim(manager, scope) {
3460
3926
  await manager.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [scope.tenantId, scope.organizationId]);
3461
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
+ }
3462
3936
  async function organizationHasActiveParsing(manager, scope) {
3463
3937
  const activeImport = await manager.getRepository(ImportTask).findOne({
3464
3938
  where: { ...activeScope(scope), status: In([...RUNNING_IMPORT_TASK_STATUSES]), deletedAt: IsNull() },
@@ -3676,10 +4150,6 @@ function assertOptionalEnum(value, allowed, label) { if (hasQueryValue(value) &&
3676
4150
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, `${label}无效`); }
3677
4151
  function fuzzyValue(value, filter) { return !hasQueryValue(filter) || String(value ?? '').toLocaleLowerCase().includes(String(filter).trim().toLocaleLowerCase()); }
3678
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 }; }
3679
- function filterSalesOrders(rows, parameters, includeParties = false) {
3680
- const date = parameters.orderDate && typeof parameters.orderDate === 'object' && !Array.isArray(parameters.orderDate) ? parameters.orderDate : {};
3681
- 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)));
3682
- }
3683
4153
  function salesMetrics(rows) {
3684
4154
  const currentYear = String(new Date().getFullYear());
3685
4155
  const currentYearRows = rows.filter(row => String(row.orderDate ?? '').slice(0, 4) === currentYear);
@@ -3785,7 +4255,7 @@ function assertPurchaseDraft(draft) {
3785
4255
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, `${prefix}名称为必填项`);
3786
4256
  const quantity = optionalDecimalInput(line.quantity, `${prefix}数量必须为有效数值`);
3787
4257
  const unitPrice = optionalDecimalInput(line.unitPriceTaxIncluded, `${prefix}含税单价必须为有效金额`);
3788
- const lineAmount = optionalDecimalInput(line.lineAmountTaxIncluded, `${prefix}含税金额必须为有效金额`);
4258
+ const lineAmount = optionalDecimalInput(purchaseLineAmountText(line), `${prefix}含税金额必须为有效金额`);
3789
4259
  validateOptionalTaxRate(line.vatRate, 'vatRate', '增值税率', { lineNumber: index + 1, productName: line.productName });
3790
4260
  const refundRate = validateOptionalTaxRate(line.refundRate, 'refundRate', '退税率', { lineNumber: index + 1, productName: line.productName });
3791
4261
  if (hasQueryValue(line.refundPolicyDate) && hasQueryValue(draft.order.orderDate) && String(line.refundPolicyDate) > String(draft.order.orderDate))
@@ -3828,6 +4298,21 @@ function optionalDecimalInput(value, message) {
3828
4298
  function defaultDecimalText(value) {
3829
4299
  return optionalDecimalInput(value, '数值必须是有限金额')?.toString() ?? '0';
3830
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
+ }
3831
4316
  function validateOptionalTaxRate(value, field, label, context) {
3832
4317
  if (!hasQueryValue(value))
3833
4318
  return null;
@@ -3930,6 +4415,133 @@ function reviewCandidate(documentType, block, payload) {
3930
4415
  warnings: block.reasons
3931
4416
  };
3932
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
+ }
3933
4545
  function appendMapValue(map, key, value) {
3934
4546
  const values = map.get(key);
3935
4547
  if (values) {
@@ -3954,7 +4566,6 @@ function assertCompleteClauseAnalyses(analyses) {
3954
4566
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析结果必须完整包含七类关键条款');
3955
4567
  }
3956
4568
  function decimalSum(rows, read) { return rows.reduce((value, row) => value.plus(read(row)), new Decimal(0)).toNumber(); }
3957
- function fuzzyMatches(value, filter) { return !filter || String(value ?? '').trim().toLocaleLowerCase().includes(filter.trim().toLocaleLowerCase()); }
3958
4569
  function shanghaiCurrentYear(now = new Date()) {
3959
4570
  return Number(new Intl.DateTimeFormat('en-US', { timeZone: 'Asia/Shanghai', year: 'numeric' }).format(now));
3960
4571
  }