@chenchaolong/plugin-trade-compliance-workbench 1.0.10 → 1.0.12

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 (90) hide show
  1. package/README.md +10 -0
  2. package/dist/lib/catalog-import/catalog-document.parser.d.ts +18 -0
  3. package/dist/lib/catalog-import/catalog-document.parser.d.ts.map +1 -0
  4. package/dist/lib/catalog-import/catalog-document.parser.js +36 -0
  5. package/dist/lib/catalog-import/catalog-document.parser.js.map +1 -0
  6. package/dist/lib/catalog-import/catalog-import.pipeline.d.ts +30 -0
  7. package/dist/lib/catalog-import/catalog-import.pipeline.d.ts.map +1 -0
  8. package/dist/lib/catalog-import/catalog-import.pipeline.js +54 -0
  9. package/dist/lib/catalog-import/catalog-import.pipeline.js.map +1 -0
  10. package/dist/lib/catalog-import/confidence.d.ts +3 -0
  11. package/dist/lib/catalog-import/confidence.d.ts.map +1 -0
  12. package/dist/lib/catalog-import/confidence.js +52 -0
  13. package/dist/lib/catalog-import/confidence.js.map +1 -0
  14. package/dist/lib/catalog-import/contracts.d.ts +50 -0
  15. package/dist/lib/catalog-import/contracts.d.ts.map +1 -0
  16. package/dist/lib/catalog-import/contracts.js +2 -0
  17. package/dist/lib/catalog-import/contracts.js.map +1 -0
  18. package/dist/lib/catalog-import/docx.parser.d.ts +6 -0
  19. package/dist/lib/catalog-import/docx.parser.d.ts.map +1 -0
  20. package/dist/lib/catalog-import/docx.parser.js +130 -0
  21. package/dist/lib/catalog-import/docx.parser.js.map +1 -0
  22. package/dist/lib/catalog-import/header-mapping.d.ts +4 -0
  23. package/dist/lib/catalog-import/header-mapping.d.ts.map +1 -0
  24. package/dist/lib/catalog-import/header-mapping.js +34 -0
  25. package/dist/lib/catalog-import/header-mapping.js.map +1 -0
  26. package/dist/lib/catalog-import/pdf-layout.parser.d.ts +15 -0
  27. package/dist/lib/catalog-import/pdf-layout.parser.d.ts.map +1 -0
  28. package/dist/lib/catalog-import/pdf-layout.parser.js +133 -0
  29. package/dist/lib/catalog-import/pdf-layout.parser.js.map +1 -0
  30. package/dist/lib/catalog-import/pdf-table.normalizer.d.ts +4 -0
  31. package/dist/lib/catalog-import/pdf-table.normalizer.d.ts.map +1 -0
  32. package/dist/lib/catalog-import/pdf-table.normalizer.js +22 -0
  33. package/dist/lib/catalog-import/pdf-table.normalizer.js.map +1 -0
  34. package/dist/lib/catalog-import/spreadsheet.parser.d.ts +7 -0
  35. package/dist/lib/catalog-import/spreadsheet.parser.d.ts.map +1 -0
  36. package/dist/lib/catalog-import/spreadsheet.parser.js +118 -0
  37. package/dist/lib/catalog-import/spreadsheet.parser.js.map +1 -0
  38. package/dist/lib/constants.d.ts +3 -0
  39. package/dist/lib/constants.d.ts.map +1 -1
  40. package/dist/lib/constants.js +3 -0
  41. package/dist/lib/constants.js.map +1 -1
  42. package/dist/lib/entities/catalog.entity.d.ts +25 -1
  43. package/dist/lib/entities/catalog.entity.d.ts.map +1 -1
  44. package/dist/lib/entities/catalog.entity.js +94 -0
  45. package/dist/lib/entities/catalog.entity.js.map +1 -1
  46. package/dist/lib/entities/index.d.ts +2 -2
  47. package/dist/lib/entities/index.d.ts.map +1 -1
  48. package/dist/lib/entities/index.js +3 -2
  49. package/dist/lib/entities/index.js.map +1 -1
  50. package/dist/lib/entities/shared.entity.d.ts +3 -1
  51. package/dist/lib/entities/shared.entity.d.ts.map +1 -1
  52. package/dist/lib/entities/shared.entity.js +8 -0
  53. package/dist/lib/entities/shared.entity.js.map +1 -1
  54. package/dist/lib/import-jobs/import-command.builder.d.ts +4 -0
  55. package/dist/lib/import-jobs/import-command.builder.d.ts.map +1 -1
  56. package/dist/lib/import-jobs/import-command.builder.js +11 -0
  57. package/dist/lib/import-jobs/import-command.builder.js.map +1 -1
  58. package/dist/lib/import-jobs/import-job-callback.processor.d.ts.map +1 -1
  59. package/dist/lib/import-jobs/import-job-callback.processor.js +22 -0
  60. package/dist/lib/import-jobs/import-job-callback.processor.js.map +1 -1
  61. package/dist/lib/import-jobs/import-job.processor.d.ts.map +1 -1
  62. package/dist/lib/import-jobs/import-job.processor.js +32 -1
  63. package/dist/lib/import-jobs/import-job.processor.js.map +1 -1
  64. package/dist/lib/public-dto.d.ts +1 -1
  65. package/dist/lib/public-dto.d.ts.map +1 -1
  66. package/dist/lib/public-dto.js +2 -0
  67. package/dist/lib/public-dto.js.map +1 -1
  68. package/dist/lib/remote-components/trade-compliance-workbench/app.js +5 -5
  69. package/dist/lib/remote-ui/app-source.js +12 -0
  70. package/dist/lib/remote-ui/app-source.js.map +1 -1
  71. package/dist/lib/remote-ui/contracts.d.ts +9 -0
  72. package/dist/lib/remote-ui/contracts.d.ts.map +1 -1
  73. package/dist/lib/runtime-validation.d.ts +1 -1
  74. package/dist/lib/types.d.ts +26 -0
  75. package/dist/lib/types.d.ts.map +1 -1
  76. package/dist/lib/types.js +3 -0
  77. package/dist/lib/types.js.map +1 -1
  78. package/dist/lib/workbench-view.provider.d.ts +7 -0
  79. package/dist/lib/workbench-view.provider.d.ts.map +1 -1
  80. package/dist/lib/workbench-view.provider.js +1 -0
  81. package/dist/lib/workbench-view.provider.js.map +1 -1
  82. package/dist/lib/workbench.middleware.d.ts +89 -6
  83. package/dist/lib/workbench.middleware.d.ts.map +1 -1
  84. package/dist/lib/workbench.middleware.js +3 -0
  85. package/dist/lib/workbench.middleware.js.map +1 -1
  86. package/dist/lib/workbench.service.d.ts +60 -1
  87. package/dist/lib/workbench.service.d.ts.map +1 -1
  88. package/dist/lib/workbench.service.js +592 -51
  89. package/dist/lib/workbench.service.js.map +1 -1
  90. package/package.json +2 -1
