@chenchaolong/plugin-trade-compliance-workbench 1.0.11 → 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 +56 -1
  87. package/dist/lib/workbench.service.d.ts.map +1 -1
  88. package/dist/lib/workbench.service.js +463 -7
  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;
@@ -442,7 +443,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
442
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: '目录中已存在相同商品名称和海关编码' }));
443
444
  continue;
444
445
  }
445
- 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, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null }));
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 }));
446
447
  batch.successCount += 1;
447
448
  batch.riskRecalculatedAt = null;
448
449
  }
@@ -681,6 +682,363 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
681
682
  await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
682
683
  return batch;
683
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
+ }
684
1042
  async createImportTask(scope, input) {
685
1043
  const repo = this.dataSource.getRepository(ImportTask);
686
1044
  const targetAssistantId = requiredId(input.targetAssistantId, '目标智能体标识');
@@ -692,6 +1050,17 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
692
1050
  order: { queuedAt: 'ASC', id: 'ASC' }
693
1051
  });
694
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
+ }
695
1064
  async claimQueuedImportTaskById(scope, id, claimAttemptToken) {
696
1065
  const attemptToken = requiredId(claimAttemptToken, '领取尝试标识');
697
1066
  return this.dataSource.transaction(async (manager) => {
@@ -2453,18 +2822,28 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
2453
2822
  const customerRepo = manager.getRepository(Customer);
2454
2823
  const now = new Date();
2455
2824
  await repo.update({ ...activeScope(scope), deletedAt: IsNull() }, { deletedAt: now, invalidationReason: reason });
2456
- const [suppliers, customers, sanctions] = await Promise.all([
2825
+ const [suppliers, customers, sanctions, aliases] = await Promise.all([
2457
2826
  supplierRepo.find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
2458
2827
  customerRepo.find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
2459
- 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() } })
2460
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
+ }
2461
2840
  for (const supplier of suppliers) {
2462
- for (const item of sanctions.filter(row => row.normalizedCompanyName === supplier.normalizedName))
2841
+ for (const item of sanctionsByNormalizedName.get(supplier.normalizedName) ?? [])
2463
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 }));
2464
2843
  await supplierRepo.update({ ...activeScope(scope), id: supplier.id, deletedAt: IsNull() }, { complianceCheckedAt: now, updatedById: scope.userId ?? null, updatedAt: now });
2465
2844
  }
2466
2845
  for (const customer of customers) {
2467
- for (const item of sanctions.filter(row => row.normalizedCompanyName === customer.normalizedName))
2846
+ for (const item of sanctionsByNormalizedName.get(customer.normalizedName) ?? [])
2468
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 }));
2469
2848
  await customerRepo.update({ ...activeScope(scope), id: customer.id, deletedAt: IsNull() }, { complianceCheckStatus: 'CHECKED', complianceCheckedAt: now, updatedById: scope.userId ?? null, updatedAt: now });
2470
2849
  }
@@ -3113,6 +3492,83 @@ function snapshotMissingRecoveryMetadataStillMissing(snapshot, current) {
3113
3492
  }
3114
3493
  function requiredId(value, label) { const id = String(value ?? '').trim(); if (!id)
3115
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
+ }
3116
3572
  function assertCompleteClauseAnalyses(analyses) {
3117
3573
  if (analyses.length !== ClauseTypes.length)
3118
3574
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析结果必须完整包含七类关键条款');