@chenchaolong/plugin-trade-compliance-workbench 1.0.52 → 1.0.54
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 +2 -2
- package/dist/lib/analytics-export.d.ts.map +1 -1
- package/dist/lib/analytics-export.js +1 -3
- package/dist/lib/analytics-export.js.map +1 -1
- package/dist/lib/analytics-query.d.ts.map +1 -1
- package/dist/lib/analytics-query.js +13 -20
- package/dist/lib/analytics-query.js.map +1 -1
- package/dist/lib/catalog-import/confidence.d.ts +1 -0
- package/dist/lib/catalog-import/confidence.d.ts.map +1 -1
- package/dist/lib/catalog-import/confidence.js +12 -0
- package/dist/lib/catalog-import/confidence.js.map +1 -1
- package/dist/lib/catalog-import/docx.parser.d.ts.map +1 -1
- package/dist/lib/catalog-import/docx.parser.js +7 -6
- package/dist/lib/catalog-import/docx.parser.js.map +1 -1
- package/dist/lib/catalog-import/pdf-layout.parser.d.ts.map +1 -1
- package/dist/lib/catalog-import/pdf-layout.parser.js +8 -7
- package/dist/lib/catalog-import/pdf-layout.parser.js.map +1 -1
- package/dist/lib/catalog-import/spreadsheet.parser.d.ts.map +1 -1
- package/dist/lib/catalog-import/spreadsheet.parser.js +12 -13
- package/dist/lib/catalog-import/spreadsheet.parser.js.map +1 -1
- package/dist/lib/demo-data.definitions.js +1 -1
- package/dist/lib/demo-data.definitions.js.map +1 -1
- package/dist/lib/entities/catalog.entity.d.ts +7 -1
- package/dist/lib/entities/catalog.entity.d.ts.map +1 -1
- package/dist/lib/entities/catalog.entity.js +26 -2
- package/dist/lib/entities/catalog.entity.js.map +1 -1
- package/dist/lib/entities/customer.entity.js.map +1 -1
- package/dist/lib/entities/procurement.entity.js.map +1 -1
- package/dist/lib/remote-components/trade-compliance-workbench/app.css +1 -1
- package/dist/lib/remote-components/trade-compliance-workbench/app.js +8 -8
- package/dist/lib/remote-ui/action-execution.d.ts +3 -0
- package/dist/lib/remote-ui/action-execution.d.ts.map +1 -1
- package/dist/lib/remote-ui/action-execution.js +21 -1
- package/dist/lib/remote-ui/action-execution.js.map +1 -1
- package/dist/lib/remote-ui/app-source.js +168 -151
- package/dist/lib/remote-ui/app-source.js.map +1 -1
- package/dist/lib/remote-ui/bridge.d.ts.map +1 -1
- package/dist/lib/remote-ui/components/forms.d.ts +5 -2
- package/dist/lib/remote-ui/components/forms.d.ts.map +1 -1
- package/dist/lib/remote-ui/components/forms.js +4 -4
- package/dist/lib/remote-ui/components/forms.js.map +1 -1
- package/dist/lib/remote-ui/components/layout.d.ts.map +1 -1
- package/dist/lib/remote-ui/components/layout.js +31 -4
- package/dist/lib/remote-ui/components/layout.js.map +1 -1
- package/dist/lib/remote-ui/components/task-status.js +2 -2
- package/dist/lib/remote-ui/components/task-status.js.map +1 -1
- package/dist/lib/remote-ui/contract-import.d.ts +1 -0
- package/dist/lib/remote-ui/contract-import.d.ts.map +1 -1
- package/dist/lib/remote-ui/contract-import.js.map +1 -1
- package/dist/lib/remote-ui/contracts.d.ts +11 -0
- package/dist/lib/remote-ui/contracts.d.ts.map +1 -1
- package/dist/lib/remote-ui/import-task-status.d.ts +5 -2
- package/dist/lib/remote-ui/import-task-status.d.ts.map +1 -1
- package/dist/lib/remote-ui/import-task-status.js +13 -8
- package/dist/lib/remote-ui/import-task-status.js.map +1 -1
- package/dist/lib/remote-ui/pages/analytics-detail-list.d.ts.map +1 -1
- package/dist/lib/remote-ui/pages/analytics-detail-list.js.map +1 -1
- package/dist/lib/remote-ui/pages/catalog-import-history.d.ts +47 -2
- package/dist/lib/remote-ui/pages/catalog-import-history.d.ts.map +1 -1
- package/dist/lib/remote-ui/pages/catalog-import-history.js +128 -8
- package/dist/lib/remote-ui/pages/catalog-import-history.js.map +1 -1
- package/dist/lib/remote-ui/pages/catalogs.css +55 -1
- package/dist/lib/remote-ui/pages/catalogs.d.ts +7 -4
- package/dist/lib/remote-ui/pages/catalogs.d.ts.map +1 -1
- package/dist/lib/remote-ui/pages/catalogs.js +40 -19
- package/dist/lib/remote-ui/pages/catalogs.js.map +1 -1
- package/dist/lib/remote-ui/pages/contracts.d.ts.map +1 -1
- package/dist/lib/remote-ui/pages/contracts.js +0 -16
- package/dist/lib/remote-ui/pages/contracts.js.map +1 -1
- package/dist/lib/remote-ui/pages/files-prototype.css +2 -2
- package/dist/lib/remote-ui/pages/files.d.ts +1 -1
- package/dist/lib/remote-ui/pages/files.d.ts.map +1 -1
- package/dist/lib/remote-ui/pages/files.js +6 -6
- package/dist/lib/remote-ui/pages/files.js.map +1 -1
- package/dist/lib/remote-ui/pages/home.d.ts.map +1 -1
- package/dist/lib/remote-ui/pages/home.js +2 -2
- package/dist/lib/remote-ui/pages/home.js.map +1 -1
- package/dist/lib/remote-ui/pages/procurement.d.ts +6 -1
- package/dist/lib/remote-ui/pages/procurement.d.ts.map +1 -1
- package/dist/lib/remote-ui/pages/procurement.js +77 -16
- package/dist/lib/remote-ui/pages/procurement.js.map +1 -1
- package/dist/lib/remote-ui/procurement-contracts.d.ts +7 -1
- package/dist/lib/remote-ui/procurement-contracts.d.ts.map +1 -1
- package/dist/lib/remote-ui/procurement-contracts.js.map +1 -1
- package/dist/lib/remote-ui/purchase-review-adapter.d.ts +6 -2
- package/dist/lib/remote-ui/purchase-review-adapter.d.ts.map +1 -1
- package/dist/lib/remote-ui/purchase-review-adapter.js +5 -3
- package/dist/lib/remote-ui/purchase-review-adapter.js.map +1 -1
- package/dist/lib/remote-ui/sidebar-state.d.ts +1 -0
- package/dist/lib/remote-ui/sidebar-state.d.ts.map +1 -1
- package/dist/lib/remote-ui/sidebar-state.js +3 -0
- package/dist/lib/remote-ui/sidebar-state.js.map +1 -1
- package/dist/lib/sales-file.service.js +1 -1
- package/dist/lib/sales-file.service.js.map +1 -1
- package/dist/lib/schema-maintenance.d.ts.map +1 -1
- package/dist/lib/schema-maintenance.js +125 -0
- package/dist/lib/schema-maintenance.js.map +1 -1
- package/dist/lib/types.d.ts +3 -0
- package/dist/lib/types.d.ts.map +1 -1
- package/dist/lib/types.js +1 -0
- package/dist/lib/types.js.map +1 -1
- package/dist/lib/workbench-action-contract.d.ts +1 -1
- package/dist/lib/workbench-action-contract.d.ts.map +1 -1
- package/dist/lib/workbench-action-contract.js +3 -1
- package/dist/lib/workbench-action-contract.js.map +1 -1
- package/dist/lib/workbench-view.provider.d.ts +12 -9
- package/dist/lib/workbench-view.provider.d.ts.map +1 -1
- package/dist/lib/workbench-view.provider.js +38 -6
- package/dist/lib/workbench-view.provider.js.map +1 -1
- package/dist/lib/workbench.middleware.d.ts +444 -15
- package/dist/lib/workbench.middleware.d.ts.map +1 -1
- package/dist/lib/workbench.middleware.js +38 -7
- package/dist/lib/workbench.middleware.js.map +1 -1
- package/dist/lib/workbench.service.d.ts +31 -3
- package/dist/lib/workbench.service.d.ts.map +1 -1
- package/dist/lib/workbench.service.js +765 -147
- package/dist/lib/workbench.service.js.map +1 -1
- package/package.json +2 -1
|
@@ -9,10 +9,10 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { finished } from 'node:stream/promises';
|
|
12
|
-
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
13
13
|
import { BankAccountSettings, CatalogReviewBlock, CompanySanctionMatch, CompanySettings, ControlledCatalogBatch, ControlledCatalogImportIssue, ControlledGoodsRecord, Customer, CustomerContract, CustomerContractAnalysisRun, CustomerContractClause, CustomerContractClauseAnalysis, DerivedSalesOrderManualData, GeneratedSalesFile, ImportTask, ManagedAsset, MonthlySafeExchangeRate, ProductComplianceMatch, ProfitRuleSettings, PurchaseOrder, PurchaseOrderLine, SanctionCatalogBatch, SanctionCatalogImportIssue, SanctionedCompanyAlias, SanctionedCompanyRecord, SettingsHistory, Supplier } from './entities/index.js';
|
|
14
14
|
import { CATALOG_REVIEW_BATCH_LIMIT } from './constants.js';
|
|
15
|
-
import { CatalogTypes, ClauseTypes, CustomerProfitRateStatuses, DomainError, DomainErrorCodes, RefundValidationMethods, SalesPaymentStatuses, SanctionHitStatuses } from './types.js';
|
|
15
|
+
import { CatalogTypes, ClauseTypes, CustomerProfitRateStatuses, DomainError, DomainErrorCodes, RefundValidationMethods, SalesPaymentStatuses, SanctionHitStatuses, StalledCatalogImportDecisions } from './types.js';
|
|
16
16
|
import { deriveSalesLine, assertReceivedAmount } from './domain/finance.js';
|
|
17
17
|
import { parseCustomerContractNo, parsePurchaseContractNo } from './domain/contract-number.js';
|
|
18
18
|
import { isProductNameMatch, normalizeExactName, normalizeForProductMatch, normalizeHsCode, normalizeSanctionSource } from './domain/normalization.js';
|
|
@@ -24,7 +24,7 @@ import { TradeComplianceFileStorage } from './adapters/file-storage.js';
|
|
|
24
24
|
import { TranslationProvider } from './adapters/translation.provider.js';
|
|
25
25
|
import { parseBankAccountType, parseExchangeRateInput, parseExpectedRevision, validateBankSettingsInput, validateProfitSettingsInput, validatePurchaseLineDiscriminators, validateTaxRates } from './runtime-validation.js';
|
|
26
26
|
import { publicCustomerContractReanalysisDto, publicImportTaskDto } from './public-dto.js';
|
|
27
|
-
import { classifyCandidate } from './catalog-import/confidence.js';
|
|
27
|
+
import { classifyCandidate, normalizeCatalogCandidate } from './catalog-import/confidence.js';
|
|
28
28
|
import { catalogTerminalOutcome } from './catalog-import/outcome.js';
|
|
29
29
|
import { analyticsBusinessDetailFromRaw, analyticsDashboardFromRaw, analyticsDashboardStatement, analyticsDetailStatement, analyticsDetailTotalFromRaw, analyticsExportComplianceFromRaw, analyticsExportCustomersFromRaw, analyticsExportFundRisksFromRaw, analyticsExportSalesFileReviewsFromRaw, analyticsExportStatements, analyticsExportSuppliersFromRaw, resolveAnalyticsPeriod } from './analytics-query.js';
|
|
30
30
|
import { analyticsExportMaxRows, renderAnalyticsExportWorkbook } from './analytics-export.js';
|
|
@@ -210,17 +210,18 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
210
210
|
return { ...transactionResult, sourceFileRemoved };
|
|
211
211
|
}
|
|
212
212
|
async deleteImportTaskTransientRows(manager, scope, task) {
|
|
213
|
+
await this.failCatalogDraft(manager, scope, task);
|
|
213
214
|
await manager.getRepository(CatalogReviewBlock).delete({ ...activeScope(scope), importTaskId: task.id });
|
|
214
215
|
await this.deleteEmptyImportCatalogBatch(manager, scope, task.id, ControlledCatalogBatch, ControlledGoodsRecord, ControlledCatalogImportIssue);
|
|
215
216
|
await this.deleteEmptyImportCatalogBatch(manager, scope, task.id, SanctionCatalogBatch, SanctionedCompanyRecord, SanctionCatalogImportIssue);
|
|
216
217
|
}
|
|
217
218
|
async deleteEmptyImportCatalogBatch(manager, scope, importTaskId, batchEntity, recordEntity, issueEntity) {
|
|
218
219
|
const batchRepo = manager.getRepository(batchEntity);
|
|
219
|
-
const batch = await batchRepo.findOne({ where: { ...activeScope(scope), importTaskId } });
|
|
220
|
+
const batch = await batchRepo.findOne({ where: { ...activeScope(scope), importTaskId }, withDeleted: true });
|
|
220
221
|
if (!batch)
|
|
221
222
|
return;
|
|
222
223
|
const committedRows = await manager.getRepository(recordEntity).count({
|
|
223
|
-
where: { ...activeScope(scope), batchId: batch.id }
|
|
224
|
+
where: { ...activeScope(scope), batchId: batch.id }, withDeleted: true
|
|
224
225
|
});
|
|
225
226
|
if (committedRows > 0)
|
|
226
227
|
return;
|
|
@@ -231,8 +232,8 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
231
232
|
const where = { ...activeScope(scope), sourceFileId: task.sourceFileId };
|
|
232
233
|
const [otherTasks, controlledBatches, sanctionBatches, contracts, orders] = await Promise.all([
|
|
233
234
|
manager.getRepository(ImportTask).count({ where: { ...where, id: Not(task.id) } }),
|
|
234
|
-
manager.getRepository(ControlledCatalogBatch).count({ where }),
|
|
235
|
-
manager.getRepository(SanctionCatalogBatch).count({ where }),
|
|
235
|
+
manager.getRepository(ControlledCatalogBatch).count({ where, withDeleted: true }),
|
|
236
|
+
manager.getRepository(SanctionCatalogBatch).count({ where, withDeleted: true }),
|
|
236
237
|
manager.getRepository(CustomerContract).count({ where }),
|
|
237
238
|
manager.getRepository(PurchaseOrder).count({ where })
|
|
238
239
|
]);
|
|
@@ -291,23 +292,27 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
291
292
|
return { run: publicCustomerContractReanalysisDto(run), consumed };
|
|
292
293
|
});
|
|
293
294
|
}
|
|
294
|
-
async listControlledBatches(scope, query = {}) { return this.pagedQuery(ControlledCatalogBatch, scope, query, ['sourceFileName'], { catalogType: query.parameters?.catalogType }); }
|
|
295
|
-
async listSanctionBatches(scope, query = {}) { return this.pagedQuery(SanctionCatalogBatch, scope, query, ['sourceFileName']); }
|
|
295
|
+
async listControlledBatches(scope, query = {}) { return this.pagedQuery(ControlledCatalogBatch, scope, query, ['sourceFileName'], { lifecycleStatus: 'ACTIVE', catalogType: query.parameters?.catalogType }); }
|
|
296
|
+
async listSanctionBatches(scope, query = {}) { return this.pagedQuery(SanctionCatalogBatch, scope, query, ['sourceFileName'], { lifecycleStatus: 'ACTIVE' }); }
|
|
296
297
|
async listControlledBatchHistory(scope, query = {}) { return this.pagedBatchHistoryQuery(ControlledCatalogBatch, scope, query, { catalogType: query.parameters?.catalogType }); }
|
|
297
298
|
async listSanctionBatchHistory(scope, query = {}) { return this.pagedBatchHistoryQuery(SanctionCatalogBatch, scope, query); }
|
|
298
299
|
async saveControlledGoods(scope, input) {
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
300
|
+
const persist = async (manager) => {
|
|
301
|
+
await lockCatalogMutation(manager, scope, this.dataSource.options?.type === 'postgres');
|
|
302
|
+
const repo = manager.getRepository(ControlledGoodsRecord);
|
|
303
|
+
const productName = input.productName.trim();
|
|
304
|
+
const normalizedProductName = productName.trim();
|
|
305
|
+
const normalizedHsCode = String(input.hsCode ?? '').replace(/\s/g, '');
|
|
306
|
+
const duplicate = await repo.findOne({ where: { ...activeScope(scope), lifecycleStatus: 'ACTIVE', catalogType: input.catalogType, normalizedProductName, normalizedHsCode, deletedAt: IsNull() } });
|
|
307
|
+
if (duplicate)
|
|
308
|
+
throw new DomainError(DomainErrorCodes.DUPLICATE_CATALOG_RECORD, '相同目录类型、商品名称和海关编码的记录已存在', { id: duplicate.id });
|
|
309
|
+
return controlledGoodsDto(await repo.save(repo.create({ ...auditScope(scope), lifecycleStatus: 'ACTIVE', sourceType: input.sourceType ?? 'MANUAL_ENTRY', catalogType: input.catalogType, productName, normalizedProductName, hsCode: input.hsCode ?? null, normalizedHsCode, controlDescription: input.controlDescription ?? null, sourceLocation: input.sourceLocation ?? null, sourceContent: input.sourceContent ?? null })));
|
|
310
|
+
};
|
|
311
|
+
return this.dataSource.options?.type === 'postgres' ? this.dataSource.transaction(persist) : persist(this.dataSource);
|
|
307
312
|
}
|
|
308
313
|
async updateControlledGoods(scope, id, input) {
|
|
309
314
|
const repo = this.dataSource.getRepository(ControlledGoodsRecord);
|
|
310
|
-
const item = await required(repo.findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() } }), '管控商品不存在');
|
|
315
|
+
const item = await required(repo.findOne({ where: { ...activeScope(scope), id, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() } }), '管控商品不存在');
|
|
311
316
|
Object.assign(item, pick(input, ['catalogType', 'productName', 'hsCode', 'controlDescription']));
|
|
312
317
|
item.normalizedProductName = item.productName.trim();
|
|
313
318
|
item.normalizedHsCode = String(item.hsCode ?? '').replace(/\s/g, '');
|
|
@@ -450,6 +455,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
450
455
|
}
|
|
451
456
|
async persistControlledCatalog(scope, input, localizedRows, claimId) {
|
|
452
457
|
return this.dataSource.transaction(async (manager) => {
|
|
458
|
+
await lockCatalogMutation(manager, scope, this.dataSource.options?.type === 'postgres');
|
|
453
459
|
const batchRepo = manager.getRepository(ControlledCatalogBatch);
|
|
454
460
|
const recordRepo = manager.getRepository(ControlledGoodsRecord);
|
|
455
461
|
const taskRepo = manager.getRepository(ImportTask);
|
|
@@ -463,7 +469,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
463
469
|
await taskRepo.save(task);
|
|
464
470
|
const now = new Date();
|
|
465
471
|
if (input.replaceBatchId) {
|
|
466
|
-
const old = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, deletedAt: IsNull() } }), '被覆盖目录批次不存在');
|
|
472
|
+
const old = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() } }), '被覆盖目录批次不存在');
|
|
467
473
|
old.deletedAt = now;
|
|
468
474
|
old.deletedById = scope.userId ?? null;
|
|
469
475
|
old.deleteOperation = 'CATALOG_REPLACE';
|
|
@@ -471,7 +477,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
471
477
|
await batchRepo.save(old);
|
|
472
478
|
await recordRepo.update({ ...activeScope(scope), batchId: old.id, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'CATALOG_REPLACE' });
|
|
473
479
|
}
|
|
474
|
-
const batch = await batchRepo.save(batchRepo.create({ ...auditScope(scope), catalogType: input.catalogType, sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName, importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null, totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0, importTaskId: input.importTaskId, riskRecalculatedAt: null }));
|
|
480
|
+
const batch = await batchRepo.save(batchRepo.create({ ...auditScope(scope), lifecycleStatus: 'ACTIVE', catalogType: input.catalogType, sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName, importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null, totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0, importTaskId: input.importTaskId, riskRecalculatedAt: null }));
|
|
475
481
|
await this.appendControlledCatalogRows(manager, scope, batch, input.rows, localizedRows);
|
|
476
482
|
await batchRepo.save(batch);
|
|
477
483
|
task.status = 'SUCCEEDED';
|
|
@@ -485,6 +491,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
485
491
|
}
|
|
486
492
|
async appendCompletedControlledCatalog(scope, input, localizedRows, expectedBatchId) {
|
|
487
493
|
return this.dataSource.transaction(async (manager) => {
|
|
494
|
+
await lockCatalogMutation(manager, scope, this.dataSource.options?.type === 'postgres');
|
|
488
495
|
const task = await required(manager.getRepository(ImportTask).findOne({
|
|
489
496
|
where: { ...activeScope(scope), id: input.importTaskId },
|
|
490
497
|
lock: { mode: 'pessimistic_write' }
|
|
@@ -537,9 +544,11 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
537
544
|
await issueRepo.save(issueRepo.create({ ...createdScope(scope), batchId: batch.id, issueType: 'INVALID', sourceSequence: batch.totalCount, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null, parsedPayload: row, message: localized.error }));
|
|
538
545
|
continue;
|
|
539
546
|
}
|
|
540
|
-
const duplicate = await recordRepo.findOne({ where:
|
|
541
|
-
|
|
542
|
-
|
|
547
|
+
const duplicate = await recordRepo.findOne({ where: [
|
|
548
|
+
{ ...activeScope(scope), batchId: batch.id, lifecycleStatus: batch.lifecycleStatus, catalogType: batch.catalogType, normalizedProductName: productName, normalizedHsCode, deletedAt: IsNull() },
|
|
549
|
+
...(batch.replacedBatchId ? [{ ...activeScope(scope), batchId: IsNull(), lifecycleStatus: 'ACTIVE', catalogType: batch.catalogType, normalizedProductName: productName, normalizedHsCode, deletedAt: IsNull() }] : []),
|
|
550
|
+
{ ...activeScope(scope), lifecycleStatus: 'ACTIVE', catalogType: batch.catalogType, normalizedProductName: productName, normalizedHsCode, deletedAt: IsNull(), ...(batch.replacedBatchId ? { batchId: Not(batch.replacedBatchId) } : {}) }
|
|
551
|
+
] });
|
|
543
552
|
if (duplicate && await this.catalogIssueExists(issueRepo, scope, batch.id, 'DUPLICATE', row))
|
|
544
553
|
continue;
|
|
545
554
|
changed = true;
|
|
@@ -549,7 +558,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
549
558
|
await issueRepo.save(issueRepo.create({ ...createdScope(scope), batchId: batch.id, issueType: 'DUPLICATE', sourceSequence: batch.totalCount, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null, parsedPayload: row, message: '目录中已存在相同商品名称和海关编码' }));
|
|
550
559
|
continue;
|
|
551
560
|
}
|
|
552
|
-
await recordRepo.save(recordRepo.create({ ...auditScope(scope), batchId: batch.id, sourceType: 'FILE_IMPORT', catalogType: batch.catalogType, productName, normalizedProductName: productName, hsCode: row.hsCode ?? null, normalizedHsCode, controlDescription: localized.controlDescription, originalProductName: localized.originalProductName, originalControlDescription: localized.originalControlDescription, translationSource: localized.translationSource, controlCode: row.controlCode ?? null, sourceSection: row.sourceSection ?? null, sourceOrdinal: row.sourceOrdinal ?? null, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null }));
|
|
561
|
+
await recordRepo.save(recordRepo.create({ ...auditScope(scope), batchId: batch.id, lifecycleStatus: batch.lifecycleStatus, sourceType: 'FILE_IMPORT', catalogType: batch.catalogType, productName, normalizedProductName: productName, hsCode: row.hsCode ?? null, normalizedHsCode, controlDescription: localized.controlDescription, originalProductName: localized.originalProductName, originalControlDescription: localized.originalControlDescription, translationSource: localized.translationSource, controlCode: row.controlCode ?? null, sourceSection: row.sourceSection ?? null, sourceOrdinal: row.sourceOrdinal ?? null, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null }));
|
|
553
562
|
batch.successCount += 1;
|
|
554
563
|
batch.riskRecalculatedAt = null;
|
|
555
564
|
}
|
|
@@ -696,20 +705,24 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
696
705
|
return { fileName: `${batch.sourceFileName}-导入问题明细.xlsx`, buffer: await renderImportIssuesWorkbook(issues) };
|
|
697
706
|
}
|
|
698
707
|
async saveSanctionedCompany(scope, input) {
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
708
|
+
const persist = async (manager) => {
|
|
709
|
+
await lockCatalogMutation(manager, scope, this.dataSource.options?.type === 'postgres');
|
|
710
|
+
const repo = manager.getRepository(SanctionedCompanyRecord);
|
|
711
|
+
const companyName = input.companyName.trim();
|
|
712
|
+
const normalizedCompanyName = normalizeExactName(companyName);
|
|
713
|
+
const normalizedSanctionListSource = normalizeSanctionSource(input.sanctionListSource);
|
|
714
|
+
const duplicate = await repo.findOne({ where: { ...activeScope(scope), lifecycleStatus: 'ACTIVE', normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull() } });
|
|
715
|
+
if (duplicate)
|
|
716
|
+
throw new DomainError(DomainErrorCodes.DUPLICATE_CATALOG_RECORD, '相同公司名称和制裁名单来源的记录已存在', { id: duplicate.id });
|
|
717
|
+
return repo.save(repo.create({ ...auditScope(scope), lifecycleStatus: 'ACTIVE', sourceType: input.sourceType ?? 'MANUAL_ENTRY', companyName, normalizedCompanyName, countryOrRegion: input.countryOrRegion ?? null, sanctionListSource: input.sanctionListSource ?? null, normalizedSanctionListSource, sanctionReason: input.sanctionReason ?? null, effectiveDate: input.effectiveDate ?? null, remarks: input.remarks ?? null, sourceLocation: input.sourceLocation ?? null, sourceContent: input.sourceContent ?? null }));
|
|
718
|
+
};
|
|
719
|
+
const saved = await (this.dataSource.options?.type === 'postgres' ? this.dataSource.transaction(persist) : persist(this.dataSource));
|
|
707
720
|
await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
|
|
708
721
|
return sanctionedCompanyDto(saved);
|
|
709
722
|
}
|
|
710
723
|
async updateSanctionedCompany(scope, id, input) {
|
|
711
724
|
const repo = this.dataSource.getRepository(SanctionedCompanyRecord);
|
|
712
|
-
const item = await required(repo.findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() } }), '制裁公司不存在');
|
|
725
|
+
const item = await required(repo.findOne({ where: { ...activeScope(scope), id, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() } }), '制裁公司不存在');
|
|
713
726
|
Object.assign(item, pick(input, ['companyName', 'countryOrRegion', 'sanctionListSource', 'sanctionReason', 'effectiveDate', 'remarks']));
|
|
714
727
|
item.normalizedCompanyName = normalizeExactName(item.companyName);
|
|
715
728
|
item.normalizedSanctionListSource = normalizeSanctionSource(item.sanctionListSource);
|
|
@@ -720,6 +733,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
720
733
|
}
|
|
721
734
|
async importSanctionCatalog(scope, input) {
|
|
722
735
|
const batch = await this.dataSource.transaction(async (manager) => {
|
|
736
|
+
await lockCatalogMutation(manager, scope, this.dataSource.options?.type === 'postgres');
|
|
723
737
|
const batchRepo = manager.getRepository(SanctionCatalogBatch);
|
|
724
738
|
const recordRepo = manager.getRepository(SanctionedCompanyRecord);
|
|
725
739
|
const issueRepo = manager.getRepository(SanctionCatalogImportIssue);
|
|
@@ -736,7 +750,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
736
750
|
await taskRepo.save(task);
|
|
737
751
|
const now = new Date();
|
|
738
752
|
if (input.replaceBatchId) {
|
|
739
|
-
const old = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, deletedAt: IsNull() } }), '被覆盖制裁批次不存在');
|
|
753
|
+
const old = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() } }), '被覆盖制裁批次不存在');
|
|
740
754
|
old.deletedAt = now;
|
|
741
755
|
old.deletedById = scope.userId ?? null;
|
|
742
756
|
old.deleteOperation = 'SANCTION_CATALOG_REPLACE';
|
|
@@ -744,7 +758,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
744
758
|
await batchRepo.save(old);
|
|
745
759
|
await recordRepo.update({ ...activeScope(scope), batchId: old.id, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SANCTION_CATALOG_REPLACE' });
|
|
746
760
|
}
|
|
747
|
-
result = await batchRepo.save(batchRepo.create({ ...auditScope(scope), sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName, importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null, totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0, importTaskId: input.importTaskId }));
|
|
761
|
+
result = await batchRepo.save(batchRepo.create({ ...auditScope(scope), lifecycleStatus: 'ACTIVE', sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName, importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null, totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0, importTaskId: input.importTaskId, riskRecalculatedAt: null }));
|
|
748
762
|
}
|
|
749
763
|
let changed = false;
|
|
750
764
|
for (let index = 0; index < input.rows.length; index += 1) {
|
|
@@ -761,9 +775,11 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
761
775
|
}
|
|
762
776
|
const normalizedCompanyName = normalizeExactName(companyName);
|
|
763
777
|
const normalizedSanctionListSource = normalizeSanctionSource(row.sanctionListSource);
|
|
764
|
-
const duplicate = await recordRepo.findOne({ where:
|
|
765
|
-
|
|
766
|
-
|
|
778
|
+
const duplicate = await recordRepo.findOne({ where: [
|
|
779
|
+
{ ...activeScope(scope), batchId: result.id, lifecycleStatus: result.lifecycleStatus, normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull() },
|
|
780
|
+
...(result.replacedBatchId ? [{ ...activeScope(scope), batchId: IsNull(), lifecycleStatus: 'ACTIVE', normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull() }] : []),
|
|
781
|
+
{ ...activeScope(scope), lifecycleStatus: 'ACTIVE', normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull(), ...(result.replacedBatchId ? { batchId: Not(result.replacedBatchId) } : {}) }
|
|
782
|
+
] });
|
|
767
783
|
if (duplicate && await this.catalogIssueExists(issueRepo, scope, result.id, 'DUPLICATE', row))
|
|
768
784
|
continue;
|
|
769
785
|
changed = true;
|
|
@@ -773,9 +789,11 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
773
789
|
await issueRepo.save(issueRepo.create({ ...createdScope(scope), batchId: result.id, issueType: 'DUPLICATE', sourceSequence: result.totalCount, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null, parsedPayload: row, message: '制裁目录中已存在相同公司和名单来源' }));
|
|
774
790
|
continue;
|
|
775
791
|
}
|
|
776
|
-
await recordRepo.save(recordRepo.create({ ...auditScope(scope), batchId: result.id, sourceType: 'FILE_IMPORT', companyName, normalizedCompanyName, countryOrRegion: row.countryOrRegion ?? null, sanctionListSource: row.sanctionListSource ?? null, normalizedSanctionListSource, sanctionReason: row.sanctionReason ?? null, effectiveDate: row.effectiveDate ?? null, remarks: row.remarks ?? null, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null }));
|
|
792
|
+
await recordRepo.save(recordRepo.create({ ...auditScope(scope), batchId: result.id, lifecycleStatus: result.lifecycleStatus, sourceType: 'FILE_IMPORT', companyName, normalizedCompanyName, countryOrRegion: row.countryOrRegion ?? null, sanctionListSource: row.sanctionListSource ?? null, normalizedSanctionListSource, sanctionReason: row.sanctionReason ?? null, effectiveDate: row.effectiveDate ?? null, remarks: row.remarks ?? null, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null }));
|
|
777
793
|
result.successCount += 1;
|
|
778
794
|
}
|
|
795
|
+
if (changed)
|
|
796
|
+
result.riskRecalculatedAt = null;
|
|
779
797
|
const saved = !appending || changed ? await batchRepo.save(result) : result;
|
|
780
798
|
if (!appending) {
|
|
781
799
|
task.status = 'SUCCEEDED';
|
|
@@ -785,7 +803,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
785
803
|
}
|
|
786
804
|
return saved;
|
|
787
805
|
});
|
|
788
|
-
await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
|
|
806
|
+
await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED', batch.id);
|
|
789
807
|
return batch;
|
|
790
808
|
}
|
|
791
809
|
async beginFastCatalogImport(scope, input) {
|
|
@@ -802,25 +820,17 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
802
820
|
return { committedCursor: task.progress.committedCursor, batchId: task.resultEntityId };
|
|
803
821
|
}
|
|
804
822
|
assertImportTaskResultWritable(task);
|
|
805
|
-
const now = new Date();
|
|
806
823
|
let batchId;
|
|
807
824
|
if (task.documentType === 'CONTROLLED_CATALOG') {
|
|
808
825
|
const catalogType = input.catalogType;
|
|
809
826
|
if (!catalogType)
|
|
810
827
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '管控目录缺少目录类型');
|
|
811
828
|
const batchRepo = manager.getRepository(ControlledCatalogBatch);
|
|
812
|
-
const recordRepo = manager.getRepository(ControlledGoodsRecord);
|
|
813
829
|
if (input.replaceBatchId) {
|
|
814
|
-
|
|
815
|
-
old.deletedAt = now;
|
|
816
|
-
old.deletedById = scope.userId ?? null;
|
|
817
|
-
old.deleteOperation = 'CATALOG_REPLACE';
|
|
818
|
-
old.updatedById = scope.userId ?? null;
|
|
819
|
-
await batchRepo.save(old);
|
|
820
|
-
await recordRepo.update({ ...activeScope(scope), batchId: old.id, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'CATALOG_REPLACE' });
|
|
830
|
+
await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() } }), '被覆盖目录批次不存在');
|
|
821
831
|
}
|
|
822
832
|
const batch = await batchRepo.save(batchRepo.create({
|
|
823
|
-
...auditScope(scope), catalogType, sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName,
|
|
833
|
+
...auditScope(scope), lifecycleStatus: 'DRAFT', catalogType, sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName,
|
|
824
834
|
importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null,
|
|
825
835
|
totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0, skippedCount: 0,
|
|
826
836
|
inferenceMetadata: input.inferenceMetadata ?? null,
|
|
@@ -830,21 +840,14 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
830
840
|
}
|
|
831
841
|
else {
|
|
832
842
|
const batchRepo = manager.getRepository(SanctionCatalogBatch);
|
|
833
|
-
const recordRepo = manager.getRepository(SanctionedCompanyRecord);
|
|
834
843
|
if (input.replaceBatchId) {
|
|
835
|
-
|
|
836
|
-
old.deletedAt = now;
|
|
837
|
-
old.deletedById = scope.userId ?? null;
|
|
838
|
-
old.deleteOperation = 'SANCTION_CATALOG_REPLACE';
|
|
839
|
-
old.updatedById = scope.userId ?? null;
|
|
840
|
-
await batchRepo.save(old);
|
|
841
|
-
await recordRepo.update({ ...activeScope(scope), batchId: old.id, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SANCTION_CATALOG_REPLACE' });
|
|
844
|
+
await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() } }), '被覆盖制裁目录批次不存在');
|
|
842
845
|
}
|
|
843
846
|
const batch = await batchRepo.save(batchRepo.create({
|
|
844
|
-
...auditScope(scope), sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName,
|
|
847
|
+
...auditScope(scope), lifecycleStatus: 'DRAFT', sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName,
|
|
845
848
|
importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null,
|
|
846
849
|
totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0, skippedCount: 0,
|
|
847
|
-
inferenceMetadata: input.inferenceMetadata ?? null, importTaskId: task.id
|
|
850
|
+
inferenceMetadata: input.inferenceMetadata ?? null, importTaskId: task.id, riskRecalculatedAt: null
|
|
848
851
|
}));
|
|
849
852
|
batchId = batch.id;
|
|
850
853
|
}
|
|
@@ -857,43 +860,47 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
857
860
|
});
|
|
858
861
|
}
|
|
859
862
|
async appendFastCatalogBatch(scope, input) {
|
|
863
|
+
const preflight = await this.dataSource.transaction(async (manager) => {
|
|
864
|
+
const locked = await this.lockFastCatalogAppend(manager, scope, input.taskId);
|
|
865
|
+
if (locked.task.progress.committedCursor >= input.endCursor && input.endCursor > input.startCursor) {
|
|
866
|
+
return locked.task.progress;
|
|
867
|
+
}
|
|
868
|
+
if (locked.task.progress.committedCursor !== input.startCursor) {
|
|
869
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入游标不一致');
|
|
870
|
+
}
|
|
871
|
+
return null;
|
|
872
|
+
});
|
|
873
|
+
if (preflight)
|
|
874
|
+
return preflight;
|
|
860
875
|
const controlledRows = input.candidates.flatMap(candidate => candidate.kind === 'CONTROLLED'
|
|
861
876
|
? candidateToControlledRows(candidate)
|
|
862
877
|
: []);
|
|
863
878
|
const localizedRows = controlledRows.length ? await this.localizeControlledCatalogRows(controlledRows, scope.organizationId) : [];
|
|
864
879
|
return this.dataSource.transaction(async (manager) => {
|
|
880
|
+
const { task, controlledBatch, sanctionBatch } = await this.lockFastCatalogAppend(manager, scope, input.taskId);
|
|
865
881
|
const taskRepo = manager.getRepository(ImportTask);
|
|
866
|
-
const task = await required(taskRepo.findOne({
|
|
867
|
-
where: { ...activeScope(scope), id: input.taskId },
|
|
868
|
-
lock: { mode: 'pessimistic_write' }
|
|
869
|
-
}), '解析任务不存在');
|
|
870
|
-
if (!task.progress || task.progress.mode !== 'FAST_PIPELINE' || !task.resultEntityId) {
|
|
871
|
-
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入尚未初始化');
|
|
872
|
-
}
|
|
873
882
|
if (task.progress.committedCursor >= input.endCursor && input.endCursor > input.startCursor)
|
|
874
883
|
return task.progress;
|
|
875
884
|
if (task.progress.committedCursor !== input.startCursor) {
|
|
876
885
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入游标不一致');
|
|
877
886
|
}
|
|
878
887
|
if (task.documentType === 'CONTROLLED_CATALOG') {
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
await
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
task.progress.
|
|
885
|
-
task.progress.
|
|
886
|
-
task.progress.invalidCount = batch.invalidCount;
|
|
887
|
-
task.progress.skippedCount = batch.skippedCount;
|
|
888
|
+
await this.appendControlledCatalogRows(manager, scope, controlledBatch, controlledRows, localizedRows);
|
|
889
|
+
await this.appendCatalogSkippedSources(manager.getRepository(ControlledCatalogImportIssue), scope, controlledBatch, input.skipped);
|
|
890
|
+
await manager.getRepository(ControlledCatalogBatch).save(controlledBatch);
|
|
891
|
+
task.progress.successCount = controlledBatch.successCount;
|
|
892
|
+
task.progress.duplicateCount = controlledBatch.duplicateCount;
|
|
893
|
+
task.progress.invalidCount = controlledBatch.invalidCount;
|
|
894
|
+
task.progress.skippedCount = controlledBatch.skippedCount;
|
|
888
895
|
}
|
|
889
896
|
else {
|
|
890
|
-
const
|
|
891
|
-
await this.appendCatalogSkippedSources(manager.getRepository(SanctionCatalogImportIssue), scope,
|
|
892
|
-
await manager.getRepository(SanctionCatalogBatch).save(
|
|
893
|
-
task.progress.successCount =
|
|
894
|
-
task.progress.duplicateCount =
|
|
895
|
-
task.progress.invalidCount =
|
|
896
|
-
task.progress.skippedCount =
|
|
897
|
+
const savedBatch = await this.appendFastSanctionCandidates(manager, scope, task.resultEntityId, input.candidates, sanctionBatch);
|
|
898
|
+
await this.appendCatalogSkippedSources(manager.getRepository(SanctionCatalogImportIssue), scope, savedBatch, input.skipped);
|
|
899
|
+
await manager.getRepository(SanctionCatalogBatch).save(savedBatch);
|
|
900
|
+
task.progress.successCount = savedBatch.successCount;
|
|
901
|
+
task.progress.duplicateCount = savedBatch.duplicateCount;
|
|
902
|
+
task.progress.invalidCount = savedBatch.invalidCount;
|
|
903
|
+
task.progress.skippedCount = savedBatch.skippedCount;
|
|
897
904
|
}
|
|
898
905
|
let insertedReviewCount = 0;
|
|
899
906
|
if (input.reviewBlocks.length) {
|
|
@@ -910,7 +917,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
910
917
|
confidence: block.confidence, status: 'PENDING', ordinal: block.ordinal,
|
|
911
918
|
sourceLocation: block.sourceLocation || null, sourceContent: block.sourceContent || null,
|
|
912
919
|
payload: block.payload, reasons: block.reasons,
|
|
913
|
-
leaseToken: null, leaseExpiresAt: null, resolvedAt: null
|
|
920
|
+
leaseToken: null, leaseExpiresAt: null, resolvedAt: null, resolutionFingerprint: null
|
|
914
921
|
}));
|
|
915
922
|
insertedReviewCount += 1;
|
|
916
923
|
}
|
|
@@ -926,6 +933,33 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
926
933
|
return task.progress;
|
|
927
934
|
});
|
|
928
935
|
}
|
|
936
|
+
async lockFastCatalogAppend(manager, scope, taskId) {
|
|
937
|
+
const task = await required(manager.getRepository(ImportTask).findOne({
|
|
938
|
+
where: { ...activeScope(scope), id: taskId }, lock: { mode: 'pessimistic_write' }
|
|
939
|
+
}), '解析任务不存在');
|
|
940
|
+
if (task.status !== 'PERSISTING' || !task.progress || task.progress.mode !== 'FAST_PIPELINE' || !task.resultEntityId) {
|
|
941
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入状态无效');
|
|
942
|
+
}
|
|
943
|
+
const controlledBatch = task.documentType === 'CONTROLLED_CATALOG'
|
|
944
|
+
? await required(manager.getRepository(ControlledCatalogBatch).findOne({
|
|
945
|
+
where: { ...activeScope(scope), id: task.resultEntityId }, lock: { mode: 'pessimistic_write' }
|
|
946
|
+
}), '管控目录批次不存在')
|
|
947
|
+
: null;
|
|
948
|
+
const sanctionBatch = task.documentType === 'SANCTION_CATALOG'
|
|
949
|
+
? await required(manager.getRepository(SanctionCatalogBatch).findOne({
|
|
950
|
+
where: { ...activeScope(scope), id: task.resultEntityId }, lock: { mode: 'pessimistic_write' }
|
|
951
|
+
}), '制裁目录批次不存在')
|
|
952
|
+
: null;
|
|
953
|
+
const batch = controlledBatch ?? sanctionBatch;
|
|
954
|
+
if (!batch || batch.importTaskId !== task.id || (batch.lifecycleStatus !== 'DRAFT' && batch.lifecycleStatus !== 'REVIEWING')) {
|
|
955
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入状态无效');
|
|
956
|
+
}
|
|
957
|
+
return {
|
|
958
|
+
task: task,
|
|
959
|
+
controlledBatch,
|
|
960
|
+
sanctionBatch
|
|
961
|
+
};
|
|
962
|
+
}
|
|
929
963
|
async finalizeFastCatalogImport(scope, taskId) {
|
|
930
964
|
const task = await this.dataSource.transaction(async (manager) => {
|
|
931
965
|
const repo = manager.getRepository(ImportTask);
|
|
@@ -935,7 +969,12 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
935
969
|
if (!current.progress || current.progress.mode !== 'FAST_PIPELINE') {
|
|
936
970
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入进度不存在');
|
|
937
971
|
}
|
|
972
|
+
if (current.status === 'SUCCEEDED') {
|
|
973
|
+
await this.assertCatalogBatchPublished(manager, scope, current);
|
|
974
|
+
return current;
|
|
975
|
+
}
|
|
938
976
|
if (current.progress.reviewPendingCount > 0) {
|
|
977
|
+
await this.markCatalogDraftReviewing(manager, scope, current);
|
|
939
978
|
current.status = 'AWAITING_REVIEW';
|
|
940
979
|
current.progress.stage = 'REVIEWING';
|
|
941
980
|
current.progress.observerStatus = 'PENDING';
|
|
@@ -953,6 +992,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
953
992
|
await this.applyImportTaskFailure(manager, scope, current, 'FAILED', outcome.errorCode, outcome.errorMessage);
|
|
954
993
|
}
|
|
955
994
|
else {
|
|
995
|
+
await this.publishCatalogDraft(manager, scope, current);
|
|
956
996
|
current.status = 'SUCCEEDED';
|
|
957
997
|
current.progress.stage = 'SUCCEEDED';
|
|
958
998
|
current.progress.observerStatus = 'COMPLETED';
|
|
@@ -969,10 +1009,125 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
969
1009
|
if (task.documentType === 'CONTROLLED_CATALOG')
|
|
970
1010
|
await this.recalculateProductRisks(scope, 'CATALOG_CHANGED', task.resultEntityId ?? undefined);
|
|
971
1011
|
else
|
|
972
|
-
await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
|
|
1012
|
+
await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED', task.resultEntityId ?? undefined);
|
|
973
1013
|
}
|
|
974
1014
|
return task;
|
|
975
1015
|
}
|
|
1016
|
+
async resolveStalledCatalogImport(scope, taskId, decision) {
|
|
1017
|
+
if (!StalledCatalogImportDecisions.includes(decision)) {
|
|
1018
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录恢复决定无效');
|
|
1019
|
+
}
|
|
1020
|
+
const result = await this.dataSource.transaction(async (manager) => {
|
|
1021
|
+
const taskRepo = manager.getRepository(ImportTask);
|
|
1022
|
+
const unfinishedTaskWhere = { ...activeScope(scope), id: taskId, deletedAt: IsNull(), completedAt: IsNull() };
|
|
1023
|
+
const task = await required(taskRepo.findOne({
|
|
1024
|
+
where: decision === 'PUBLISH_VALID_ROWS'
|
|
1025
|
+
? [unfinishedTaskWhere, { ...activeScope(scope), id: taskId, deletedAt: IsNull(), status: 'SUCCEEDED' }]
|
|
1026
|
+
: unfinishedTaskWhere,
|
|
1027
|
+
lock: { mode: 'pessimistic_write' }
|
|
1028
|
+
}), '解析任务不存在');
|
|
1029
|
+
if (task.deletedAt != null ||
|
|
1030
|
+
(task.documentType !== 'CONTROLLED_CATALOG' && task.documentType !== 'SANCTION_CATALOG') ||
|
|
1031
|
+
task.progress?.mode !== 'FAST_PIPELINE' ||
|
|
1032
|
+
!task.resultEntityId) {
|
|
1033
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '仅待复核的快速目录导入任务支持显式恢复');
|
|
1034
|
+
}
|
|
1035
|
+
const progress = task.progress;
|
|
1036
|
+
const batchId = task.resultEntityId;
|
|
1037
|
+
const publishedRetry = decision === 'PUBLISH_VALID_ROWS' && task.status === 'SUCCEEDED' && task.completedAt != null;
|
|
1038
|
+
const awaitingRecovery = task.status === 'AWAITING_REVIEW' && task.completedAt == null;
|
|
1039
|
+
if (!publishedRetry && !awaitingRecovery)
|
|
1040
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '仅待复核的快速目录导入任务支持显式恢复');
|
|
1041
|
+
const batch = task.documentType === 'CONTROLLED_CATALOG'
|
|
1042
|
+
? await required(manager.getRepository(ControlledCatalogBatch).findOne({
|
|
1043
|
+
where: { ...activeScope(scope), id: batchId, importTaskId: task.id, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' }
|
|
1044
|
+
}), '管控目录批次不存在')
|
|
1045
|
+
: await required(manager.getRepository(SanctionCatalogBatch).findOne({
|
|
1046
|
+
where: { ...activeScope(scope), id: batchId, importTaskId: task.id, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' }
|
|
1047
|
+
}), '制裁目录批次不存在');
|
|
1048
|
+
if (batch.importTaskId !== task.id || batch.deletedAt ||
|
|
1049
|
+
(publishedRetry ? batch.lifecycleStatus !== 'ACTIVE' : !['DRAFT', 'REVIEWING', 'ACTIVE'].includes(batch.lifecycleStatus))) {
|
|
1050
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录恢复批次状态无效');
|
|
1051
|
+
}
|
|
1052
|
+
if (publishedRetry) {
|
|
1053
|
+
return {
|
|
1054
|
+
task: publicImportTaskDto(task),
|
|
1055
|
+
batchCounts: {
|
|
1056
|
+
totalCount: batch.totalCount, successCount: batch.successCount, duplicateCount: batch.duplicateCount,
|
|
1057
|
+
invalidCount: batch.invalidCount, skippedCount: batch.skippedCount ?? 0
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
const reviewRepo = manager.getRepository(CatalogReviewBlock);
|
|
1062
|
+
const unresolved = await reviewRepo.find({ where: {
|
|
1063
|
+
...activeScope(scope), importTaskId: task.id, status: In(['PENDING', 'LEASED'])
|
|
1064
|
+
}, order: { ordinal: 'ASC', id: 'ASC' } });
|
|
1065
|
+
const resolvedAt = new Date();
|
|
1066
|
+
for (const block of unresolved) {
|
|
1067
|
+
block.status = 'REJECTED';
|
|
1068
|
+
block.leaseExpiresAt = null;
|
|
1069
|
+
block.resolvedAt = resolvedAt;
|
|
1070
|
+
}
|
|
1071
|
+
if (decision === 'PUBLISH_VALID_ROWS') {
|
|
1072
|
+
await this.appendStalledCatalogReviewIssues(manager, scope, task, batch, unresolved);
|
|
1073
|
+
}
|
|
1074
|
+
if (unresolved.length > 0)
|
|
1075
|
+
await reviewRepo.save(unresolved);
|
|
1076
|
+
await this.reconcileCatalogReviewPendingCount(reviewRepo, scope, task);
|
|
1077
|
+
if (progress.reviewPendingCount !== 0) {
|
|
1078
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录恢复后仍存在待复核块');
|
|
1079
|
+
}
|
|
1080
|
+
progress.reviewRejectedCount += unresolved.length;
|
|
1081
|
+
progress.successCount = batch.successCount;
|
|
1082
|
+
progress.duplicateCount = batch.duplicateCount;
|
|
1083
|
+
progress.invalidCount = batch.invalidCount;
|
|
1084
|
+
progress.skippedCount = batch.skippedCount ?? 0;
|
|
1085
|
+
progress.progressVersion += 1;
|
|
1086
|
+
progress.lastProgressAt = new Date().toISOString();
|
|
1087
|
+
if (decision === 'PUBLISH_VALID_ROWS') {
|
|
1088
|
+
const outcome = catalogTerminalOutcome(batch);
|
|
1089
|
+
progress.outcome = outcome.outcome;
|
|
1090
|
+
if (outcome.status === 'FAILED') {
|
|
1091
|
+
await this.applyImportTaskFailure(manager, scope, task, 'FAILED', outcome.errorCode, outcome.errorMessage);
|
|
1092
|
+
}
|
|
1093
|
+
else {
|
|
1094
|
+
if (batch.lifecycleStatus === 'DRAFT' || batch.lifecycleStatus === 'REVIEWING') {
|
|
1095
|
+
await this.publishCatalogDraft(manager, scope, task);
|
|
1096
|
+
}
|
|
1097
|
+
task.status = 'SUCCEEDED';
|
|
1098
|
+
progress.stage = 'SUCCEEDED';
|
|
1099
|
+
progress.observerStatus = 'COMPLETED';
|
|
1100
|
+
task.errorCode = null;
|
|
1101
|
+
task.errorMessage = null;
|
|
1102
|
+
task.completedAt = new Date();
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
else {
|
|
1106
|
+
task.status = 'FAILED';
|
|
1107
|
+
progress.stage = 'FAILED';
|
|
1108
|
+
progress.observerStatus = 'FAILED';
|
|
1109
|
+
task.errorCode = 'STALLED_CATALOG_DISCARDED';
|
|
1110
|
+
task.errorMessage = '用户已明确放弃停滞目录批次,请重新导入源文件';
|
|
1111
|
+
task.completedAt = new Date();
|
|
1112
|
+
await this.failCatalogDraft(manager, scope, task, true);
|
|
1113
|
+
}
|
|
1114
|
+
await taskRepo.save(task);
|
|
1115
|
+
return {
|
|
1116
|
+
task: publicImportTaskDto(task),
|
|
1117
|
+
batchCounts: {
|
|
1118
|
+
totalCount: batch.totalCount, successCount: batch.successCount, duplicateCount: batch.duplicateCount,
|
|
1119
|
+
invalidCount: batch.invalidCount, skippedCount: batch.skippedCount ?? 0
|
|
1120
|
+
}
|
|
1121
|
+
};
|
|
1122
|
+
});
|
|
1123
|
+
if (decision === 'PUBLISH_VALID_ROWS' && result.task.status === 'SUCCEEDED' && result.batchCounts.successCount > 0) {
|
|
1124
|
+
if (result.task.documentType === 'CONTROLLED_CATALOG')
|
|
1125
|
+
await this.recalculateProductRisks(scope, 'CATALOG_CHANGED', result.task.resultEntityId ?? undefined);
|
|
1126
|
+
else
|
|
1127
|
+
await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED', result.task.resultEntityId ?? undefined);
|
|
1128
|
+
}
|
|
1129
|
+
return result;
|
|
1130
|
+
}
|
|
976
1131
|
async waitImportProgress(scope, taskId, afterProgressVersion = 0, waitMs = 10_000) {
|
|
977
1132
|
const deadline = Date.now() + Math.max(0, Math.min(waitMs, 10_000));
|
|
978
1133
|
while (true) {
|
|
@@ -1002,6 +1157,13 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1002
1157
|
await repo.update({
|
|
1003
1158
|
...activeScope(scope), importTaskId: taskId, status: 'LEASED', leaseExpiresAt: LessThanOrEqual(new Date())
|
|
1004
1159
|
}, { status: 'PENDING', leaseToken: null, leaseExpiresAt: null });
|
|
1160
|
+
const previousPendingCount = task.progress?.reviewPendingCount;
|
|
1161
|
+
const pendingCount = await this.reconcileCatalogReviewPendingCount(repo, scope, task);
|
|
1162
|
+
if (task.progress && previousPendingCount !== pendingCount) {
|
|
1163
|
+
task.progress.progressVersion += 1;
|
|
1164
|
+
task.progress.lastProgressAt = new Date().toISOString();
|
|
1165
|
+
await manager.getRepository(ImportTask).save(task);
|
|
1166
|
+
}
|
|
1005
1167
|
const blocks = await repo.createQueryBuilder('block')
|
|
1006
1168
|
.setLock('pessimistic_write')
|
|
1007
1169
|
.setOnLocked('skip_locked')
|
|
@@ -1037,7 +1199,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1037
1199
|
if (!leaseToken.trim() || items.length === 0 || items.length > CATALOG_REVIEW_BATCH_LIMIT) {
|
|
1038
1200
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '复核提交参数无效');
|
|
1039
1201
|
}
|
|
1040
|
-
|
|
1202
|
+
const result = await this.dataSource.transaction(async (manager) => {
|
|
1041
1203
|
const taskRepo = manager.getRepository(ImportTask);
|
|
1042
1204
|
const task = await required(taskRepo.findOne({
|
|
1043
1205
|
where: { ...activeScope(scope), id: taskId }, lock: { mode: 'pessimistic_write' }
|
|
@@ -1045,6 +1207,30 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1045
1207
|
if (!task.progress || !task.resultEntityId)
|
|
1046
1208
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录复核任务状态无效');
|
|
1047
1209
|
const reviewRepo = manager.getRepository(CatalogReviewBlock);
|
|
1210
|
+
if (task.status === 'SUCCEEDED')
|
|
1211
|
+
await this.assertCatalogBatchPublished(manager, scope, task);
|
|
1212
|
+
const hasPriorReviewDecisions = task.progress.reviewResolvedCount > 0 || task.progress.reviewRejectedCount > 0;
|
|
1213
|
+
const resolvedBlocks = task.status === 'SUCCEEDED' || hasPriorReviewDecisions
|
|
1214
|
+
? await reviewRepo.find({ where: {
|
|
1215
|
+
...activeScope(scope), importTaskId: taskId, id: In(items.map(item => item.blockId)),
|
|
1216
|
+
status: In(['RESOLVED', 'REJECTED']), leaseToken
|
|
1217
|
+
} })
|
|
1218
|
+
: [];
|
|
1219
|
+
if (resolvedBlocks.length > 0 || task.status === 'SUCCEEDED') {
|
|
1220
|
+
const matchingRetry = catalogReviewRetryMatches(resolvedBlocks, items);
|
|
1221
|
+
if (!matchingRetry)
|
|
1222
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '复核块租约无效或已过期');
|
|
1223
|
+
const remaining = await this.reconcileCatalogReviewPendingCount(reviewRepo, scope, task);
|
|
1224
|
+
if (task.status === 'SUCCEEDED' && remaining !== 0)
|
|
1225
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '已发布目录仍存在待复核块');
|
|
1226
|
+
return {
|
|
1227
|
+
resolved: items.filter(item => item.decision === 'ACCEPT').length,
|
|
1228
|
+
rejected: items.filter(item => item.decision === 'REJECT').length,
|
|
1229
|
+
remaining,
|
|
1230
|
+
errors: [],
|
|
1231
|
+
task: publicImportTaskDto(task)
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1048
1234
|
const blocks = await reviewRepo.find({ where: {
|
|
1049
1235
|
...activeScope(scope), importTaskId: taskId, id: In(items.map(item => item.blockId)),
|
|
1050
1236
|
status: 'LEASED', leaseToken
|
|
@@ -1054,6 +1240,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1054
1240
|
}
|
|
1055
1241
|
const acceptedCandidates = [];
|
|
1056
1242
|
const rejectedBlocks = [];
|
|
1243
|
+
const errors = [];
|
|
1057
1244
|
let resolved = 0;
|
|
1058
1245
|
let rejected = 0;
|
|
1059
1246
|
for (const item of items) {
|
|
@@ -1062,22 +1249,35 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1062
1249
|
block.status = 'REJECTED';
|
|
1063
1250
|
rejectedBlocks.push(block);
|
|
1064
1251
|
rejected += 1;
|
|
1252
|
+
block.resolvedAt = new Date();
|
|
1253
|
+
block.leaseExpiresAt = null;
|
|
1254
|
+
block.resolutionFingerprint = catalogReviewResolutionFingerprint(item);
|
|
1065
1255
|
}
|
|
1066
1256
|
else {
|
|
1067
|
-
const
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1257
|
+
const evaluation = evaluateCatalogReviewAccept(task.documentType, block, item.payload);
|
|
1258
|
+
if (!evaluation.candidate) {
|
|
1259
|
+
restorePendingReviewBlock(block);
|
|
1260
|
+
errors.push({ blockId: block.id, reasons: evaluation.reasons });
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
let resolutionFingerprint;
|
|
1264
|
+
try {
|
|
1265
|
+
resolutionFingerprint = catalogReviewResolutionFingerprint(item);
|
|
1072
1266
|
}
|
|
1267
|
+
catch {
|
|
1268
|
+
restorePendingReviewBlock(block);
|
|
1269
|
+
errors.push({ blockId: block.id, reasons: ['REVIEW_PAYLOAD_INVALID'] });
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
const candidate = evaluation.candidate;
|
|
1073
1273
|
acceptedCandidates.push(candidate);
|
|
1074
|
-
block.payload =
|
|
1274
|
+
block.payload = candidate.values;
|
|
1075
1275
|
block.status = 'RESOLVED';
|
|
1076
1276
|
resolved += 1;
|
|
1277
|
+
block.resolvedAt = new Date();
|
|
1278
|
+
block.leaseExpiresAt = null;
|
|
1279
|
+
block.resolutionFingerprint = resolutionFingerprint;
|
|
1077
1280
|
}
|
|
1078
|
-
block.resolvedAt = new Date();
|
|
1079
|
-
block.leaseToken = null;
|
|
1080
|
-
block.leaseExpiresAt = null;
|
|
1081
1281
|
}
|
|
1082
1282
|
if (task.documentType === 'CONTROLLED_CATALOG' && acceptedCandidates.length) {
|
|
1083
1283
|
const batchRepo = manager.getRepository(ControlledCatalogBatch);
|
|
@@ -1106,7 +1306,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1106
1306
|
await reviewRepo.save(blocks);
|
|
1107
1307
|
task.progress.reviewResolvedCount += resolved;
|
|
1108
1308
|
task.progress.reviewRejectedCount += rejected;
|
|
1109
|
-
|
|
1309
|
+
await this.reconcileCatalogReviewPendingCount(reviewRepo, scope, task);
|
|
1110
1310
|
task.progress.progressVersion += 1;
|
|
1111
1311
|
task.progress.lastProgressAt = new Date().toISOString();
|
|
1112
1312
|
if (task.progress.reviewPendingCount === 0) {
|
|
@@ -1121,6 +1321,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1121
1321
|
await this.applyImportTaskFailure(manager, scope, task, 'FAILED', outcome.errorCode, outcome.errorMessage);
|
|
1122
1322
|
}
|
|
1123
1323
|
else {
|
|
1324
|
+
await this.publishCatalogDraft(manager, scope, task);
|
|
1124
1325
|
task.status = 'SUCCEEDED';
|
|
1125
1326
|
task.progress.stage = 'SUCCEEDED';
|
|
1126
1327
|
task.progress.observerStatus = 'COMPLETED';
|
|
@@ -1130,12 +1331,28 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1130
1331
|
}
|
|
1131
1332
|
}
|
|
1132
1333
|
else {
|
|
1334
|
+
await this.markCatalogDraftReviewing(manager, scope, task);
|
|
1133
1335
|
task.status = 'AWAITING_REVIEW';
|
|
1134
1336
|
task.progress.stage = 'REVIEWING';
|
|
1135
1337
|
}
|
|
1136
1338
|
await taskRepo.save(task);
|
|
1137
|
-
return { resolved, rejected, remaining: task.progress.reviewPendingCount, task: publicImportTaskDto(task) };
|
|
1339
|
+
return { resolved, rejected, remaining: task.progress.reviewPendingCount, errors, task: publicImportTaskDto(task) };
|
|
1138
1340
|
});
|
|
1341
|
+
if (result.task.status === 'SUCCEEDED' && (result.task.progress?.successCount ?? 0) > 0) {
|
|
1342
|
+
if (result.task.documentType === 'CONTROLLED_CATALOG')
|
|
1343
|
+
await this.recalculateProductRisks(scope, 'CATALOG_CHANGED', result.task.resultEntityId ?? undefined);
|
|
1344
|
+
else
|
|
1345
|
+
await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED', result.task.resultEntityId ?? undefined);
|
|
1346
|
+
}
|
|
1347
|
+
return result;
|
|
1348
|
+
}
|
|
1349
|
+
async reconcileCatalogReviewPendingCount(reviewRepo, scope, task) {
|
|
1350
|
+
const pendingCount = await reviewRepo.count({ where: {
|
|
1351
|
+
...activeScope(scope), importTaskId: task.id, status: In(['PENDING', 'LEASED'])
|
|
1352
|
+
} });
|
|
1353
|
+
if (task.progress)
|
|
1354
|
+
task.progress.reviewPendingCount = pendingCount;
|
|
1355
|
+
return pendingCount;
|
|
1139
1356
|
}
|
|
1140
1357
|
async setImportObserverConversation(scope, taskId, conversationId) {
|
|
1141
1358
|
const normalizedConversationId = requiredId(conversationId, '观察会话标识');
|
|
@@ -1156,12 +1373,12 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1156
1373
|
return repo.save(task);
|
|
1157
1374
|
});
|
|
1158
1375
|
}
|
|
1159
|
-
async appendFastSanctionCandidates(manager, scope, batchId, candidates) {
|
|
1376
|
+
async appendFastSanctionCandidates(manager, scope, batchId, candidates, validatedBatch) {
|
|
1160
1377
|
const batchRepo = manager.getRepository(SanctionCatalogBatch);
|
|
1161
1378
|
const recordRepo = manager.getRepository(SanctionedCompanyRecord);
|
|
1162
1379
|
const aliasRepo = manager.getRepository(SanctionedCompanyAlias);
|
|
1163
1380
|
const issueRepo = manager.getRepository(SanctionCatalogImportIssue);
|
|
1164
|
-
const batch = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: batchId } }), '制裁目录批次不存在');
|
|
1381
|
+
const batch = validatedBatch ?? await required(batchRepo.findOne({ where: { ...activeScope(scope), id: batchId } }), '制裁目录批次不存在');
|
|
1165
1382
|
for (const candidate of candidates.filter(value => value.kind === 'SANCTION')) {
|
|
1166
1383
|
const companyName = String(candidate.values.companyName ?? '').trim();
|
|
1167
1384
|
const sourceLocation = candidate.evidence[0]?.sourceLocation ?? null;
|
|
@@ -1171,7 +1388,11 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1171
1388
|
const normalizedCompanyName = normalizeExactName(companyName);
|
|
1172
1389
|
const sanctionListSource = optionalString(candidate.values.sanctionListSource);
|
|
1173
1390
|
const normalizedSanctionListSource = normalizeSanctionSource(sanctionListSource);
|
|
1174
|
-
const duplicate = await recordRepo.findOne({ where:
|
|
1391
|
+
const duplicate = await recordRepo.findOne({ where: [
|
|
1392
|
+
{ ...activeScope(scope), batchId: batch.id, lifecycleStatus: batch.lifecycleStatus, normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull() },
|
|
1393
|
+
...(batch.replacedBatchId ? [{ ...activeScope(scope), batchId: IsNull(), lifecycleStatus: 'ACTIVE', normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull() }] : []),
|
|
1394
|
+
{ ...activeScope(scope), lifecycleStatus: 'ACTIVE', normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull(), ...(batch.replacedBatchId ? { batchId: Not(batch.replacedBatchId) } : {}) }
|
|
1395
|
+
] });
|
|
1175
1396
|
batch.totalCount += 1;
|
|
1176
1397
|
if (duplicate) {
|
|
1177
1398
|
batch.duplicateCount += 1;
|
|
@@ -1183,7 +1404,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1183
1404
|
continue;
|
|
1184
1405
|
}
|
|
1185
1406
|
const record = await recordRepo.save(recordRepo.create({
|
|
1186
|
-
...auditScope(scope), batchId: batch.id, sourceType: 'FILE_IMPORT', companyName, normalizedCompanyName,
|
|
1407
|
+
...auditScope(scope), batchId: batch.id, lifecycleStatus: batch.lifecycleStatus, sourceType: 'FILE_IMPORT', companyName, normalizedCompanyName,
|
|
1187
1408
|
countryOrRegion: optionalString(candidate.values.countryOrRegion), sanctionListSource,
|
|
1188
1409
|
normalizedSanctionListSource, sanctionReason: optionalString(candidate.values.sanctionReason),
|
|
1189
1410
|
effectiveDate: optionalString(candidate.values.effectiveDate), remarks: optionalString(candidate.values.remarks),
|
|
@@ -1248,6 +1469,34 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1248
1469
|
}
|
|
1249
1470
|
return batchRepo.save(batch);
|
|
1250
1471
|
}
|
|
1472
|
+
async appendStalledCatalogReviewIssues(manager, scope, task, batch, blocks) {
|
|
1473
|
+
const issueRepo = (task.documentType === 'CONTROLLED_CATALOG'
|
|
1474
|
+
? manager.getRepository(ControlledCatalogImportIssue)
|
|
1475
|
+
: manager.getRepository(SanctionCatalogImportIssue));
|
|
1476
|
+
for (const block of blocks) {
|
|
1477
|
+
const parsedPayload = {
|
|
1478
|
+
reviewBlockId: block.id, reviewDecision: 'STALLED_RECOVERY_REJECT',
|
|
1479
|
+
payload: block.payload, reasons: block.reasons
|
|
1480
|
+
};
|
|
1481
|
+
const existing = await issueRepo.findOne({ where: {
|
|
1482
|
+
...activeScope(scope), batchId: batch.id, issueType: 'INVALID', parsedPayload
|
|
1483
|
+
} });
|
|
1484
|
+
if (existing)
|
|
1485
|
+
continue;
|
|
1486
|
+
batch.totalCount += 1;
|
|
1487
|
+
batch.invalidCount += 1;
|
|
1488
|
+
batch.skippedCount += 1;
|
|
1489
|
+
await issueRepo.save(issueRepo.create({
|
|
1490
|
+
...createdScope(scope), batchId: batch.id, issueType: 'INVALID', sourceSequence: batch.totalCount,
|
|
1491
|
+
sourceLocation: block.sourceLocation, sourceContent: block.sourceContent, parsedPayload,
|
|
1492
|
+
message: `停滞目录恢复时跳过待复核来源行:${block.reasons.join(', ') || '未提供原因'}`
|
|
1493
|
+
}));
|
|
1494
|
+
}
|
|
1495
|
+
if (task.documentType === 'CONTROLLED_CATALOG')
|
|
1496
|
+
await manager.getRepository(ControlledCatalogBatch).save(batch);
|
|
1497
|
+
else
|
|
1498
|
+
await manager.getRepository(SanctionCatalogBatch).save(batch);
|
|
1499
|
+
}
|
|
1251
1500
|
async catalogBatchCounts(manager, scope, task) {
|
|
1252
1501
|
if (!task.resultEntityId)
|
|
1253
1502
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录导入批次不存在');
|
|
@@ -1265,7 +1514,224 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1265
1514
|
skippedCount: batch.skippedCount ?? 0
|
|
1266
1515
|
};
|
|
1267
1516
|
}
|
|
1268
|
-
async
|
|
1517
|
+
async assertCatalogBatchPublished(manager, scope, task) {
|
|
1518
|
+
if (!task.resultEntityId)
|
|
1519
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录导入批次不存在');
|
|
1520
|
+
if (task.documentType === 'CONTROLLED_CATALOG') {
|
|
1521
|
+
await required(manager.getRepository(ControlledCatalogBatch).findOne({
|
|
1522
|
+
where: { ...activeScope(scope), id: task.resultEntityId, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() },
|
|
1523
|
+
lock: { mode: 'pessimistic_write' }
|
|
1524
|
+
}), '已发布管控目录批次不存在');
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
if (task.documentType !== 'SANCTION_CATALOG')
|
|
1528
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '仅目录任务支持发布');
|
|
1529
|
+
await required(manager.getRepository(SanctionCatalogBatch).findOne({
|
|
1530
|
+
where: { ...activeScope(scope), id: task.resultEntityId, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() },
|
|
1531
|
+
lock: { mode: 'pessimistic_write' }
|
|
1532
|
+
}), '已发布制裁目录批次不存在');
|
|
1533
|
+
}
|
|
1534
|
+
async markCatalogDraftReviewing(manager, scope, task) {
|
|
1535
|
+
if (!task.resultEntityId)
|
|
1536
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录导入批次不存在');
|
|
1537
|
+
if (task.documentType === 'CONTROLLED_CATALOG') {
|
|
1538
|
+
const batchRepo = manager.getRepository(ControlledCatalogBatch);
|
|
1539
|
+
const batch = await required(batchRepo.findOne({
|
|
1540
|
+
where: { ...activeScope(scope), id: task.resultEntityId }, lock: { mode: 'pessimistic_write' }
|
|
1541
|
+
}), '管控目录批次不存在');
|
|
1542
|
+
if (batch.lifecycleStatus !== 'DRAFT' && batch.lifecycleStatus !== 'REVIEWING') {
|
|
1543
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '管控目录草稿状态无效');
|
|
1544
|
+
}
|
|
1545
|
+
const previousStatus = batch.lifecycleStatus;
|
|
1546
|
+
batch.lifecycleStatus = 'REVIEWING';
|
|
1547
|
+
batch.updatedById = scope.userId ?? null;
|
|
1548
|
+
await batchRepo.save(batch);
|
|
1549
|
+
await manager.getRepository(ControlledGoodsRecord).update({
|
|
1550
|
+
...activeScope(scope), batchId: batch.id, lifecycleStatus: previousStatus, deletedAt: IsNull()
|
|
1551
|
+
}, { lifecycleStatus: 'REVIEWING', updatedById: scope.userId ?? null });
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
const batchRepo = manager.getRepository(SanctionCatalogBatch);
|
|
1555
|
+
const batch = await required(batchRepo.findOne({
|
|
1556
|
+
where: { ...activeScope(scope), id: task.resultEntityId }, lock: { mode: 'pessimistic_write' }
|
|
1557
|
+
}), '制裁目录批次不存在');
|
|
1558
|
+
if (batch.lifecycleStatus !== 'DRAFT' && batch.lifecycleStatus !== 'REVIEWING') {
|
|
1559
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '制裁目录草稿状态无效');
|
|
1560
|
+
}
|
|
1561
|
+
const previousStatus = batch.lifecycleStatus;
|
|
1562
|
+
batch.lifecycleStatus = 'REVIEWING';
|
|
1563
|
+
batch.updatedById = scope.userId ?? null;
|
|
1564
|
+
await batchRepo.save(batch);
|
|
1565
|
+
await manager.getRepository(SanctionedCompanyRecord).update({
|
|
1566
|
+
...activeScope(scope), batchId: batch.id, lifecycleStatus: previousStatus, deletedAt: IsNull()
|
|
1567
|
+
}, { lifecycleStatus: 'REVIEWING', updatedById: scope.userId ?? null });
|
|
1568
|
+
}
|
|
1569
|
+
async publishCatalogDraft(manager, scope, task) {
|
|
1570
|
+
if (!task.resultEntityId)
|
|
1571
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录导入批次不存在');
|
|
1572
|
+
await lockCatalogMutation(manager, scope, this.dataSource.options?.type === 'postgres');
|
|
1573
|
+
const now = new Date();
|
|
1574
|
+
if (task.documentType === 'CONTROLLED_CATALOG') {
|
|
1575
|
+
const batchRepo = manager.getRepository(ControlledCatalogBatch);
|
|
1576
|
+
const recordRepo = manager.getRepository(ControlledGoodsRecord);
|
|
1577
|
+
const batch = await required(batchRepo.findOne({
|
|
1578
|
+
where: { ...activeScope(scope), id: task.resultEntityId }, lock: { mode: 'pessimistic_write' }
|
|
1579
|
+
}), '管控目录批次不存在');
|
|
1580
|
+
if (batch.lifecycleStatus !== 'DRAFT' && batch.lifecycleStatus !== 'REVIEWING') {
|
|
1581
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '管控目录草稿状态无效');
|
|
1582
|
+
}
|
|
1583
|
+
await this.reclassifyControlledPublishConflicts(manager, scope, batch, now);
|
|
1584
|
+
if (batch.replacedBatchId) {
|
|
1585
|
+
const replaced = await required(batchRepo.findOne({
|
|
1586
|
+
where: { ...activeScope(scope), id: batch.replacedBatchId, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() },
|
|
1587
|
+
lock: { mode: 'pessimistic_write' }
|
|
1588
|
+
}), '被覆盖目录批次不存在');
|
|
1589
|
+
replaced.deletedAt = now;
|
|
1590
|
+
replaced.deletedById = scope.userId ?? null;
|
|
1591
|
+
replaced.deleteOperation = 'CATALOG_REPLACE';
|
|
1592
|
+
replaced.updatedById = scope.userId ?? null;
|
|
1593
|
+
await batchRepo.save(replaced);
|
|
1594
|
+
await recordRepo.update({
|
|
1595
|
+
...activeScope(scope), batchId: replaced.id, lifecycleStatus: 'ACTIVE', deletedAt: IsNull()
|
|
1596
|
+
}, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'CATALOG_REPLACE', updatedById: scope.userId ?? null });
|
|
1597
|
+
}
|
|
1598
|
+
const previousStatus = batch.lifecycleStatus;
|
|
1599
|
+
batch.lifecycleStatus = 'ACTIVE';
|
|
1600
|
+
batch.updatedById = scope.userId ?? null;
|
|
1601
|
+
await batchRepo.save(batch);
|
|
1602
|
+
await recordRepo.update({
|
|
1603
|
+
...activeScope(scope), batchId: batch.id, lifecycleStatus: previousStatus, deletedAt: IsNull()
|
|
1604
|
+
}, { lifecycleStatus: 'ACTIVE', updatedById: scope.userId ?? null });
|
|
1605
|
+
this.synchronizeCatalogTaskCounts(task, batch);
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
if (task.documentType !== 'SANCTION_CATALOG')
|
|
1609
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '仅目录任务支持发布');
|
|
1610
|
+
const batchRepo = manager.getRepository(SanctionCatalogBatch);
|
|
1611
|
+
const recordRepo = manager.getRepository(SanctionedCompanyRecord);
|
|
1612
|
+
const aliasRepo = manager.getRepository(SanctionedCompanyAlias);
|
|
1613
|
+
const batch = await required(batchRepo.findOne({
|
|
1614
|
+
where: { ...activeScope(scope), id: task.resultEntityId }, lock: { mode: 'pessimistic_write' }
|
|
1615
|
+
}), '制裁目录批次不存在');
|
|
1616
|
+
if (batch.lifecycleStatus !== 'DRAFT' && batch.lifecycleStatus !== 'REVIEWING') {
|
|
1617
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '制裁目录草稿状态无效');
|
|
1618
|
+
}
|
|
1619
|
+
await this.reclassifySanctionPublishConflicts(manager, scope, batch, now);
|
|
1620
|
+
if (batch.replacedBatchId) {
|
|
1621
|
+
const replaced = await required(batchRepo.findOne({
|
|
1622
|
+
where: { ...activeScope(scope), id: batch.replacedBatchId, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() },
|
|
1623
|
+
lock: { mode: 'pessimistic_write' }
|
|
1624
|
+
}), '被覆盖制裁目录批次不存在');
|
|
1625
|
+
replaced.deletedAt = now;
|
|
1626
|
+
replaced.deletedById = scope.userId ?? null;
|
|
1627
|
+
replaced.deleteOperation = 'SANCTION_CATALOG_REPLACE';
|
|
1628
|
+
replaced.updatedById = scope.userId ?? null;
|
|
1629
|
+
await batchRepo.save(replaced);
|
|
1630
|
+
const replacedRecords = await recordRepo.find({
|
|
1631
|
+
where: { ...activeScope(scope), batchId: replaced.id, lifecycleStatus: 'ACTIVE', deletedAt: IsNull() },
|
|
1632
|
+
select: { id: true }
|
|
1633
|
+
});
|
|
1634
|
+
if (replacedRecords.length > 0) {
|
|
1635
|
+
await aliasRepo.update({
|
|
1636
|
+
...activeScope(scope), sanctionedCompanyId: In(replacedRecords.map(record => record.id)), deletedAt: IsNull()
|
|
1637
|
+
}, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SANCTION_CATALOG_REPLACE', updatedById: scope.userId ?? null });
|
|
1638
|
+
}
|
|
1639
|
+
await recordRepo.update({
|
|
1640
|
+
...activeScope(scope), batchId: replaced.id, lifecycleStatus: 'ACTIVE', deletedAt: IsNull()
|
|
1641
|
+
}, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SANCTION_CATALOG_REPLACE', updatedById: scope.userId ?? null });
|
|
1642
|
+
}
|
|
1643
|
+
const previousStatus = batch.lifecycleStatus;
|
|
1644
|
+
batch.lifecycleStatus = 'ACTIVE';
|
|
1645
|
+
batch.updatedById = scope.userId ?? null;
|
|
1646
|
+
await batchRepo.save(batch);
|
|
1647
|
+
await recordRepo.update({
|
|
1648
|
+
...activeScope(scope), batchId: batch.id, lifecycleStatus: previousStatus, deletedAt: IsNull()
|
|
1649
|
+
}, { lifecycleStatus: 'ACTIVE', updatedById: scope.userId ?? null });
|
|
1650
|
+
this.synchronizeCatalogTaskCounts(task, batch);
|
|
1651
|
+
}
|
|
1652
|
+
synchronizeCatalogTaskCounts(task, batch) {
|
|
1653
|
+
if (!task.progress)
|
|
1654
|
+
return;
|
|
1655
|
+
task.progress.successCount = batch.successCount;
|
|
1656
|
+
task.progress.duplicateCount = batch.duplicateCount;
|
|
1657
|
+
task.progress.invalidCount = batch.invalidCount;
|
|
1658
|
+
task.progress.skippedCount = batch.skippedCount;
|
|
1659
|
+
}
|
|
1660
|
+
async reclassifyControlledPublishConflicts(manager, scope, batch, now) {
|
|
1661
|
+
const recordRepo = manager.getRepository(ControlledGoodsRecord);
|
|
1662
|
+
const issueRepo = manager.getRepository(ControlledCatalogImportIssue);
|
|
1663
|
+
const drafts = await recordRepo.find({
|
|
1664
|
+
where: { ...activeScope(scope), batchId: batch.id, lifecycleStatus: In(['DRAFT', 'REVIEWING']), deletedAt: IsNull() }
|
|
1665
|
+
});
|
|
1666
|
+
for (const record of drafts) {
|
|
1667
|
+
const conflictWhere = {
|
|
1668
|
+
...activeScope(scope), lifecycleStatus: 'ACTIVE', catalogType: record.catalogType,
|
|
1669
|
+
normalizedProductName: record.normalizedProductName, normalizedHsCode: record.normalizedHsCode,
|
|
1670
|
+
deletedAt: IsNull()
|
|
1671
|
+
};
|
|
1672
|
+
const conflict = await recordRepo.findOne({ where: batch.replacedBatchId
|
|
1673
|
+
? [{ ...conflictWhere, batchId: IsNull() }, { ...conflictWhere, batchId: Not(batch.replacedBatchId) }]
|
|
1674
|
+
: conflictWhere });
|
|
1675
|
+
if (!conflict)
|
|
1676
|
+
continue;
|
|
1677
|
+
batch.successCount = Math.max(0, batch.successCount - 1);
|
|
1678
|
+
batch.duplicateCount += 1;
|
|
1679
|
+
await issueRepo.save(issueRepo.create({
|
|
1680
|
+
...createdScope(scope), batchId: batch.id, issueType: 'DUPLICATE',
|
|
1681
|
+
sourceSequence: Math.max(1, batch.totalCount - batch.successCount),
|
|
1682
|
+
sourceLocation: record.sourceLocation, sourceContent: record.sourceContent,
|
|
1683
|
+
parsedPayload: {
|
|
1684
|
+
catalogType: record.catalogType, productName: record.productName, hsCode: record.hsCode
|
|
1685
|
+
},
|
|
1686
|
+
message: '发布前检测到相同商品名称和海关编码的活动记录'
|
|
1687
|
+
}));
|
|
1688
|
+
await recordRepo.update({ ...activeScope(scope), id: record.id }, {
|
|
1689
|
+
lifecycleStatus: 'FAILED', deletedAt: now, deletedById: scope.userId ?? null,
|
|
1690
|
+
deleteOperation: 'IMPORT_FAILED', updatedById: scope.userId ?? null
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
async reclassifySanctionPublishConflicts(manager, scope, batch, now) {
|
|
1695
|
+
const recordRepo = manager.getRepository(SanctionedCompanyRecord);
|
|
1696
|
+
const issueRepo = manager.getRepository(SanctionCatalogImportIssue);
|
|
1697
|
+
const aliasRepo = manager.getRepository(SanctionedCompanyAlias);
|
|
1698
|
+
const drafts = await recordRepo.find({
|
|
1699
|
+
where: { ...activeScope(scope), batchId: batch.id, lifecycleStatus: In(['DRAFT', 'REVIEWING']), deletedAt: IsNull() }
|
|
1700
|
+
});
|
|
1701
|
+
for (const record of drafts) {
|
|
1702
|
+
const conflictWhere = {
|
|
1703
|
+
...activeScope(scope), lifecycleStatus: 'ACTIVE', normalizedCompanyName: record.normalizedCompanyName,
|
|
1704
|
+
normalizedSanctionListSource: record.normalizedSanctionListSource, deletedAt: IsNull()
|
|
1705
|
+
};
|
|
1706
|
+
const conflict = await recordRepo.findOne({ where: batch.replacedBatchId
|
|
1707
|
+
? [{ ...conflictWhere, batchId: IsNull() }, { ...conflictWhere, batchId: Not(batch.replacedBatchId) }]
|
|
1708
|
+
: conflictWhere });
|
|
1709
|
+
if (!conflict)
|
|
1710
|
+
continue;
|
|
1711
|
+
batch.successCount = Math.max(0, batch.successCount - 1);
|
|
1712
|
+
batch.duplicateCount += 1;
|
|
1713
|
+
await issueRepo.save(issueRepo.create({
|
|
1714
|
+
...createdScope(scope), batchId: batch.id, issueType: 'DUPLICATE',
|
|
1715
|
+
sourceSequence: Math.max(1, batch.totalCount - batch.successCount),
|
|
1716
|
+
sourceLocation: record.sourceLocation, sourceContent: record.sourceContent,
|
|
1717
|
+
parsedPayload: {
|
|
1718
|
+
companyName: record.companyName, sanctionListSource: record.sanctionListSource
|
|
1719
|
+
},
|
|
1720
|
+
message: '发布前检测到相同公司和名单来源的活动记录'
|
|
1721
|
+
}));
|
|
1722
|
+
await aliasRepo.update({
|
|
1723
|
+
...activeScope(scope), sanctionedCompanyId: record.id, deletedAt: IsNull()
|
|
1724
|
+
}, {
|
|
1725
|
+
deletedAt: now, deletedById: scope.userId ?? null,
|
|
1726
|
+
deleteOperation: 'IMPORT_FAILED', updatedById: scope.userId ?? null
|
|
1727
|
+
});
|
|
1728
|
+
await recordRepo.update({ ...activeScope(scope), id: record.id }, {
|
|
1729
|
+
lifecycleStatus: 'FAILED', deletedAt: now, deletedById: scope.userId ?? null,
|
|
1730
|
+
deleteOperation: 'IMPORT_FAILED', updatedById: scope.userId ?? null
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
async failCatalogDraft(manager, scope, task, allowActive = false) {
|
|
1269
1735
|
if (!task.resultEntityId || (task.documentType !== 'CONTROLLED_CATALOG' && task.documentType !== 'SANCTION_CATALOG'))
|
|
1270
1736
|
return;
|
|
1271
1737
|
const now = new Date();
|
|
@@ -1273,11 +1739,9 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1273
1739
|
const batchRepo = manager.getRepository(ControlledCatalogBatch);
|
|
1274
1740
|
const recordRepo = manager.getRepository(ControlledGoodsRecord);
|
|
1275
1741
|
const current = await batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId } });
|
|
1276
|
-
if (!current
|
|
1277
|
-
return;
|
|
1278
|
-
const replaced = await batchRepo.findOne({ where: { ...activeScope(scope), id: current.replacedBatchId } });
|
|
1279
|
-
if (!replaced || replaced.deleteOperation !== 'CATALOG_REPLACE')
|
|
1742
|
+
if (!current || (current.lifecycleStatus !== 'DRAFT' && current.lifecycleStatus !== 'REVIEWING' && (!allowActive || current.lifecycleStatus !== 'ACTIVE')))
|
|
1280
1743
|
return;
|
|
1744
|
+
current.lifecycleStatus = 'FAILED';
|
|
1281
1745
|
current.deletedAt = now;
|
|
1282
1746
|
current.deletedById = scope.userId ?? null;
|
|
1283
1747
|
current.deleteOperation = 'IMPORT_FAILED';
|
|
@@ -1285,26 +1749,16 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1285
1749
|
await batchRepo.save(current);
|
|
1286
1750
|
await recordRepo.update({
|
|
1287
1751
|
...activeScope(scope), batchId: current.id, deletedAt: IsNull()
|
|
1288
|
-
}, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'IMPORT_FAILED', updatedById: scope.userId ?? null });
|
|
1289
|
-
replaced.deletedAt = null;
|
|
1290
|
-
replaced.deletedById = null;
|
|
1291
|
-
replaced.deleteOperation = null;
|
|
1292
|
-
replaced.updatedById = scope.userId ?? null;
|
|
1293
|
-
await batchRepo.save(replaced);
|
|
1294
|
-
await recordRepo.update({
|
|
1295
|
-
...activeScope(scope), batchId: replaced.id, deleteOperation: 'CATALOG_REPLACE'
|
|
1296
|
-
}, { deletedAt: null, deletedById: null, deleteOperation: null, updatedById: scope.userId ?? null });
|
|
1752
|
+
}, { lifecycleStatus: 'FAILED', deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'IMPORT_FAILED', updatedById: scope.userId ?? null });
|
|
1297
1753
|
return;
|
|
1298
1754
|
}
|
|
1299
1755
|
const batchRepo = manager.getRepository(SanctionCatalogBatch);
|
|
1300
1756
|
const recordRepo = manager.getRepository(SanctionedCompanyRecord);
|
|
1301
1757
|
const aliasRepo = manager.getRepository(SanctionedCompanyAlias);
|
|
1302
1758
|
const current = await batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId } });
|
|
1303
|
-
if (!current
|
|
1304
|
-
return;
|
|
1305
|
-
const replaced = await batchRepo.findOne({ where: { ...activeScope(scope), id: current.replacedBatchId } });
|
|
1306
|
-
if (!replaced || replaced.deleteOperation !== 'SANCTION_CATALOG_REPLACE')
|
|
1759
|
+
if (!current || (current.lifecycleStatus !== 'DRAFT' && current.lifecycleStatus !== 'REVIEWING' && (!allowActive || current.lifecycleStatus !== 'ACTIVE')))
|
|
1307
1760
|
return;
|
|
1761
|
+
current.lifecycleStatus = 'FAILED';
|
|
1308
1762
|
current.deletedAt = now;
|
|
1309
1763
|
current.deletedById = scope.userId ?? null;
|
|
1310
1764
|
current.deleteOperation = 'IMPORT_FAILED';
|
|
@@ -1320,15 +1774,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1320
1774
|
}
|
|
1321
1775
|
await recordRepo.update({
|
|
1322
1776
|
...activeScope(scope), batchId: current.id, deletedAt: IsNull()
|
|
1323
|
-
}, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'IMPORT_FAILED', updatedById: scope.userId ?? null });
|
|
1324
|
-
replaced.deletedAt = null;
|
|
1325
|
-
replaced.deletedById = null;
|
|
1326
|
-
replaced.deleteOperation = null;
|
|
1327
|
-
replaced.updatedById = scope.userId ?? null;
|
|
1328
|
-
await batchRepo.save(replaced);
|
|
1329
|
-
await recordRepo.update({
|
|
1330
|
-
...activeScope(scope), batchId: replaced.id, deleteOperation: 'SANCTION_CATALOG_REPLACE'
|
|
1331
|
-
}, { deletedAt: null, deletedById: null, deleteOperation: null, updatedById: scope.userId ?? null });
|
|
1777
|
+
}, { lifecycleStatus: 'FAILED', deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'IMPORT_FAILED', updatedById: scope.userId ?? null });
|
|
1332
1778
|
}
|
|
1333
1779
|
async applyImportTaskFailure(manager, scope, task, status, errorCode, errorMessage, completedAt = new Date()) {
|
|
1334
1780
|
task.status = status;
|
|
@@ -1338,7 +1784,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1338
1784
|
if (task.progress?.mode === 'FAST_PIPELINE') {
|
|
1339
1785
|
task.progress.stage = 'FAILED';
|
|
1340
1786
|
task.progress.observerStatus = 'FAILED';
|
|
1341
|
-
await this.
|
|
1787
|
+
await this.failCatalogDraft(manager, scope, task);
|
|
1342
1788
|
}
|
|
1343
1789
|
}
|
|
1344
1790
|
async createImportTask(scope, input) {
|
|
@@ -1723,8 +2169,15 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1723
2169
|
return this.dataSource.transaction(async (manager) => {
|
|
1724
2170
|
const repo = manager.getRepository(ImportTask);
|
|
1725
2171
|
const task = await required(repo.findOne({ where: { ...activeScope(scope), id }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
|
|
1726
|
-
if (task.status === 'AWAITING_REVIEW')
|
|
1727
|
-
|
|
2172
|
+
if (task.status === 'AWAITING_REVIEW') {
|
|
2173
|
+
const replaceablePurchaseDraft = task.documentType === 'PURCHASE_CONTRACT' && task.resultEntityId == null && version === 'PURCHASE_DRAFT_V1';
|
|
2174
|
+
if (!replaceablePurchaseDraft || canonicalJson(task.resultPayload ?? null) === canonicalJson(payload))
|
|
2175
|
+
return task;
|
|
2176
|
+
task.resultPayload = payload;
|
|
2177
|
+
task.resultPayloadVersion = version;
|
|
2178
|
+
task.completedAt = new Date();
|
|
2179
|
+
return repo.save(task);
|
|
2180
|
+
}
|
|
1728
2181
|
assertImportTaskResultWritable(task);
|
|
1729
2182
|
await invalidateCustomerContractReanalysis(manager, scope, { reviewSource: 'IMPORT_DRAFT', task });
|
|
1730
2183
|
task.resultPayload = payload;
|
|
@@ -1770,7 +2223,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1770
2223
|
const lineRepo = manager.getRepository(PurchaseOrderLine);
|
|
1771
2224
|
for (let index = 0; index < draft.lines.length; index += 1) {
|
|
1772
2225
|
const line = draft.lines[index];
|
|
1773
|
-
await lineRepo.save(lineRepo.create({ ...auditScope(scope), purchaseOrderId: entity.id, lineNumber: line.lineNumber ?? index + 1, sourceType: line.sourceType ?? 'CONTRACT_EXTRACTED', productName: line.productName.trim(), normalizedProductName: normalizeForProductMatch(line.productName), englishName: line.englishName ?? null, englishNameSource: line.englishNameSource ?? null, productDescription: line.productDescription ?? null, modelSpecification: line.modelSpecification ?? null, unit: line.unit ?? null, quantity: defaultDecimalText(line.quantity), unitPriceTaxIncluded: defaultDecimalText(line.unitPriceTaxIncluded), lineAmountTaxIncluded:
|
|
2226
|
+
await lineRepo.save(lineRepo.create({ ...auditScope(scope), purchaseOrderId: entity.id, lineNumber: line.lineNumber ?? index + 1, sourceType: line.sourceType ?? 'CONTRACT_EXTRACTED', productName: line.productName.trim(), normalizedProductName: normalizeForProductMatch(line.productName), englishName: line.englishName ?? null, englishNameSource: line.englishNameSource ?? null, productDescription: line.productDescription ?? null, modelSpecification: line.modelSpecification ?? null, unit: line.unit ?? null, quantity: defaultDecimalText(line.quantity), unitPriceTaxIncluded: defaultDecimalText(line.unitPriceTaxIncluded), lineAmountTaxIncluded: purchaseLineAmountText(line), vatRate: optionalString(line.vatRate), vatRateSource: line.vatRateSource ?? null, hsCode: line.hsCode ?? null, hsCodeStatus: line.hsCodeStatus ?? 'UNCONFIRMED', hsCodeSource: line.hsCodeSource ?? null, hsCodeReferenceSnapshotId: line.hsCodeReferenceSnapshotId ?? null, originCountry: line.originCountry ?? null, domesticSourceLocation: line.domesticSourceLocation ?? null, refundRate: optionalString(line.refundRate), refundRateSource: line.refundRateSource ?? null, refundPolicyReference: line.refundPolicyReference ?? null, refundPolicyDate: optionalString(line.refundPolicyDate), refundValidationMethod: line.refundValidationMethod ?? null, refundConfirmedAt: confirmedAt, refundConfirmedById: confirmedById, amountMismatchConfirmedAt: line.amountMismatch === true ? confirmedAt : null, amountMismatchConfirmedById: line.amountMismatch === true ? confirmedById : null, sourceLocation: line.sourceLocation ?? null, sourceContent: line.sourceContent ?? null }));
|
|
1774
2227
|
}
|
|
1775
2228
|
task.status = 'SUCCEEDED';
|
|
1776
2229
|
task.resultEntityId = entity.id;
|
|
@@ -1845,7 +2298,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1845
2298
|
const currentOrders = orders.filter(order => String(order.orderDate ?? '').startsWith(`${currentYear}-`));
|
|
1846
2299
|
const top = new Map();
|
|
1847
2300
|
for (const line of lines)
|
|
1848
|
-
top.set(line.productName, (top.get(line.productName) ?? new Decimal(0)).plus(line
|
|
2301
|
+
top.set(line.productName, (top.get(line.productName) ?? new Decimal(0)).plus(purchaseLineMetricAmount(line)));
|
|
1849
2302
|
const orderBalances = {};
|
|
1850
2303
|
for (const order of orders) {
|
|
1851
2304
|
const orderLines = lines.filter(line => line.purchaseOrderId === order.id);
|
|
@@ -1896,6 +2349,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1896
2349
|
const saved = await this.withLockedPurchaseLines(scope, existing.purchaseOrderId, async ({ lineRepo, lines, contracts, customers, manuals }) => {
|
|
1897
2350
|
const item = await required(Promise.resolve(lines.find(line => line.id === id) ?? null), '采购商品不存在');
|
|
1898
2351
|
Object.assign(item, pick(input, ['productName', 'englishName', 'englishNameSource', 'productDescription', 'modelSpecification', 'unit', 'quantity', 'unitPriceTaxIncluded', 'lineAmountTaxIncluded', 'vatRate', 'vatRateSource', 'hsCode', 'hsCodeStatus', 'hsCodeSource', 'originCountry', 'domesticSourceLocation', 'refundRate', 'refundRateSource', 'refundPolicyReference', 'refundPolicyDate', 'refundValidationMethod']));
|
|
2352
|
+
item.lineAmountTaxIncluded = purchaseLineAmountText(item);
|
|
1899
2353
|
validatePurchaseLineDiscriminators(item);
|
|
1900
2354
|
const rates = validateTaxRates(item, { purchaseOrderLineId: item.id, lineNumber: item.lineNumber, productName: item.productName });
|
|
1901
2355
|
item.vatRate = rates.vatRate;
|
|
@@ -1918,7 +2372,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1918
2372
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '采购商品数量必须大于 0');
|
|
1919
2373
|
const saved = await this.withLockedPurchaseLines(scope, purchaseOrderId, async ({ lineRepo, lines, contracts, customers, manuals }) => {
|
|
1920
2374
|
const lineNumber = lines.reduce((maximum, line) => Math.max(maximum, line.lineNumber), 0) + 1;
|
|
1921
|
-
const item = lineRepo.create({ ...auditScope(scope), purchaseOrderId, lineNumber, sourceType: discriminators.sourceType ?? 'MANUAL_ENTRY', productName: input.productName.trim(), normalizedProductName: normalizeForProductMatch(input.productName), englishName: input.englishName ?? null, englishNameSource: discriminators.englishNameSource ?? null, productDescription: input.productDescription ?? null, modelSpecification: input.modelSpecification ?? null, unit: input.unit ?? null, quantity: input.quantity, unitPriceTaxIncluded: input.unitPriceTaxIncluded, lineAmountTaxIncluded: input
|
|
2375
|
+
const item = lineRepo.create({ ...auditScope(scope), purchaseOrderId, lineNumber, sourceType: discriminators.sourceType ?? 'MANUAL_ENTRY', productName: input.productName.trim(), normalizedProductName: normalizeForProductMatch(input.productName), englishName: input.englishName ?? null, englishNameSource: discriminators.englishNameSource ?? null, productDescription: input.productDescription ?? null, modelSpecification: input.modelSpecification ?? null, unit: input.unit ?? null, quantity: input.quantity, unitPriceTaxIncluded: input.unitPriceTaxIncluded, lineAmountTaxIncluded: purchaseLineAmountText(input), vatRate: rates.vatRate, vatRateSource: discriminators.vatRateSource ?? 'MANUAL', hsCode: input.hsCode ?? null, hsCodeStatus: discriminators.hsCodeStatus ?? 'UNCONFIRMED', hsCodeSource: discriminators.hsCodeSource ?? null, hsCodeReferenceSnapshotId: null, originCountry: input.originCountry ?? null, domesticSourceLocation: input.domesticSourceLocation ?? null, refundRate: rates.refundRate, refundRateSource: discriminators.refundRateSource ?? null, refundPolicyReference: input.refundPolicyReference ?? null, refundPolicyDate: input.refundPolicyDate ?? null, refundValidationMethod: discriminators.refundValidationMethod ?? null, refundConfirmedAt: null, refundConfirmedById: null, amountMismatchConfirmedAt: null, amountMismatchConfirmedById: null, sourceLocation: null, sourceContent: null });
|
|
1922
2376
|
this.assertLockedPurchaseReceipts([...lines, item], contracts, customers, manuals);
|
|
1923
2377
|
return lineRepo.save(item);
|
|
1924
2378
|
});
|
|
@@ -2527,7 +2981,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
2527
2981
|
const linesByPurchase = groupBy(lines, line => line.purchaseOrderId);
|
|
2528
2982
|
const riskRows = lines.length ? await this.dataSource.getRepository(ProductComplianceMatch).find({ where: { ...activeScope(scope), purchaseOrderLineId: In(lines.map(line => line.id)), deletedAt: IsNull() } }) : [];
|
|
2529
2983
|
const controlledGoodsIds = [...new Set(riskRows.map(match => match.controlledGoodsId))];
|
|
2530
|
-
const controlledGoods = controlledGoodsIds.length ? await this.dataSource.getRepository(ControlledGoodsRecord).find({ where: { ...activeScope(scope), id: In(controlledGoodsIds), deletedAt: IsNull() } }) : [];
|
|
2984
|
+
const controlledGoods = controlledGoodsIds.length ? await this.dataSource.getRepository(ControlledGoodsRecord).find({ where: { ...activeScope(scope), id: In(controlledGoodsIds), lifecycleStatus: 'ACTIVE', deletedAt: IsNull() } }) : [];
|
|
2531
2985
|
const controlledGoodsById = new Map(controlledGoods.map(item => [item.id, item]));
|
|
2532
2986
|
const risksByLine = groupBy(riskRows, match => match.purchaseOrderLineId);
|
|
2533
2987
|
const purchasesByBaseCode = groupBy(purchases, purchase => parsePurchaseContractNo(purchase.contractNo).baseCode);
|
|
@@ -2718,7 +3172,8 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
2718
3172
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '供应商存在有效采购订单,不能删除', { orderCount: count });
|
|
2719
3173
|
}
|
|
2720
3174
|
const repo = manager.getRepository(entity);
|
|
2721
|
-
const
|
|
3175
|
+
const lifecycle = entity === ControlledGoodsRecord || entity === SanctionedCompanyRecord ? { lifecycleStatus: 'ACTIVE' } : {};
|
|
3176
|
+
const target = await required(repo.findOne({ where: { ...activeScope(scope), id, ...lifecycle, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } }), '记录不存在');
|
|
2722
3177
|
const now = new Date();
|
|
2723
3178
|
target.deletedAt = now;
|
|
2724
3179
|
target.deletedById = scope.userId ?? null;
|
|
@@ -3094,7 +3549,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
3094
3549
|
});
|
|
3095
3550
|
await matchRepo.update({ ...activeScope(scope), deletedAt: IsNull() }, { deletedAt: now, invalidationReason: reason });
|
|
3096
3551
|
const goods = await manager.getRepository(ControlledGoodsRecord).find({
|
|
3097
|
-
where: { ...activeScope(scope), deletedAt: IsNull() },
|
|
3552
|
+
where: { ...activeScope(scope), lifecycleStatus: 'ACTIVE', deletedAt: IsNull() },
|
|
3098
3553
|
order: { id: 'ASC' },
|
|
3099
3554
|
lock: { mode: 'pessimistic_read' }
|
|
3100
3555
|
});
|
|
@@ -3118,17 +3573,28 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
3118
3573
|
return { scanned: lines.length, matches };
|
|
3119
3574
|
});
|
|
3120
3575
|
}
|
|
3121
|
-
async recalculateCompanyRisks(scope, reason) {
|
|
3576
|
+
async recalculateCompanyRisks(scope, reason, sanctionBatchId) {
|
|
3122
3577
|
return this.dataSource.transaction(async (manager) => {
|
|
3123
3578
|
const repo = manager.getRepository(CompanySanctionMatch);
|
|
3124
3579
|
const supplierRepo = manager.getRepository(Supplier);
|
|
3125
3580
|
const customerRepo = manager.getRepository(Customer);
|
|
3126
3581
|
const now = new Date();
|
|
3582
|
+
if (this.dataSource.options?.type === 'postgres') {
|
|
3583
|
+
await manager.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [`trade-compliance:company-risk:${scope.tenantId}:${scope.organizationId}`, 'recalculate']);
|
|
3584
|
+
}
|
|
3585
|
+
const sanctionBatch = sanctionBatchId
|
|
3586
|
+
? await required(manager.getRepository(SanctionCatalogBatch).findOne({
|
|
3587
|
+
where: { ...activeScope(scope), id: sanctionBatchId }, lock: { mode: 'pessimistic_write' }
|
|
3588
|
+
}), '制裁目录批次不存在')
|
|
3589
|
+
: null;
|
|
3590
|
+
if (sanctionBatch?.riskRecalculatedAt) {
|
|
3591
|
+
return { scanned: 0, matches: 0, riskRecalculatedAt: sanctionBatch.riskRecalculatedAt };
|
|
3592
|
+
}
|
|
3127
3593
|
await repo.update({ ...activeScope(scope), deletedAt: IsNull() }, { deletedAt: now, invalidationReason: reason });
|
|
3128
3594
|
const [suppliers, customers, sanctions, aliases] = await Promise.all([
|
|
3129
3595
|
supplierRepo.find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
|
|
3130
3596
|
customerRepo.find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
|
|
3131
|
-
manager.getRepository(SanctionedCompanyRecord).find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
|
|
3597
|
+
manager.getRepository(SanctionedCompanyRecord).find({ where: { ...activeScope(scope), lifecycleStatus: 'ACTIVE', deletedAt: IsNull() } }),
|
|
3132
3598
|
manager.getRepository(SanctionedCompanyAlias).find({ where: { ...activeScope(scope), deletedAt: IsNull() } })
|
|
3133
3599
|
]);
|
|
3134
3600
|
const sanctionsById = new Map(sanctions.map(item => [item.id, item]));
|
|
@@ -3150,7 +3616,14 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
3150
3616
|
await repo.save(repo.create({ ...createdScope(scope), subjectType: 'CUSTOMER', supplierId: null, customerId: customer.id, sanctionedCompanyId: item.id, matchedNormalizedName: customer.normalizedName, checkedAt: now, deletedAt: null, invalidationReason: null }));
|
|
3151
3617
|
await customerRepo.update({ ...activeScope(scope), id: customer.id, deletedAt: IsNull() }, { complianceCheckStatus: 'CHECKED', complianceCheckedAt: now, updatedById: scope.userId ?? null, updatedAt: now });
|
|
3152
3618
|
}
|
|
3153
|
-
|
|
3619
|
+
const matches = await repo.count({ where: { ...activeScope(scope), deletedAt: IsNull() } });
|
|
3620
|
+
if (sanctionBatch) {
|
|
3621
|
+
sanctionBatch.riskRecalculatedAt = now;
|
|
3622
|
+
sanctionBatch.updatedById = scope.userId ?? null;
|
|
3623
|
+
await manager.getRepository(SanctionCatalogBatch).save(sanctionBatch);
|
|
3624
|
+
return { scanned: suppliers.length + customers.length, matches, riskRecalculatedAt: now };
|
|
3625
|
+
}
|
|
3626
|
+
return { scanned: suppliers.length + customers.length, matches };
|
|
3154
3627
|
});
|
|
3155
3628
|
}
|
|
3156
3629
|
async pagedQuery(entity, scope, query, searchFields, exactFilters = {}, filters = {}) {
|
|
@@ -3189,7 +3662,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
3189
3662
|
if (catalogBatchId)
|
|
3190
3663
|
qb.andWhere('row.batchId = :catalogBatchId', { catalogBatchId });
|
|
3191
3664
|
else
|
|
3192
|
-
qb.andWhere('row.deletedAt IS NULL');
|
|
3665
|
+
qb.andWhere('row.deletedAt IS NULL').andWhere("row.lifecycleStatus = 'ACTIVE'");
|
|
3193
3666
|
for (const [key, value] of Object.entries(filters.exact ?? {}))
|
|
3194
3667
|
if (hasQueryValue(value))
|
|
3195
3668
|
qb.andWhere(`row.${key} = :exact_${key}`, { [`exact_${key}`]: value });
|
|
@@ -3459,6 +3932,14 @@ function handoffCallbackIdentityMatches(input, expectedSourceMessageId, persiste
|
|
|
3459
3932
|
async function lockOrganizationTaskClaim(manager, scope) {
|
|
3460
3933
|
await manager.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [scope.tenantId, scope.organizationId]);
|
|
3461
3934
|
}
|
|
3935
|
+
async function lockCatalogMutation(manager, scope, enabled) {
|
|
3936
|
+
if (!enabled)
|
|
3937
|
+
return;
|
|
3938
|
+
await manager.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [
|
|
3939
|
+
`trade-compliance:catalog:${scope.tenantId}:${scope.organizationId}`,
|
|
3940
|
+
'mutate'
|
|
3941
|
+
]);
|
|
3942
|
+
}
|
|
3462
3943
|
async function organizationHasActiveParsing(manager, scope) {
|
|
3463
3944
|
const activeImport = await manager.getRepository(ImportTask).findOne({
|
|
3464
3945
|
where: { ...activeScope(scope), status: In([...RUNNING_IMPORT_TASK_STATUSES]), deletedAt: IsNull() },
|
|
@@ -3676,10 +4157,6 @@ function assertOptionalEnum(value, allowed, label) { if (hasQueryValue(value) &&
|
|
|
3676
4157
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, `${label}无效`); }
|
|
3677
4158
|
function fuzzyValue(value, filter) { return !hasQueryValue(filter) || String(value ?? '').toLocaleLowerCase().includes(String(filter).trim().toLocaleLowerCase()); }
|
|
3678
4159
|
function pageRows(rows, query) { const pageSize = Math.min(200, Math.max(1, Number(query.pageSize ?? 20))); const pageCount = Math.max(1, Math.ceil(rows.length / pageSize)); const page = Math.min(pageCount, Math.max(1, Number(query.page ?? 1))); return { items: rows.slice((page - 1) * pageSize, page * pageSize), total: rows.length, page, pageSize }; }
|
|
3679
|
-
function filterSalesOrders(rows, parameters, includeParties = false) {
|
|
3680
|
-
const date = parameters.orderDate && typeof parameters.orderDate === 'object' && !Array.isArray(parameters.orderDate) ? parameters.orderDate : {};
|
|
3681
|
-
return rows.filter(row => fuzzyValue(row.salesOrderNo, parameters.salesOrderNo) && fuzzyValue(row.customerContractNo, parameters.customerContractNo) && fuzzyValue(row.purchaseContractNo, parameters.purchaseContractNo) && (!includeParties || fuzzyValue(row.supplierName, parameters.supplierName) && fuzzyValue(row.customerName, parameters.customerName)) && (!hasQueryValue(parameters.paymentStatus) || row.paymentStatus === parameters.paymentStatus) && (!hasQueryValue(date.from) || String(row.orderDate ?? '') >= String(date.from)) && (!hasQueryValue(date.to) || String(row.orderDate ?? '') <= String(date.to)));
|
|
3682
|
-
}
|
|
3683
4160
|
function salesMetrics(rows) {
|
|
3684
4161
|
const currentYear = String(new Date().getFullYear());
|
|
3685
4162
|
const currentYearRows = rows.filter(row => String(row.orderDate ?? '').slice(0, 4) === currentYear);
|
|
@@ -3785,7 +4262,7 @@ function assertPurchaseDraft(draft) {
|
|
|
3785
4262
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, `${prefix}名称为必填项`);
|
|
3786
4263
|
const quantity = optionalDecimalInput(line.quantity, `${prefix}数量必须为有效数值`);
|
|
3787
4264
|
const unitPrice = optionalDecimalInput(line.unitPriceTaxIncluded, `${prefix}含税单价必须为有效金额`);
|
|
3788
|
-
const lineAmount = optionalDecimalInput(line
|
|
4265
|
+
const lineAmount = optionalDecimalInput(purchaseLineAmountText(line), `${prefix}含税金额必须为有效金额`);
|
|
3789
4266
|
validateOptionalTaxRate(line.vatRate, 'vatRate', '增值税率', { lineNumber: index + 1, productName: line.productName });
|
|
3790
4267
|
const refundRate = validateOptionalTaxRate(line.refundRate, 'refundRate', '退税率', { lineNumber: index + 1, productName: line.productName });
|
|
3791
4268
|
if (hasQueryValue(line.refundPolicyDate) && hasQueryValue(draft.order.orderDate) && String(line.refundPolicyDate) > String(draft.order.orderDate))
|
|
@@ -3828,6 +4305,21 @@ function optionalDecimalInput(value, message) {
|
|
|
3828
4305
|
function defaultDecimalText(value) {
|
|
3829
4306
|
return optionalDecimalInput(value, '数值必须是有限金额')?.toString() ?? '0';
|
|
3830
4307
|
}
|
|
4308
|
+
function purchaseLineAmountText(line) {
|
|
4309
|
+
if (hasQueryValue(line.lineAmountTaxIncluded))
|
|
4310
|
+
return defaultDecimalText(line.lineAmountTaxIncluded);
|
|
4311
|
+
const quantity = optionalDecimalInput(line.quantity, '采购商品数量必须为有效数值');
|
|
4312
|
+
const unitPrice = optionalDecimalInput(line.unitPriceTaxIncluded, '采购商品含税单价必须为有效金额');
|
|
4313
|
+
return quantity && unitPrice ? quantity.mul(unitPrice).toString() : '0';
|
|
4314
|
+
}
|
|
4315
|
+
function purchaseLineMetricAmount(line) {
|
|
4316
|
+
const lineAmount = optionalDecimalInput(line.lineAmountTaxIncluded, '采购商品含税金额必须为有效金额') ?? new Decimal(0);
|
|
4317
|
+
if (lineAmount.gt(0))
|
|
4318
|
+
return lineAmount;
|
|
4319
|
+
const quantity = optionalDecimalInput(line.quantity, '采购商品数量必须为有效数值');
|
|
4320
|
+
const unitPrice = optionalDecimalInput(line.unitPriceTaxIncluded, '采购商品含税单价必须为有效金额');
|
|
4321
|
+
return quantity && unitPrice ? quantity.mul(unitPrice) : lineAmount;
|
|
4322
|
+
}
|
|
3831
4323
|
function validateOptionalTaxRate(value, field, label, context) {
|
|
3832
4324
|
if (!hasQueryValue(value))
|
|
3833
4325
|
return null;
|
|
@@ -3930,6 +4422,133 @@ function reviewCandidate(documentType, block, payload) {
|
|
|
3930
4422
|
warnings: block.reasons
|
|
3931
4423
|
};
|
|
3932
4424
|
}
|
|
4425
|
+
function mergeReviewPayload(base, patch) {
|
|
4426
|
+
const merged = { ...base };
|
|
4427
|
+
for (const [key, value] of Object.entries(patch ?? {})) {
|
|
4428
|
+
if (value === null)
|
|
4429
|
+
delete merged[key];
|
|
4430
|
+
else
|
|
4431
|
+
merged[key] = value;
|
|
4432
|
+
}
|
|
4433
|
+
return merged;
|
|
4434
|
+
}
|
|
4435
|
+
function restorePendingReviewBlock(block) {
|
|
4436
|
+
block.status = 'PENDING';
|
|
4437
|
+
block.leaseToken = null;
|
|
4438
|
+
block.leaseExpiresAt = null;
|
|
4439
|
+
block.resolvedAt = null;
|
|
4440
|
+
block.resolutionFingerprint = null;
|
|
4441
|
+
}
|
|
4442
|
+
function catalogReviewRetryMatches(blocks, items) {
|
|
4443
|
+
if (blocks.length !== items.length)
|
|
4444
|
+
return false;
|
|
4445
|
+
return items.every(item => {
|
|
4446
|
+
const block = blocks.find(value => value.id === item.blockId);
|
|
4447
|
+
if (!block || block.status !== (item.decision === 'ACCEPT' ? 'RESOLVED' : 'REJECTED'))
|
|
4448
|
+
return false;
|
|
4449
|
+
if (!block.resolutionFingerprint)
|
|
4450
|
+
return false;
|
|
4451
|
+
try {
|
|
4452
|
+
return block.resolutionFingerprint === catalogReviewResolutionFingerprint(item);
|
|
4453
|
+
}
|
|
4454
|
+
catch {
|
|
4455
|
+
return false;
|
|
4456
|
+
}
|
|
4457
|
+
});
|
|
4458
|
+
}
|
|
4459
|
+
function catalogReviewResolutionFingerprint(item) {
|
|
4460
|
+
const resolution = item.decision === 'REJECT'
|
|
4461
|
+
? { decision: item.decision }
|
|
4462
|
+
: Object.prototype.hasOwnProperty.call(item, 'payload')
|
|
4463
|
+
? { decision: item.decision, payload: item.payload, payloadPresent: true }
|
|
4464
|
+
: { decision: item.decision, payloadPresent: false };
|
|
4465
|
+
return createHash('sha256').update(canonicalJson(resolution)).digest('hex');
|
|
4466
|
+
}
|
|
4467
|
+
function canonicalJson(value) {
|
|
4468
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
4469
|
+
return JSON.stringify(value);
|
|
4470
|
+
if (typeof value === 'number') {
|
|
4471
|
+
if (!Number.isFinite(value))
|
|
4472
|
+
throw new TypeError('Resolution payload is not JSON serializable');
|
|
4473
|
+
return JSON.stringify(value);
|
|
4474
|
+
}
|
|
4475
|
+
if (Array.isArray(value))
|
|
4476
|
+
return `[${value.map(item => item === undefined ? 'null' : canonicalJson(item)).join(',')}]`;
|
|
4477
|
+
if (value && typeof value === 'object') {
|
|
4478
|
+
const entries = Object.entries(value)
|
|
4479
|
+
.filter(([, item]) => item !== undefined)
|
|
4480
|
+
.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
|
|
4481
|
+
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}`;
|
|
4482
|
+
}
|
|
4483
|
+
throw new TypeError('Resolution payload is not JSON serializable');
|
|
4484
|
+
}
|
|
4485
|
+
function evaluateCatalogReviewAccept(documentType, block, patch) {
|
|
4486
|
+
try {
|
|
4487
|
+
const payload = mergeReviewPayload(block.payload, patch);
|
|
4488
|
+
let candidate = reviewCandidate(documentType, block, payload);
|
|
4489
|
+
if (!validCatalogReviewCandidateValues(candidate))
|
|
4490
|
+
return { candidate: null, reasons: ['REVIEW_PAYLOAD_INVALID'] };
|
|
4491
|
+
candidate = normalizeCatalogCandidate(candidate);
|
|
4492
|
+
const decision = classifyCandidate(candidate);
|
|
4493
|
+
if (decision.confidence === 'LOW')
|
|
4494
|
+
return { candidate: null, reasons: decision.reasons };
|
|
4495
|
+
if (candidate.kind === 'SANCTION' && typeof candidate.values.effectiveDate === 'string' && !isRealIsoDate(candidate.values.effectiveDate)) {
|
|
4496
|
+
return { candidate: null, reasons: ['EFFECTIVE_DATE_INVALID'] };
|
|
4497
|
+
}
|
|
4498
|
+
return { candidate, reasons: [] };
|
|
4499
|
+
}
|
|
4500
|
+
catch {
|
|
4501
|
+
return { candidate: null, reasons: ['REVIEW_PAYLOAD_INVALID'] };
|
|
4502
|
+
}
|
|
4503
|
+
}
|
|
4504
|
+
const CATALOG_REVIEW_VARCHAR_MAX = 255;
|
|
4505
|
+
const CATALOG_REVIEW_TEXT_MAX = 100_000;
|
|
4506
|
+
const CATALOG_REVIEW_ARRAY_MAX = 100;
|
|
4507
|
+
function validCatalogReviewCandidateValues(candidate) {
|
|
4508
|
+
const values = candidate.values;
|
|
4509
|
+
const varcharFields = candidate.kind === 'CONTROLLED'
|
|
4510
|
+
? ['productName', 'hsCode', 'controlCode', 'sourceSection', 'sourceOrdinal']
|
|
4511
|
+
: ['companyName', 'countryOrRegion', 'sanctionListSource', 'effectiveDate'];
|
|
4512
|
+
const textFields = candidate.kind === 'CONTROLLED'
|
|
4513
|
+
? ['originalProductName', 'controlDescription', 'originalControlDescription']
|
|
4514
|
+
: ['sanctionReason', 'remarks'];
|
|
4515
|
+
if (!validOptionalStrings(values, varcharFields, CATALOG_REVIEW_VARCHAR_MAX))
|
|
4516
|
+
return false;
|
|
4517
|
+
if (!validOptionalStrings(values, textFields, CATALOG_REVIEW_TEXT_MAX))
|
|
4518
|
+
return false;
|
|
4519
|
+
if (values.translationRequired !== undefined && typeof values.translationRequired !== 'boolean')
|
|
4520
|
+
return false;
|
|
4521
|
+
if (values.translationSource !== undefined && values.translationSource !== 'SOURCE_CHINESE' && values.translationSource !== 'AGENT_TRANSLATED')
|
|
4522
|
+
return false;
|
|
4523
|
+
if (values.hsCodes !== undefined && (!Array.isArray(values.hsCodes) || values.hsCodes.length > CATALOG_REVIEW_ARRAY_MAX ||
|
|
4524
|
+
values.hsCodes.some(value => typeof value !== 'string' || value.length > CATALOG_REVIEW_VARCHAR_MAX)))
|
|
4525
|
+
return false;
|
|
4526
|
+
if (values.aliases !== undefined && (!Array.isArray(values.aliases) || values.aliases.length > CATALOG_REVIEW_ARRAY_MAX ||
|
|
4527
|
+
values.aliases.some(value => !validCatalogReviewAlias(value))))
|
|
4528
|
+
return false;
|
|
4529
|
+
return true;
|
|
4530
|
+
}
|
|
4531
|
+
function validOptionalStrings(values, fields, maxLength) {
|
|
4532
|
+
return fields.every(field => values[field] === undefined || (typeof values[field] === 'string' && values[field].length <= maxLength));
|
|
4533
|
+
}
|
|
4534
|
+
function validCatalogReviewAlias(value) {
|
|
4535
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
4536
|
+
return false;
|
|
4537
|
+
const alias = value.alias;
|
|
4538
|
+
const aliasType = value.aliasType;
|
|
4539
|
+
return typeof alias === 'string' && alias.length > 0 && alias.length <= CATALOG_REVIEW_VARCHAR_MAX &&
|
|
4540
|
+
(aliasType === 'AKA' || aliasType === 'LOCAL_LANGUAGE');
|
|
4541
|
+
}
|
|
4542
|
+
function isRealIsoDate(value) {
|
|
4543
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
4544
|
+
if (!match)
|
|
4545
|
+
return false;
|
|
4546
|
+
const year = Number(match[1]);
|
|
4547
|
+
const month = Number(match[2]);
|
|
4548
|
+
const day = Number(match[3]);
|
|
4549
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
4550
|
+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
|
4551
|
+
}
|
|
3933
4552
|
function appendMapValue(map, key, value) {
|
|
3934
4553
|
const values = map.get(key);
|
|
3935
4554
|
if (values) {
|
|
@@ -3954,7 +4573,6 @@ function assertCompleteClauseAnalyses(analyses) {
|
|
|
3954
4573
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析结果必须完整包含七类关键条款');
|
|
3955
4574
|
}
|
|
3956
4575
|
function decimalSum(rows, read) { return rows.reduce((value, row) => value.plus(read(row)), new Decimal(0)).toNumber(); }
|
|
3957
|
-
function fuzzyMatches(value, filter) { return !filter || String(value ?? '').trim().toLocaleLowerCase().includes(filter.trim().toLocaleLowerCase()); }
|
|
3958
4576
|
function shanghaiCurrentYear(now = new Date()) {
|
|
3959
4577
|
return Number(new Intl.DateTimeFormat('en-US', { timeZone: 'Asia/Shanghai', year: 'numeric' }).format(now));
|
|
3960
4578
|
}
|