@@ -1,7 +1,7 @@
1
1
  import { __decorate, __metadata, __param } from "tslib";
2
2
  import { Injectable, Optional } from '@nestjs/common';
3
3
  import { InjectDataSource } from '@nestjs/typeorm';
4
- import { Brackets, DataSource, In, IsNull, Not } from 'typeorm';
4
+ import { Brackets, DataSource, In, IsNull, LessThanOrEqual, Not } from 'typeorm';
5
5
  import { Decimal } from 'decimal.js';
6
6
  import ExcelJS from 'exceljs';
7
7
  import { createWriteStream } from 'node:fs';
@@ -10,7 +10,7 @@ import { tmpdir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
  import { finished } from 'node:stream/promises';
12
12
  import { randomUUID } from 'node:crypto';
13
- import { BankAccountSettings, CompanySanctionMatch, CompanySettings, ControlledCatalogBatch, ControlledCatalogImportIssue, ControlledGoodsRecord, Customer, CustomerContract, CustomerContractAnalysisRun, CustomerContractClause, CustomerContractClauseAnalysis, DerivedSalesOrderManualData, GeneratedSalesFile, ImportTask, ManagedAsset, MonthlySafeExchangeRate, ProductComplianceMatch, ProfitRuleSettings, PurchaseOrder, PurchaseOrderLine, SanctionCatalogBatch, SanctionCatalogImportIssue, SanctionedCompanyRecord, SettingsHistory, Supplier } from './entities/index.js';
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 { CatalogTypes, ClauseTypes, CustomerProfitRateStatuses, DomainError, DomainErrorCodes, RefundValidationMethods, SalesPaymentStatuses, SanctionHitStatuses } from './types.js';
15
15
  import { deriveSalesLine, assertReceivedAmount } from './domain/finance.js';
16
16
  import { parseCustomerContractNo, parsePurchaseContractNo } from './domain/contract-number.js';
@@ -23,6 +23,7 @@ import { TradeComplianceFileStorage } from './adapters/file-storage.js';
23
23
  import { TranslationProvider } from './adapters/translation.provider.js';
24
24
  import { parseBankAccountType, parseExchangeRateInput, parseExpectedRevision, validateBankSettingsInput, validateProfitSettingsInput, validatePurchaseLineDiscriminators, validateTaxRates } from './runtime-validation.js';
25
25
  import { publicCustomerContractReanalysisDto, publicImportTaskDto } from './public-dto.js';
26
+ import { classifyCandidate } from './catalog-import/confidence.js';
26
27
  const DEFAULT_IMPORT_ISSUE_EXPORT_MAX_ROWS = 50_000;
27
28
  const HARD_IMPORT_ISSUE_EXPORT_MAX_ROWS = 100_000;
28
29
  const CUSTOMER_REANALYSIS_TIMEOUT_MS = 20 * 60 * 1000;
@@ -212,8 +213,16 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
212
213
  async importControlledCatalog(scope, input) {
213
214
  while (true) {
214
215
  const claim = await this.claimControlledCatalogProcessing(scope, input.importTaskId);
215
- if (claim.kind === 'COMPLETED')
216
- return this.completeControlledCatalogPostProcessing(scope, claim.batch);
216
+ if (claim.kind === 'COMPLETED') {
217
+ if (!input.rows.length)
218
+ return this.completeControlledCatalogPostProcessing(scope, claim.batch);
219
+ const pendingRows = await this.pendingControlledCatalogRows(scope, claim.batch, input.rows);
220
+ if (!pendingRows.length)
221
+ return this.completeControlledCatalogPostProcessing(scope, claim.batch);
222
+ const localizedRows = await this.localizeControlledCatalogRows(pendingRows);
223
+ const appended = await this.appendCompletedControlledCatalog(scope, { ...input, rows: pendingRows }, localizedRows, claim.batch.id);
224
+ return this.completeControlledCatalogPostProcessing(scope, appended);
225
+ }
217
226
  if (claim.kind === 'BUSY') {
218
227
  await this.waitForControlledCatalogProcessing(scope, input.importTaskId, claim.claimId, claim.expiresAt);
219
228
  continue;
@@ -337,7 +346,6 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
337
346
  return this.dataSource.transaction(async (manager) => {
338
347
  const batchRepo = manager.getRepository(ControlledCatalogBatch);
339
348
  const recordRepo = manager.getRepository(ControlledGoodsRecord);
340
- const issueRepo = manager.getRepository(ControlledCatalogImportIssue);
341
349
  const taskRepo = manager.getRepository(ImportTask);
342
350
  const task = await required(taskRepo.findOne({ where: { ...activeScope(scope), id: input.importTaskId }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
343
351
  if (task.status === 'SUCCEEDED' && task.resultEntityId)
@@ -357,26 +365,8 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
357
365
  await batchRepo.save(old);
358
366
  await recordRepo.update({ ...activeScope(scope), batchId: old.id, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'CATALOG_REPLACE' });
359
367
  }
360
- 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: input.rows.length, successCount: 0, duplicateCount: 0, invalidCount: 0, importTaskId: input.importTaskId, riskRecalculatedAt: null }));
361
- for (let index = 0; index < input.rows.length; index += 1) {
362
- const row = input.rows[index];
363
- const localized = localizedRows[index];
364
- const productName = localized.productName;
365
- const normalizedHsCode = String(row.hsCode ?? '').replace(/\s/g, '');
366
- if (localized.error) {
367
- batch.invalidCount += 1;
368
- await issueRepo.save(issueRepo.create({ ...createdScope(scope), batchId: batch.id, issueType: 'INVALID', sourceSequence: index + 1, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null, parsedPayload: row, message: localized.error }));
369
- continue;
370
- }
371
- const duplicate = await recordRepo.findOne({ where: { ...activeScope(scope), catalogType: input.catalogType, normalizedProductName: productName, normalizedHsCode, deletedAt: IsNull() } });
372
- if (duplicate) {
373
- batch.duplicateCount += 1;
374
- await issueRepo.save(issueRepo.create({ ...createdScope(scope), batchId: batch.id, issueType: 'DUPLICATE', sourceSequence: index + 1, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null, parsedPayload: row, message: '目录中已存在相同商品名称和海关编码' }));
375
- continue;
376
- }
377
- await recordRepo.save(recordRepo.create({ ...auditScope(scope), batchId: batch.id, sourceType: 'FILE_IMPORT', catalogType: input.catalogType, productName, normalizedProductName: productName, hsCode: row.hsCode ?? null, normalizedHsCode, controlDescription: localized.controlDescription, originalProductName: localized.originalProductName, originalControlDescription: localized.originalControlDescription, translationSource: localized.translationSource, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null }));
378
- batch.successCount += 1;
379
- }
368
+ 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 }));
369
+ await this.appendControlledCatalogRows(manager, scope, batch, input.rows, localizedRows);
380
370
  await batchRepo.save(batch);
381
371
  task.status = 'SUCCEEDED';
382
372
  task.resultEntityId = batch.id;
@@ -387,6 +377,84 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
387
377
  return batch;
388
378
  });
