@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.
- package/README.md +10 -0
- package/dist/lib/catalog-import/catalog-document.parser.d.ts +18 -0
- package/dist/lib/catalog-import/catalog-document.parser.d.ts.map +1 -0
- package/dist/lib/catalog-import/catalog-document.parser.js +36 -0
- package/dist/lib/catalog-import/catalog-document.parser.js.map +1 -0
- package/dist/lib/catalog-import/catalog-import.pipeline.d.ts +30 -0
- package/dist/lib/catalog-import/catalog-import.pipeline.d.ts.map +1 -0
- package/dist/lib/catalog-import/catalog-import.pipeline.js +54 -0
- package/dist/lib/catalog-import/catalog-import.pipeline.js.map +1 -0
- package/dist/lib/catalog-import/confidence.d.ts +3 -0
- package/dist/lib/catalog-import/confidence.d.ts.map +1 -0
- package/dist/lib/catalog-import/confidence.js +52 -0
- package/dist/lib/catalog-import/confidence.js.map +1 -0
- package/dist/lib/catalog-import/contracts.d.ts +50 -0
- package/dist/lib/catalog-import/contracts.d.ts.map +1 -0
- package/dist/lib/catalog-import/contracts.js +2 -0
- package/dist/lib/catalog-import/contracts.js.map +1 -0
- package/dist/lib/catalog-import/docx.parser.d.ts +6 -0
- package/dist/lib/catalog-import/docx.parser.d.ts.map +1 -0
- package/dist/lib/catalog-import/docx.parser.js +130 -0
- package/dist/lib/catalog-import/docx.parser.js.map +1 -0
- package/dist/lib/catalog-import/header-mapping.d.ts +4 -0
- package/dist/lib/catalog-import/header-mapping.d.ts.map +1 -0
- package/dist/lib/catalog-import/header-mapping.js +34 -0
- package/dist/lib/catalog-import/header-mapping.js.map +1 -0
- package/dist/lib/catalog-import/pdf-layout.parser.d.ts +15 -0
- package/dist/lib/catalog-import/pdf-layout.parser.d.ts.map +1 -0
- package/dist/lib/catalog-import/pdf-layout.parser.js +133 -0
- package/dist/lib/catalog-import/pdf-layout.parser.js.map +1 -0
- package/dist/lib/catalog-import/pdf-table.normalizer.d.ts +4 -0
- package/dist/lib/catalog-import/pdf-table.normalizer.d.ts.map +1 -0
- package/dist/lib/catalog-import/pdf-table.normalizer.js +22 -0
- package/dist/lib/catalog-import/pdf-table.normalizer.js.map +1 -0
- package/dist/lib/catalog-import/spreadsheet.parser.d.ts +7 -0
- package/dist/lib/catalog-import/spreadsheet.parser.d.ts.map +1 -0
- package/dist/lib/catalog-import/spreadsheet.parser.js +118 -0
- package/dist/lib/catalog-import/spreadsheet.parser.js.map +1 -0
- package/dist/lib/constants.d.ts +3 -0
- package/dist/lib/constants.d.ts.map +1 -1
- package/dist/lib/constants.js +3 -0
- package/dist/lib/constants.js.map +1 -1
- package/dist/lib/entities/catalog.entity.d.ts +25 -1
- package/dist/lib/entities/catalog.entity.d.ts.map +1 -1
- package/dist/lib/entities/catalog.entity.js +94 -0
- package/dist/lib/entities/catalog.entity.js.map +1 -1
- package/dist/lib/entities/index.d.ts +2 -2
- package/dist/lib/entities/index.d.ts.map +1 -1
- package/dist/lib/entities/index.js +3 -2
- package/dist/lib/entities/index.js.map +1 -1
- package/dist/lib/entities/shared.entity.d.ts +3 -1
- package/dist/lib/entities/shared.entity.d.ts.map +1 -1
- package/dist/lib/entities/shared.entity.js +8 -0
- package/dist/lib/entities/shared.entity.js.map +1 -1
- package/dist/lib/import-jobs/import-command.builder.d.ts +4 -0
- package/dist/lib/import-jobs/import-command.builder.d.ts.map +1 -1
- package/dist/lib/import-jobs/import-command.builder.js +11 -0
- package/dist/lib/import-jobs/import-command.builder.js.map +1 -1
- package/dist/lib/import-jobs/import-job-callback.processor.d.ts.map +1 -1
- package/dist/lib/import-jobs/import-job-callback.processor.js +22 -0
- package/dist/lib/import-jobs/import-job-callback.processor.js.map +1 -1
- package/dist/lib/import-jobs/import-job.processor.d.ts.map +1 -1
- package/dist/lib/import-jobs/import-job.processor.js +32 -1
- package/dist/lib/import-jobs/import-job.processor.js.map +1 -1
- package/dist/lib/public-dto.d.ts +1 -1
- package/dist/lib/public-dto.d.ts.map +1 -1
- package/dist/lib/public-dto.js +2 -0
- package/dist/lib/public-dto.js.map +1 -1
- package/dist/lib/remote-components/trade-compliance-workbench/app.js +5 -5
- package/dist/lib/remote-ui/app-source.js +12 -0
- package/dist/lib/remote-ui/app-source.js.map +1 -1
- package/dist/lib/remote-ui/contracts.d.ts +9 -0
- package/dist/lib/remote-ui/contracts.d.ts.map +1 -1
- package/dist/lib/runtime-validation.d.ts +1 -1
- package/dist/lib/types.d.ts +26 -0
- package/dist/lib/types.d.ts.map +1 -1
- package/dist/lib/types.js +3 -0
- package/dist/lib/types.js.map +1 -1
- package/dist/lib/workbench-view.provider.d.ts +7 -0
- package/dist/lib/workbench-view.provider.d.ts.map +1 -1
- package/dist/lib/workbench-view.provider.js +1 -0
- package/dist/lib/workbench-view.provider.js.map +1 -1
- package/dist/lib/workbench.middleware.d.ts +89 -6
- package/dist/lib/workbench.middleware.d.ts.map +1 -1
- package/dist/lib/workbench.middleware.js +3 -0
- package/dist/lib/workbench.middleware.js.map +1 -1
- package/dist/lib/workbench.service.d.ts +60 -1
- package/dist/lib/workbench.service.d.ts.map +1 -1
- package/dist/lib/workbench.service.js +592 -51
- package/dist/lib/workbench.service.js.map +1 -1
- 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
|
-
|
|
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:
|
|
361
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
|
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
|
|
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, '重新分析结果必须完整包含七类关键条款');
|