389
379
  }
380
+ async appendCompletedControlledCatalog(scope, input, localizedRows, expectedBatchId) {
381
+ return this.dataSource.transaction(async (manager) => {
382
+ const task = await required(manager.getRepository(ImportTask).findOne({
383
+ where: { ...activeScope(scope), id: input.importTaskId },
384
+ lock: { mode: 'pessimistic_write' }
385
+ }), '解析任务不存在');
386
+ if (task.status !== 'SUCCEEDED' || task.resultEntityId !== expectedBatchId) {
387
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '已完成目录批次已变更,无法追加解析结果');
388
+ }
389
+ const batchRepo = manager.getRepository(ControlledCatalogBatch);
390
+ const batch = await required(batchRepo.findOne({
391
+ where: { ...activeScope(scope), id: expectedBatchId, importTaskId: input.importTaskId, catalogType: input.catalogType }
392
+ }), '已完成导入批次不存在');
393
+ const changed = await this.appendControlledCatalogRows(manager, scope, batch, input.rows, localizedRows);
394
+ return changed ? batchRepo.save(batch) : batch;
395
+ });
396
+ }
397
+ async pendingControlledCatalogRows(scope, batch, rows) {
398
+ const recordRepo = this.dataSource.getRepository(ControlledGoodsRecord);
399
+ const pending = [];
400
+ for (const row of rows) {
401
+ const normalizedHsCode = String(row.hsCode ?? '').replace(/\s/g, '');
402
+ const productName = String(row.productName ?? '').trim();
403
+ const originalProductName = String(row.originalProductName ?? '').trim();
404
+ if (productName) {
405
+ pending.push(row);
406
+ continue;
407
+ }
408
+ const existing = originalProductName
409
+ ? await recordRepo.findOne({ where: { ...activeScope(scope), batchId: batch.id, originalProductName, normalizedHsCode, deletedAt: IsNull() } })
410
+ : null;
411
+ if (!existing)
412
+ pending.push(row);
413
+ }
414
+ return pending;
415
+ }
416
+ async appendControlledCatalogRows(manager, scope, batch, rows, localizedRows) {
417
+ const recordRepo = manager.getRepository(ControlledGoodsRecord);
418
+ const issueRepo = manager.getRepository(ControlledCatalogImportIssue);
419
+ let changed = false;
420
+ for (let index = 0; index < rows.length; index += 1) {
421
+ const row = rows[index];
422
+ const localized = localizedRows[index];
423
+ const productName = localized.productName;
424
+ const normalizedHsCode = String(row.hsCode ?? '').replace(/\s/g, '');
425
+ if (localized.error) {
426
+ if (await this.catalogIssueExists(issueRepo, scope, batch.id, 'INVALID', row))
427
+ continue;
428
+ changed = true;
429
+ batch.invalidCount += 1;
430
+ batch.totalCount += 1;
431
+ 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 }));
432
+ continue;
433
+ }
434
+ const duplicate = await recordRepo.findOne({ where: { ...activeScope(scope), catalogType: batch.catalogType, normalizedProductName: productName, normalizedHsCode, deletedAt: IsNull() } });
435
+ if (duplicate?.batchId === batch.id)
436
+ continue;
437
+ if (duplicate && await this.catalogIssueExists(issueRepo, scope, batch.id, 'DUPLICATE', row))
438
+ continue;
439
+ changed = true;
440
+ batch.totalCount += 1;
441
+ if (duplicate) {
442
+ batch.duplicateCount += 1;
443
+ 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: '目录中已存在相同商品名称和海关编码' }));
444
+ continue;
445
+ }
446
+ 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 }));
447
+ batch.successCount += 1;
448
+ batch.riskRecalculatedAt = null;
449
+ }
450
+ return changed;
451
+ }
452
+ async catalogIssueExists(repository, scope, batchId, issueType, row) {
453
+ const where = {
454
+ ...activeScope(scope), batchId, issueType, parsedPayload: row
455
+ };
456
+ return Boolean(await repository.findOne({ where }));
457
+ }
390
458
  async completeControlledCatalogPostProcessing(scope, batch) {
391
459
  if (batch.riskRecalculatedAt)
392
460
  return batch;
@@ -551,51 +619,426 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
551
619
  const issueRepo = manager.getRepository(SanctionCatalogImportIssue);
552
620
  const taskRepo = manager.getRepository(ImportTask);
553
621
  const task = await required(taskRepo.findOne({ where: { ...activeScope(scope), id: input.importTaskId }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
554
- if (task.status === 'SUCCEEDED' && task.resultEntityId)
555
- return required(batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId } }), '已完成导入批次不存在');
556
- assertImportTaskResultWritable(task);
557
- task.status = 'PERSISTING';
558
- await taskRepo.save(task);
559
- const now = new Date();
560
- if (input.replaceBatchId) {
561
- const old = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, deletedAt: IsNull() } }), '被覆盖制裁批次不存在');
562
- old.deletedAt = now;
563
- old.deletedById = scope.userId ?? null;
564
- old.deleteOperation = 'SANCTION_CATALOG_REPLACE';
565
- old.updatedById = scope.userId ?? null;
566
- await batchRepo.save(old);
567
- await recordRepo.update({ ...activeScope(scope), batchId: old.id, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SANCTION_CATALOG_REPLACE' });
622
+ const appending = task.status === 'SUCCEEDED' && Boolean(task.resultEntityId);
623
+ let result;
624
+ if (appending) {
625
+ result = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId, importTaskId: input.importTaskId } }), '已完成导入批次不存在');
626
+ }
627
+ else {
628
+ assertImportTaskResultWritable(task);
629
+ task.status = 'PERSISTING';
630
+ await taskRepo.save(task);
631
+ const now = new Date();
632
+ if (input.replaceBatchId) {
633
+ const old = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, deletedAt: IsNull() } }), '被覆盖制裁批次不存在');
634
+ old.deletedAt = now;
635
+ old.deletedById = scope.userId ?? null;
636
+ old.deleteOperation = 'SANCTION_CATALOG_REPLACE';
637
+ old.updatedById = scope.userId ?? null;
638
+ await batchRepo.save(old);
639
+ await recordRepo.update({ ...activeScope(scope), batchId: old.id, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SANCTION_CATALOG_REPLACE' });
640
+ }
641
+ 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 }));
568
642
  }
569
- const 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: input.rows.length, successCount: 0, duplicateCount: 0, invalidCount: 0, importTaskId: input.importTaskId }));
643
+ let changed = false;
570
644
  for (let index = 0; index < input.rows.length; index += 1) {
571
645
  const row = input.rows[index];
572
646
  const companyName = String(row.companyName ?? '').trim();
573
647
  if (!companyName) {
648
+ if (await this.catalogIssueExists(issueRepo, scope, result.id, 'INVALID', row))
649
+ continue;
650
+ changed = true;
574
651
  result.invalidCount += 1;
575
- await issueRepo.save(issueRepo.create({ ...createdScope(scope), batchId: result.id, issueType: 'INVALID', sourceSequence: index + 1, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null, parsedPayload: row, message: '公司名称为空' }));
652
+ result.totalCount += 1;
653
+ await issueRepo.save(issueRepo.create({ ...createdScope(scope), batchId: result.id, issueType: 'INVALID', sourceSequence: result.totalCount, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null, parsedPayload: row, message: '公司名称为空' }));
576
654
  continue;
577
655
  }
578
656
  const normalizedCompanyName = normalizeExactName(companyName);
579
657
  const normalizedSanctionListSource = normalizeSanctionSource(row.sanctionListSource);
580
658
  const duplicate = await recordRepo.findOne({ where: { ...activeScope(scope), normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull() } });
659
+ if (duplicate?.batchId === result.id)
660
+ continue;
661
+ if (duplicate && await this.catalogIssueExists(issueRepo, scope, result.id, 'DUPLICATE', row))
662
+ continue;
663
+ changed = true;
664
+ result.totalCount += 1;
581
665
  if (duplicate) {
582
666
  result.duplicateCount += 1;
583
- await issueRepo.save(issueRepo.create({ ...createdScope(scope), batchId: result.id, issueType: 'DUPLICATE', sourceSequence: index + 1, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null, parsedPayload: row, message: '制裁目录中已存在相同公司和名单来源' }));
667
+ 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: '制裁目录中已存在相同公司和名单来源' }));
584
668
  continue;
585
669
  }
586
670
  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 }));
587
671
  result.successCount += 1;
588
672
  }
589
- const saved = await batchRepo.save(result);
590
- task.status = 'SUCCEEDED';
591
- task.resultEntityId = saved.id;
592
- task.completedAt = new Date();
593
- await taskRepo.save(task);
673
+ const saved = !appending || changed ? await batchRepo.save(result) : result;
674
+ if (!appending) {
675
+ task.status = 'SUCCEEDED';
676
+ task.resultEntityId = saved.id;
677
+ task.completedAt = new Date();
678
+ await taskRepo.save(task);
679
+ }
594
680
  return saved;
595
681
  });
596
682
  await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
597
683
  return batch;
598
684
  }
685
+ async beginFastCatalogImport(scope, input) {
686
+ return this.dataSource.transaction(async (manager) => {
687
+ const taskRepo = manager.getRepository(ImportTask);
688
+ const task = await required(taskRepo.findOne({
689
+ where: { ...activeScope(scope), id: input.taskId },
690
+ lock: { mode: 'pessimistic_write' }
691
+ }), '解析任务不存在');
692
+ if (task.documentType !== 'CONTROLLED_CATALOG' && task.documentType !== 'SANCTION_CATALOG') {
693
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '仅目录任务支持快速入库');
694
+ }
695
+ if (task.progress?.mode === 'FAST_PIPELINE' && task.resultEntityId) {
696
+ return { committedCursor: task.progress.committedCursor, batchId: task.resultEntityId };
697
+ }
698
+ assertImportTaskResultWritable(task);
699
+ const now = new Date();
700
+ let batchId;
701
+ if (task.documentType === 'CONTROLLED_CATALOG') {
702
+ const catalogType = input.catalogType;
703
+ if (!catalogType)
704
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '管控目录缺少目录类型');
705
+ const batchRepo = manager.getRepository(ControlledCatalogBatch);
706
+ const recordRepo = manager.getRepository(ControlledGoodsRecord);
707
+ if (input.replaceBatchId) {
708
+ const old = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, deletedAt: IsNull() } }), '被覆盖目录批次不存在');
709
+ old.deletedAt = now;
710
+ old.deletedById = scope.userId ?? null;
711
+ old.deleteOperation = 'CATALOG_REPLACE';
712
+ old.updatedById = scope.userId ?? null;
713
+ await batchRepo.save(old);
714
+ await recordRepo.update({ ...activeScope(scope), batchId: old.id, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'CATALOG_REPLACE' });
715
+ }
716
+ const batch = await batchRepo.save(batchRepo.create({
717
+ ...auditScope(scope), catalogType, sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName,
718
+ importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null,
719
+ totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0,
720
+ importTaskId: task.id, riskRecalculatedAt: null
721
+ }));
722
+ batchId = batch.id;
723
+ }
724
+ else {
725
+ const batchRepo = manager.getRepository(SanctionCatalogBatch);
726
+ const recordRepo = manager.getRepository(SanctionedCompanyRecord);
727
+ if (input.replaceBatchId) {
728
+ const old = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, deletedAt: IsNull() } }), '被覆盖制裁目录批次不存在');
729
+ old.deletedAt = now;
730
+ old.deletedById = scope.userId ?? null;
731
+ old.deleteOperation = 'SANCTION_CATALOG_REPLACE';
732
+ old.updatedById = scope.userId ?? null;
733
+ await batchRepo.save(old);
734
+ await recordRepo.update({ ...activeScope(scope), batchId: old.id, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SANCTION_CATALOG_REPLACE' });
735
+ }
736
+ const batch = await batchRepo.save(batchRepo.create({
737
+ ...auditScope(scope), sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName,
738
+ importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null,
739
+ totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0, importTaskId: task.id
740
+ }));
741
+ batchId = batch.id;
742
+ }
743
+ task.status = 'PERSISTING';
744
+ task.resultEntityId = batchId;
745
+ task.completedAt = null;
746
+ task.progress = initialFastCatalogProgress(input.totalCandidates);
747
+ await taskRepo.save(task);
748
+ return { committedCursor: 0, batchId };
749
+ });
750
+ }
751
+ async appendFastCatalogBatch(scope, input) {
752
+ const controlledRows = input.candidates.flatMap(candidate => candidate.kind === 'CONTROLLED'
753
+ ? candidateToControlledRows(candidate)
754
+ : []);
755
+ const localizedRows = controlledRows.length ? await this.localizeControlledCatalogRows(controlledRows) : [];
756
+ return this.dataSource.transaction(async (manager) => {
757
+ const taskRepo = manager.getRepository(ImportTask);
758
+ const task = await required(taskRepo.findOne({
759
+ where: { ...activeScope(scope), id: input.taskId },
760
+ lock: { mode: 'pessimistic_write' }
761
+ }), '解析任务不存在');
762
+ if (!task.progress || task.progress.mode !== 'FAST_PIPELINE' || !task.resultEntityId) {
763
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入尚未初始化');
764
+ }
765
+ if (task.progress.committedCursor >= input.endCursor && input.endCursor > input.startCursor)
766
+ return task.progress;
767
+ if (task.progress.committedCursor !== input.startCursor) {
768
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入游标不一致');
769
+ }
770
+ if (task.documentType === 'CONTROLLED_CATALOG') {
771
+ const batchRepo = manager.getRepository(ControlledCatalogBatch);
772
+ const batch = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId } }), '管控目录批次不存在');
773
+ await this.appendControlledCatalogRows(manager, scope, batch, controlledRows, localizedRows);
774
+ await batchRepo.save(batch);
775
+ task.progress.successCount = batch.successCount;
776
+ task.progress.duplicateCount = batch.duplicateCount;
777
+ task.progress.invalidCount = batch.invalidCount;
778
+ }
779
+ else {
780
+ const batch = await this.appendFastSanctionCandidates(manager, scope, task.resultEntityId, input.candidates);
781
+ task.progress.successCount = batch.successCount;
782
+ task.progress.duplicateCount = batch.duplicateCount;
783
+ task.progress.invalidCount = batch.invalidCount;
784
+ }
785
+ if (input.reviewBlocks.length) {
786
+ const reviewRepo = manager.getRepository(CatalogReviewBlock);
787
+ for (const block of input.reviewBlocks) {
788
+ const existing = await reviewRepo.findOne({ where: {
789
+ ...activeScope(scope), importTaskId: task.id, ordinal: block.ordinal,
790
+ sourceLocation: block.sourceLocation, status: 'PENDING'
791
+ } });
792
+ if (existing)
793
+ continue;
794
+ await reviewRepo.save(reviewRepo.create({
795
+ ...createdScope(scope), importTaskId: task.id, documentType: task.documentType,
796
+ confidence: block.confidence, status: 'PENDING', ordinal: block.ordinal,
797
+ sourceLocation: block.sourceLocation || null, sourceContent: block.sourceContent || null,
798
+ payload: block.payload, reasons: block.reasons,
799
+ leaseToken: null, leaseExpiresAt: null, resolvedAt: null
800
+ }));
801
+ }
802
+ }
803
+ task.progress.committedCursor = input.endCursor;
804
+ task.progress.processedCount = input.endCursor + input.reviewBlocks.length + input.skipped.length;
805
+ task.progress.reviewPendingCount += input.reviewBlocks.length;
806
+ task.progress.currentBatch = input.endCursor === 0 ? 0 : Math.ceil(input.endCursor / 100);
807
+ task.progress.progressVersion += 1;
808
+ task.progress.lastProgressAt = new Date().toISOString();
809
+ task.progress.stage = input.reviewBlocks.length ? 'REVIEWING' : 'SAVING';
810
+ await taskRepo.save(task);
811
+ return task.progress;
812
+ });
813
+ }
814
+ async finalizeFastCatalogImport(scope, taskId) {
815
+ const task = await this.dataSource.transaction(async (manager) => {
816
+ const repo = manager.getRepository(ImportTask);
817
+ const current = await required(repo.findOne({
818
+ where: { ...activeScope(scope), id: taskId }, lock: { mode: 'pessimistic_write' }
819
+ }), '解析任务不存在');
820
+ if (!current.progress || current.progress.mode !== 'FAST_PIPELINE') {
821
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入进度不存在');
822
+ }
823
+ current.status = current.progress.reviewPendingCount > 0 ? 'AWAITING_REVIEW' : 'SUCCEEDED';
824
+ current.progress.stage = current.progress.reviewPendingCount > 0 ? 'REVIEWING' : 'SUCCEEDED';
825
+ current.progress.observerStatus = current.progress.reviewPendingCount > 0 ? 'PENDING' : 'COMPLETED';
826
+ current.progress.progressVersion += 1;
827
+ current.progress.lastProgressAt = new Date().toISOString();
828
+ current.completedAt = new Date();
829
+ return repo.save(current);
830
+ });
831
+ if (task.documentType === 'CONTROLLED_CATALOG')
832
+ await this.recalculateProductRisks(scope, 'CATALOG_CHANGED', task.resultEntityId ?? undefined);
833
+ else
834
+ await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
835
+ return task;
836
+ }
837
+ async waitImportProgress(scope, taskId, afterProgressVersion = 0, waitMs = 10_000) {
838
+ const deadline = Date.now() + Math.max(0, Math.min(waitMs, 10_000));
839
+ while (true) {
840
+ const task = await required(this.dataSource.getRepository(ImportTask).findOne({
841
+ where: { ...activeScope(scope), id: taskId }
842
+ }), '解析任务不存在');
843
+ const version = task.progress?.progressVersion ?? 0;
844
+ if (version > afterProgressVersion || TERMINAL_IMPORT_TASK_STATUSES.includes(task.status)) {
845
+ return { task: publicImportTaskDto(task) };
846
+ }
847
+ const remaining = deadline - Date.now();
848
+ if (remaining <= 0)
849
+ return { task: publicImportTaskDto(task) };
850
+ await new Promise(resolve => setTimeout(resolve, Math.min(250, remaining)));
851
+ }
852
+ }
853
+ async nextCatalogReviewBatch(scope, taskId, requestedLimit = 20) {
854
+ const limit = Math.max(1, Math.min(requestedLimit, 20));
855
+ return this.dataSource.transaction(async (manager) => {
856
+ const task = await required(manager.getRepository(ImportTask).findOne({
857
+ where: { ...activeScope(scope), id: taskId }, lock: { mode: 'pessimistic_write' }
858
+ }), '解析任务不存在');
859
+ if (task.documentType !== 'CONTROLLED_CATALOG' && task.documentType !== 'SANCTION_CATALOG') {
860
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '仅目录任务包含复核块');
861
+ }
862
+ const repo = manager.getRepository(CatalogReviewBlock);
863
+ await repo.update({
864
+ ...activeScope(scope), importTaskId: taskId, status: 'LEASED', leaseExpiresAt: LessThanOrEqual(new Date())
865
+ }, { status: 'PENDING', leaseToken: null, leaseExpiresAt: null });
866
+ const blocks = await repo.createQueryBuilder('block')
867
+ .setLock('pessimistic_write')
868
+ .setOnLocked('skip_locked')
869
+ .where('block."tenantId" = :tenantId AND block."organizationId" = :organizationId AND block."importTaskId" = :taskId AND block.status = :status', {
870
+ ...activeScope(scope), taskId, status: 'PENDING'
871
+ })
872
+ .orderBy('block.ordinal', 'ASC')
873
+ .addOrderBy('block.id', 'ASC')
874
+ .limit(limit)
875
+ .getMany();
876
+ if (blocks.length === 0)
877
+ return { leaseToken: null, items: [] };
878
+ const leaseToken = randomUUID();
879
+ const leaseExpiresAt = new Date(Date.now() + 5 * 60_000);
880
+ for (const block of blocks) {
881
+ block.status = 'LEASED';
882
+ block.leaseToken = leaseToken;
883
+ block.leaseExpiresAt = leaseExpiresAt;
884
+ }
885
+ await repo.save(blocks);
886
+ return {
887
+ leaseToken,
888
+ leaseExpiresAt: leaseExpiresAt.toISOString(),
889
+ items: blocks.map(block => ({
890
+ blockId: block.id, ordinal: block.ordinal, confidence: block.confidence,
891
+ sourceLocation: block.sourceLocation, sourceContent: block.sourceContent,
892
+ payload: block.payload, reasons: block.reasons
893
+ }))
894
+ };
895
+ });
896
+ }
897
+ async submitCatalogReviewBatch(scope, taskId, leaseToken, items) {
898
+ if (!leaseToken.trim() || items.length === 0 || items.length > 20) {
899
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '复核提交参数无效');
900
+ }
901
+ return this.dataSource.transaction(async (manager) => {
902
+ const taskRepo = manager.getRepository(ImportTask);
903
+ const task = await required(taskRepo.findOne({
904
+ where: { ...activeScope(scope), id: taskId }, lock: { mode: 'pessimistic_write' }
905
+ }), '解析任务不存在');
906
+ if (!task.progress || !task.resultEntityId)
907
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录复核任务状态无效');
908
+ const reviewRepo = manager.getRepository(CatalogReviewBlock);
909
+ const blocks = await reviewRepo.find({ where: {
910
+ ...activeScope(scope), importTaskId: taskId, id: In(items.map(item => item.blockId)),
911
+ status: 'LEASED', leaseToken
912
+ } });
913
+ if (blocks.length !== items.length || blocks.some(block => !block.leaseExpiresAt || block.leaseExpiresAt.getTime() <= Date.now())) {
914
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '复核块租约无效或已过期');
915
+ }
916
+ const acceptedCandidates = [];
917
+ let resolved = 0;
918
+ let rejected = 0;
919
+ for (const item of items) {
920
+ const block = blocks.find(value => value.id === item.blockId);
921
+ if (item.decision === 'REJECT') {
922
+ block.status = 'REJECTED';
923
+ rejected += 1;
924
+ }
925
+ else {
926
+ const payload = item.payload ?? block.payload;
927
+ const candidate = reviewCandidate(task.documentType, block, payload);
928
+ const decision = classifyCandidate(candidate);
929
+ if (decision.confidence === 'LOW') {
930
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, `复核结果未通过代码校验:${decision.reasons.join(',')}`);
931
+ }
932
+ acceptedCandidates.push(candidate);
933
+ block.payload = payload;
934
+ block.status = 'RESOLVED';
935
+ resolved += 1;
936
+ }
937
+ block.resolvedAt = new Date();
938
+ block.leaseToken = null;
939
+ block.leaseExpiresAt = null;
940
+ }
941
+ if (task.documentType === 'CONTROLLED_CATALOG' && acceptedCandidates.length) {
942
+ const batchRepo = manager.getRepository(ControlledCatalogBatch);
943
+ const batch = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId } }), '管控目录批次不存在');
944
+ const rows = acceptedCandidates.flatMap(candidateToControlledRows);
945
+ const localized = rows.map(row => ({
946
+ productName: String(row.productName ?? '').trim(),
947
+ controlDescription: optionalString(row.controlDescription),
948
+ originalProductName: null, originalControlDescription: null,
949
+ translationSource: row.translationSource ?? null
950
+ }));
951
+ await this.appendControlledCatalogRows(manager, scope, batch, rows, localized);
952
+ await batchRepo.save(batch);
953
+ task.progress.successCount = batch.successCount;
954
+ task.progress.duplicateCount = batch.duplicateCount;
955
+ task.progress.invalidCount = batch.invalidCount;
956
+ }
957
+ if (task.documentType === 'SANCTION_CATALOG' && acceptedCandidates.length) {
958
+ const batch = await this.appendFastSanctionCandidates(manager, scope, task.resultEntityId, acceptedCandidates);
959
+ task.progress.successCount = batch.successCount;
960
+ task.progress.duplicateCount = batch.duplicateCount;
961
+ task.progress.invalidCount = batch.invalidCount;
962
+ }
963
+ await reviewRepo.save(blocks);
964
+ task.progress.reviewResolvedCount += resolved;
965
+ task.progress.reviewRejectedCount += rejected;
966
+ task.progress.reviewPendingCount = Math.max(0, task.progress.reviewPendingCount - resolved - rejected);
967
+ task.progress.progressVersion += 1;
968
+ task.progress.lastProgressAt = new Date().toISOString();
969
+ if (task.progress.reviewPendingCount === 0) {
970
+ task.status = 'SUCCEEDED';
971
+ task.progress.stage = 'SUCCEEDED';
972
+ task.progress.observerStatus = 'COMPLETED';
973
+ task.completedAt = new Date();
974
+ }
975
+ else {
976
+ task.status = 'AWAITING_REVIEW';
977
+ task.progress.stage = 'REVIEWING';
978
+ }
979
+ await taskRepo.save(task);
980
+ return { resolved, rejected, remaining: task.progress.reviewPendingCount, task: publicImportTaskDto(task) };
981
+ });
982
+ }
983
+ async setImportObserverConversation(scope, taskId, conversationId) {
984
+ const normalizedConversationId = requiredId(conversationId, '观察会话标识');
985
+ return this.dataSource.transaction(async (manager) => {
986
+ const repo = manager.getRepository(ImportTask);
987
+ const task = await required(repo.findOne({
988
+ where: { ...activeScope(scope), id: taskId }, lock: { mode: 'pessimistic_write' }
989
+ }), '解析任务不存在');
990
+ if (task.observerConversationId && task.observerConversationId !== normalizedConversationId) {
991
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '观察会话标识不一致');
992
+ }
993
+ task.observerConversationId = normalizedConversationId;
994
+ if (task.progress)
995
+ task.progress.observerStatus = 'ACTIVE';
996
+ return repo.save(task);
997
+ });
998
+ }
999
+ async appendFastSanctionCandidates(manager, scope, batchId, candidates) {
1000
+ const batchRepo = manager.getRepository(SanctionCatalogBatch);
1001
+ const recordRepo = manager.getRepository(SanctionedCompanyRecord);
1002
+ const aliasRepo = manager.getRepository(SanctionedCompanyAlias);
1003
+ const issueRepo = manager.getRepository(SanctionCatalogImportIssue);
1004
+ const batch = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: batchId } }), '制裁目录批次不存在');
1005
+ for (const candidate of candidates.filter(value => value.kind === 'SANCTION')) {
1006
+ const companyName = String(candidate.values.companyName ?? '').trim();
1007
+ const sourceLocation = candidate.evidence[0]?.sourceLocation ?? null;
1008
+ const sourceContent = candidate.evidence[0]?.sourceContent ?? null;
1009
+ if (!companyName)
1010
+ continue;
1011
+ const normalizedCompanyName = normalizeExactName(companyName);
1012
+ const sanctionListSource = optionalString(candidate.values.sanctionListSource);
1013
+ const normalizedSanctionListSource = normalizeSanctionSource(sanctionListSource);
1014
+ const duplicate = await recordRepo.findOne({ where: { ...activeScope(scope), normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull() } });
1015
+ batch.totalCount += 1;
1016
+ if (duplicate) {
1017
+ batch.duplicateCount += 1;
1018
+ await issueRepo.save(issueRepo.create({
1019
+ ...createdScope(scope), batchId: batch.id, issueType: 'DUPLICATE', sourceSequence: batch.totalCount,
1020
+ sourceLocation, sourceContent, parsedPayload: candidate.values,
1021
+ message: '制裁目录中已存在相同公司和名单来源'
1022
+ }));
1023
+ continue;
1024
+ }
1025
+ const record = await recordRepo.save(recordRepo.create({
1026
+ ...auditScope(scope), batchId: batch.id, sourceType: 'FILE_IMPORT', companyName, normalizedCompanyName,
1027
+ countryOrRegion: optionalString(candidate.values.countryOrRegion), sanctionListSource,
1028
+ normalizedSanctionListSource, sanctionReason: optionalString(candidate.values.sanctionReason),
1029
+ effectiveDate: optionalString(candidate.values.effectiveDate), remarks: optionalString(candidate.values.remarks),
1030
+ sourceLocation, sourceContent
1031
+ }));
1032
+ for (const alias of candidateAliases(candidate)) {
1033
+ await aliasRepo.save(aliasRepo.create({
1034
+ ...auditScope(scope), sanctionedCompanyId: record.id, alias: alias.alias,
1035
+ normalizedAlias: normalizeExactName(alias.alias), aliasType: alias.aliasType, sourceLocation
1036
+ }));
1037
+ }
1038
+ batch.successCount += 1;
1039
+ }
1040
+ return batchRepo.save(batch);
1041
+ }
599
1042
  async createImportTask(scope, input) {
600
1043
  const repo = this.dataSource.getRepository(ImportTask);
601
1044
  const targetAssistantId = requiredId(input.targetAssistantId, '目标智能体标识');
@@ -607,6 +1050,17 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
607
1050
  order: { queuedAt: 'ASC', id: 'ASC' }
608
1051
  });
609
1052
  }
1053
+ async findNextFastCatalogImportCandidate(scope) {
1054
+ const tasks = await this.dataSource.getRepository(ImportTask).find({
1055
+ where: {
1056
+ ...activeScope(scope),
1057
+ documentType: In(['CONTROLLED_CATALOG', 'SANCTION_CATALOG']),
1058
+ status: In(['EXTRACTING', 'VALIDATING', 'PERSISTING'])
1059
+ },
1060
+ order: { startedAt: 'ASC', id: 'ASC' }
1061
+ });
1062
+ return tasks.find(task => task.progress?.mode === 'FAST_PIPELINE') ?? null;
1063
+ }
610
1064
  async claimQueuedImportTaskById(scope, id, claimAttemptToken) {
611
1065
  const attemptToken = requiredId(claimAttemptToken, '领取尝试标识');
612
1066
  return this.dataSource.transaction(async (manager) => {
@@ -2368,18 +2822,28 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
2368
2822
  const customerRepo = manager.getRepository(Customer);
2369
2823
  const now = new Date();
2370
2824
  await repo.update({ ...activeScope(scope), deletedAt: IsNull() }, { deletedAt: now, invalidationReason: reason });
2371
- const [suppliers, customers, sanctions] = await Promise.all([
2825
+ const [suppliers, customers, sanctions, aliases] = await Promise.all([
2372
2826
  supplierRepo.find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
2373
2827
  customerRepo.find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
2374
- manager.getRepository(SanctionedCompanyRecord).find({ where: { ...activeScope(scope), deletedAt: IsNull() } })
2828
+ manager.getRepository(SanctionedCompanyRecord).find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
2829
+ manager.getRepository(SanctionedCompanyAlias).find({ where: { ...activeScope(scope), deletedAt: IsNull() } })
2375
2830
  ]);
2831
+ const sanctionsById = new Map(sanctions.map(item => [item.id, item]));
2832
+ const sanctionsByNormalizedName = new Map();
2833
+ for (const item of sanctions)
2834
+ appendMapValue(sanctionsByNormalizedName, item.normalizedCompanyName, item);
2835
+ for (const alias of aliases) {
2836
+ const item = sanctionsById.get(alias.sanctionedCompanyId);
2837
+ if (item)
2838
+ appendMapValue(sanctionsByNormalizedName, alias.normalizedAlias, item);
2839
+ }
2376
2840
  for (const supplier of suppliers) {
2377
- for (const item of sanctions.filter(row => row.normalizedCompanyName === supplier.normalizedName))
2841
+ for (const item of sanctionsByNormalizedName.get(supplier.normalizedName) ?? [])
2378
2842
  await repo.save(repo.create({ ...createdScope(scope), subjectType: 'SUPPLIER', supplierId: supplier.id, customerId: null, sanctionedCompanyId: item.id, matchedNormalizedName: supplier.normalizedName, checkedAt: now, deletedAt: null, invalidationReason: null }));
2379
2843
  await supplierRepo.update({ ...activeScope(scope), id: supplier.id, deletedAt: IsNull() }, { complianceCheckedAt: now, updatedById: scope.userId ?? null, updatedAt: now });
2380
2844
  }
2381
2845
  for (const customer of customers) {
2382
- for (const item of sanctions.filter(row => row.normalizedCompanyName === customer.normalizedName))
2846
+ for (const item of sanctionsByNormalizedName.get(customer.normalizedName) ?? [])
2383
2847
  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 }));
2384
2848
  await customerRepo.update({ ...activeScope(scope), id: customer.id, deletedAt: IsNull() }, { complianceCheckStatus: 'CHECKED', complianceCheckedAt: now, updatedById: scope.userId ?? null, updatedAt: now });
2385
2849
  }
@@ -3028,6 +3492,83 @@ function snapshotMissingRecoveryMetadataStillMissing(snapshot, current) {
3028
3492
  }
3029
3493
  function requiredId(value, label) { const id = String(value ?? '').trim(); if (!id)
3030
3494
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, `缺少${label}`); return id; }
3495
+ function optionalString(value) {
3496
+ const normalized = String(value ?? '').trim();
3497
+ return normalized || null;
3498
+ }
3499
+ function initialFastCatalogProgress(totalCandidates) {
3500
+ return {
3501
+ version: 1,
3502
+ mode: 'FAST_PIPELINE',
3503
+ stage: 'SAVING',
3504
+ totalCandidates,
3505
+ processedCount: 0,
3506
+ successCount: 0,
3507
+ duplicateCount: 0,
3508
+ invalidCount: 0,
3509
+ currentBatch: 0,
3510
+ totalBatches: totalCandidates > 0 ? Math.ceil(totalCandidates / 100) : 0,
3511
+ committedCursor: 0,
3512
+ progressVersion: 1,
3513
+ lastProgressAt: new Date().toISOString(),
3514
+ observerStatus: 'PENDING',
3515
+ reviewPendingCount: 0,
3516
+ reviewResolvedCount: 0,
3517
+ reviewRejectedCount: 0
3518
+ };
3519
+ }
3520
+ function candidateToControlledRows(candidate) {
3521
+ const evidence = candidate.evidence[0];
3522
+ const hsCodes = Array.isArray(candidate.values.hsCodes) && candidate.values.hsCodes.length
3523
+ ? candidate.values.hsCodes
3524
+ : [undefined];
3525
+ return hsCodes.map(hsCode => ({
3526
+ productName: optionalString(candidate.values.productName) ?? undefined,
3527
+ hsCode: typeof hsCode === 'string' ? hsCode : undefined,
3528
+ controlDescription: optionalString(candidate.values.controlDescription) ?? undefined,
3529
+ controlCode: optionalString(candidate.values.controlCode) ?? undefined,
3530
+ sourceSection: optionalString(candidate.values.sourceSection) ?? undefined,
3531
+ sourceOrdinal: optionalString(candidate.values.sourceOrdinal) ?? String(candidate.ordinal),
3532
+ sourceLocation: evidence?.sourceLocation,
3533
+ sourceContent: evidence?.sourceContent,
3534
+ translationSource: 'SOURCE_CHINESE'
3535
+ }));
3536
+ }
3537
+ function candidateAliases(candidate) {
3538
+ if (!Array.isArray(candidate.values.aliases))
3539
+ return [];
3540
+ return candidate.values.aliases.flatMap(value => {
3541
+ if (!value || typeof value !== 'object')
3542
+ return [];
3543
+ const alias = optionalString(value.alias);
3544
+ const aliasType = value.aliasType;
3545
+ return alias && (aliasType === 'AKA' || aliasType === 'LOCAL_LANGUAGE') ? [{ alias, aliasType }] : [];
3546
+ });
3547
+ }
3548
+ function reviewCandidate(documentType, block, payload) {
3549
+ const values = { ...payload };
3550
+ if (!Array.isArray(values.hsCodes) && typeof values.hsCode === 'string')
3551
+ values.hsCodes = [values.hsCode];
3552
+ return {
3553
+ kind: documentType === 'CONTROLLED_CATALOG' ? 'CONTROLLED' : 'SANCTION',
3554
+ ordinal: block.ordinal,
3555
+ values,
3556
+ evidence: block.sourceLocation && block.sourceContent
3557
+ ? [{ sourceLocation: block.sourceLocation, sourceContent: block.sourceContent }]
3558
+ : [],
3559
+ signals: ['STRUCTURED_SOURCE', 'HEADER_MAPPED', 'RECORD_START_EXPLICIT', 'COLUMN_LAYOUT_STABLE'],
3560
+ warnings: block.reasons
3561
+ };
3562
+ }
3563
+ function appendMapValue(map, key, value) {
3564
+ const values = map.get(key);
3565
+ if (values) {
3566
+ if (!values.includes(value))
3567
+ values.push(value);
3568
+ }
3569
+ else
3570
+ map.set(key, [value]);
3571
+ }
3031
3572
  function assertCompleteClauseAnalyses(analyses) {
3032
3573
  if (analyses.length !== ClauseTypes.length)
3033
3574
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析结果必须完整包含七类关键条款');