@chenchaolong/plugin-trade-compliance-workbench 1.0.51 → 1.0.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/constants.d.ts +1 -0
- package/dist/lib/constants.d.ts.map +1 -1
- package/dist/lib/constants.js +1 -0
- package/dist/lib/constants.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/import-jobs/import-command.builder.js +2 -2
- package/dist/lib/import-jobs/import-command.builder.js.map +1 -1
- package/dist/lib/import-jobs/import-job-callback.processor.d.ts.map +1 -1
- package/dist/lib/import-jobs/import-job-callback.processor.js +2 -0
- package/dist/lib/import-jobs/import-job-callback.processor.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-components/trade-compliance-workbench/editor-source.mjs +17 -1
- package/dist/lib/remote-components/trade-compliance-workbench/editor.js +100 -100
- 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 +178 -153
- 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 +3 -1
- package/dist/lib/remote-ui/contract-import.js.map +1 -1
- package/dist/lib/remote-ui/contracts.d.ts +21 -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 +63 -0
- package/dist/lib/remote-ui/pages/catalog-import-history.d.ts.map +1 -0
- package/dist/lib/remote-ui/pages/catalog-import-history.js +158 -0
- package/dist/lib/remote-ui/pages/catalog-import-history.js.map +1 -0
- package/dist/lib/remote-ui/pages/catalogs.css +164 -0
- package/dist/lib/remote-ui/pages/catalogs.d.ts +12 -6
- package/dist/lib/remote-ui/pages/catalogs.d.ts.map +1 -1
- package/dist/lib/remote-ui/pages/catalogs.js +79 -23
- 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/pages/sales.d.ts +4 -1
- package/dist/lib/remote-ui/pages/sales.d.ts.map +1 -1
- package/dist/lib/remote-ui/pages/sales.js +9 -4
- package/dist/lib/remote-ui/pages/sales.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/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/remote-ui/styles.css +1 -1
- package/dist/lib/sales-file.service.d.ts.map +1 -1
- package/dist/lib/sales-file.service.js +33 -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 +19 -9
- package/dist/lib/workbench-view.provider.d.ts.map +1 -1
- package/dist/lib/workbench-view.provider.js +43 -7
- package/dist/lib/workbench-view.provider.js.map +1 -1
- package/dist/lib/workbench.middleware.js +8 -8
- package/dist/lib/workbench.middleware.js.map +1 -1
- package/dist/lib/workbench.service.d.ts +44 -3
- package/dist/lib/workbench.service.d.ts.map +1 -1
- package/dist/lib/workbench.service.js +788 -152
- package/dist/lib/workbench.service.js.map +1 -1
- package/package.json +2 -1
|
@@ -9,9 +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
|
-
import {
|
|
14
|
+
import { CATALOG_REVIEW_BATCH_LIMIT } from './constants.js';
|
|
15
|
+
import { CatalogTypes, ClauseTypes, CustomerProfitRateStatuses, DomainError, DomainErrorCodes, RefundValidationMethods, SalesPaymentStatuses, SanctionHitStatuses, StalledCatalogImportDecisions } from './types.js';
|
|
15
16
|
import { deriveSalesLine, assertReceivedAmount } from './domain/finance.js';
|
|
16
17
|
import { parseCustomerContractNo, parsePurchaseContractNo } from './domain/contract-number.js';
|
|
17
18
|
import { isProductNameMatch, normalizeExactName, normalizeForProductMatch, normalizeHsCode, normalizeSanctionSource } from './domain/normalization.js';
|
|
@@ -23,7 +24,7 @@ import { TradeComplianceFileStorage } from './adapters/file-storage.js';
|
|
|
23
24
|
import { TranslationProvider } from './adapters/translation.provider.js';
|
|
24
25
|
import { parseBankAccountType, parseExchangeRateInput, parseExpectedRevision, validateBankSettingsInput, validateProfitSettingsInput, validatePurchaseLineDiscriminators, validateTaxRates } from './runtime-validation.js';
|
|
25
26
|
import { publicCustomerContractReanalysisDto, publicImportTaskDto } from './public-dto.js';
|
|
26
|
-
import { classifyCandidate } from './catalog-import/confidence.js';
|
|
27
|
+
import { classifyCandidate, normalizeCatalogCandidate } from './catalog-import/confidence.js';
|
|
27
28
|
import { catalogTerminalOutcome } from './catalog-import/outcome.js';
|
|
28
29
|
import { analyticsBusinessDetailFromRaw, analyticsDashboardFromRaw, analyticsDashboardStatement, analyticsDetailStatement, analyticsDetailTotalFromRaw, analyticsExportComplianceFromRaw, analyticsExportCustomersFromRaw, analyticsExportFundRisksFromRaw, analyticsExportSalesFileReviewsFromRaw, analyticsExportStatements, analyticsExportSuppliersFromRaw, resolveAnalyticsPeriod } from './analytics-query.js';
|
|
29
30
|
import { analyticsExportMaxRows, renderAnalyticsExportWorkbook } from './analytics-export.js';
|
|
@@ -209,17 +210,18 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
209
210
|
return { ...transactionResult, sourceFileRemoved };
|
|
210
211
|
}
|
|
211
212
|
async deleteImportTaskTransientRows(manager, scope, task) {
|
|
213
|
+
await this.failCatalogDraft(manager, scope, task);
|
|
212
214
|
await manager.getRepository(CatalogReviewBlock).delete({ ...activeScope(scope), importTaskId: task.id });
|
|
213
215
|
await this.deleteEmptyImportCatalogBatch(manager, scope, task.id, ControlledCatalogBatch, ControlledGoodsRecord, ControlledCatalogImportIssue);
|
|
214
216
|
await this.deleteEmptyImportCatalogBatch(manager, scope, task.id, SanctionCatalogBatch, SanctionedCompanyRecord, SanctionCatalogImportIssue);
|
|
215
217
|
}
|
|
216
218
|
async deleteEmptyImportCatalogBatch(manager, scope, importTaskId, batchEntity, recordEntity, issueEntity) {
|
|
217
219
|
const batchRepo = manager.getRepository(batchEntity);
|
|
218
|
-
const batch = await batchRepo.findOne({ where: { ...activeScope(scope), importTaskId } });
|
|
220
|
+
const batch = await batchRepo.findOne({ where: { ...activeScope(scope), importTaskId }, withDeleted: true });
|
|
219
221
|
if (!batch)
|
|
220
222
|
return;
|
|
221
223
|
const committedRows = await manager.getRepository(recordEntity).count({
|
|
222
|
-
where: { ...activeScope(scope), batchId: batch.id }
|
|
224
|
+
where: { ...activeScope(scope), batchId: batch.id }, withDeleted: true
|
|
223
225
|
});
|
|
224
226
|
if (committedRows > 0)
|
|
225
227
|
return;
|
|
@@ -230,8 +232,8 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
230
232
|
const where = { ...activeScope(scope), sourceFileId: task.sourceFileId };
|
|
231
233
|
const [otherTasks, controlledBatches, sanctionBatches, contracts, orders] = await Promise.all([
|
|
232
234
|
manager.getRepository(ImportTask).count({ where: { ...where, id: Not(task.id) } }),
|
|
233
|
-
manager.getRepository(ControlledCatalogBatch).count({ where }),
|
|
234
|
-
manager.getRepository(SanctionCatalogBatch).count({ where }),
|
|
235
|
+
manager.getRepository(ControlledCatalogBatch).count({ where, withDeleted: true }),
|
|
236
|
+
manager.getRepository(SanctionCatalogBatch).count({ where, withDeleted: true }),
|
|
235
237
|
manager.getRepository(CustomerContract).count({ where }),
|
|
236
238
|
manager.getRepository(PurchaseOrder).count({ where })
|
|
237
239
|
]);
|
|
@@ -290,21 +292,27 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
290
292
|
return { run: publicCustomerContractReanalysisDto(run), consumed };
|
|
291
293
|
});
|
|
292
294
|
}
|
|
293
|
-
async listControlledBatches(scope, query = {}) { return this.pagedQuery(ControlledCatalogBatch, scope, query, ['sourceFileName'], { catalogType: query.parameters?.catalogType }); }
|
|
294
|
-
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' }); }
|
|
297
|
+
async listControlledBatchHistory(scope, query = {}) { return this.pagedBatchHistoryQuery(ControlledCatalogBatch, scope, query, { catalogType: query.parameters?.catalogType }); }
|
|
298
|
+
async listSanctionBatchHistory(scope, query = {}) { return this.pagedBatchHistoryQuery(SanctionCatalogBatch, scope, query); }
|
|
295
299
|
async saveControlledGoods(scope, input) {
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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);
|
|
304
312
|
}
|
|
305
313
|
async updateControlledGoods(scope, id, input) {
|
|
306
314
|
const repo = this.dataSource.getRepository(ControlledGoodsRecord);
|
|
307
|
-
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() } }), '管控商品不存在');
|
|
308
316
|
Object.assign(item, pick(input, ['catalogType', 'productName', 'hsCode', 'controlDescription']));
|
|
309
317
|
item.normalizedProductName = item.productName.trim();
|
|
310
318
|
item.normalizedHsCode = String(item.hsCode ?? '').replace(/\s/g, '');
|
|
@@ -447,6 +455,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
447
455
|
}
|
|
448
456
|
async persistControlledCatalog(scope, input, localizedRows, claimId) {
|
|
449
457
|
return this.dataSource.transaction(async (manager) => {
|
|
458
|
+
await lockCatalogMutation(manager, scope, this.dataSource.options?.type === 'postgres');
|
|
450
459
|
const batchRepo = manager.getRepository(ControlledCatalogBatch);
|
|
451
460
|
const recordRepo = manager.getRepository(ControlledGoodsRecord);
|
|
452
461
|
const taskRepo = manager.getRepository(ImportTask);
|
|
@@ -460,7 +469,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
460
469
|
await taskRepo.save(task);
|
|
461
470
|
const now = new Date();
|
|
462
471
|
if (input.replaceBatchId) {
|
|
463
|
-
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() } }), '被覆盖目录批次不存在');
|
|
464
473
|
old.deletedAt = now;
|
|
465
474
|
old.deletedById = scope.userId ?? null;
|
|
466
475
|
old.deleteOperation = 'CATALOG_REPLACE';
|
|
@@ -468,7 +477,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
468
477
|
await batchRepo.save(old);
|
|
469
478
|
await recordRepo.update({ ...activeScope(scope), batchId: old.id, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'CATALOG_REPLACE' });
|
|
470
479
|
}
|
|
471
|
-
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 }));
|
|
472
481
|
await this.appendControlledCatalogRows(manager, scope, batch, input.rows, localizedRows);
|
|
473
482
|
await batchRepo.save(batch);
|
|
474
483
|
task.status = 'SUCCEEDED';
|
|
@@ -482,6 +491,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
482
491
|
}
|
|
483
492
|
async appendCompletedControlledCatalog(scope, input, localizedRows, expectedBatchId) {
|
|
484
493
|
return this.dataSource.transaction(async (manager) => {
|
|
494
|
+
await lockCatalogMutation(manager, scope, this.dataSource.options?.type === 'postgres');
|
|
485
495
|
const task = await required(manager.getRepository(ImportTask).findOne({
|
|
486
496
|
where: { ...activeScope(scope), id: input.importTaskId },
|
|
487
497
|
lock: { mode: 'pessimistic_write' }
|
|
@@ -534,9 +544,11 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
534
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 }));
|
|
535
545
|
continue;
|
|
536
546
|
}
|
|
537
|
-
const duplicate = await recordRepo.findOne({ where:
|
|
538
|
-
|
|
539
|
-
|
|
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
|
+
] });
|
|
540
552
|
if (duplicate && await this.catalogIssueExists(issueRepo, scope, batch.id, 'DUPLICATE', row))
|
|
541
553
|
continue;
|
|
542
554
|
changed = true;
|
|
@@ -546,7 +558,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
546
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: '目录中已存在相同商品名称和海关编码' }));
|
|
547
559
|
continue;
|
|
548
560
|
}
|
|
549
|
-
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 }));
|
|
550
562
|
batch.successCount += 1;
|
|
551
563
|
batch.riskRecalculatedAt = null;
|
|
552
564
|
}
|
|
@@ -693,20 +705,24 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
693
705
|
return { fileName: `${batch.sourceFileName}-导入问题明细.xlsx`, buffer: await renderImportIssuesWorkbook(issues) };
|
|
694
706
|
}
|
|
695
707
|
async saveSanctionedCompany(scope, input) {
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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));
|
|
704
720
|
await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
|
|
705
721
|
return sanctionedCompanyDto(saved);
|
|
706
722
|
}
|
|
707
723
|
async updateSanctionedCompany(scope, id, input) {
|
|
708
724
|
const repo = this.dataSource.getRepository(SanctionedCompanyRecord);
|
|
709
|
-
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() } }), '制裁公司不存在');
|
|
710
726
|
Object.assign(item, pick(input, ['companyName', 'countryOrRegion', 'sanctionListSource', 'sanctionReason', 'effectiveDate', 'remarks']));
|
|
711
727
|
item.normalizedCompanyName = normalizeExactName(item.companyName);
|
|
712
728
|
item.normalizedSanctionListSource = normalizeSanctionSource(item.sanctionListSource);
|
|
@@ -717,6 +733,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
717
733
|
}
|
|
718
734
|
async importSanctionCatalog(scope, input) {
|
|
719
735
|
const batch = await this.dataSource.transaction(async (manager) => {
|
|
736
|
+
await lockCatalogMutation(manager, scope, this.dataSource.options?.type === 'postgres');
|
|
720
737
|
const batchRepo = manager.getRepository(SanctionCatalogBatch);
|
|
721
738
|
const recordRepo = manager.getRepository(SanctionedCompanyRecord);
|
|
722
739
|
const issueRepo = manager.getRepository(SanctionCatalogImportIssue);
|
|
@@ -733,7 +750,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
733
750
|
await taskRepo.save(task);
|
|
734
751
|
const now = new Date();
|
|
735
752
|
if (input.replaceBatchId) {
|
|
736
|
-
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() } }), '被覆盖制裁批次不存在');
|
|
737
754
|
old.deletedAt = now;
|
|
738
755
|
old.deletedById = scope.userId ?? null;
|
|
739
756
|
old.deleteOperation = 'SANCTION_CATALOG_REPLACE';
|
|
@@ -741,7 +758,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
741
758
|
await batchRepo.save(old);
|
|
742
759
|
await recordRepo.update({ ...activeScope(scope), batchId: old.id, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SANCTION_CATALOG_REPLACE' });
|
|
743
760
|
}
|
|
744
|
-
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 }));
|
|
745
762
|
}
|
|
746
763
|
let changed = false;
|
|
747
764
|
for (let index = 0; index < input.rows.length; index += 1) {
|
|
@@ -758,9 +775,11 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
758
775
|
}
|
|
759
776
|
const normalizedCompanyName = normalizeExactName(companyName);
|
|
760
777
|
const normalizedSanctionListSource = normalizeSanctionSource(row.sanctionListSource);
|
|
761
|
-
const duplicate = await recordRepo.findOne({ where:
|
|
762
|
-
|
|
763
|
-
|
|
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
|
+
] });
|
|
764
783
|
if (duplicate && await this.catalogIssueExists(issueRepo, scope, result.id, 'DUPLICATE', row))
|
|
765
784
|
continue;
|
|
766
785
|
changed = true;
|
|
@@ -770,9 +789,11 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
770
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: '制裁目录中已存在相同公司和名单来源' }));
|
|
771
790
|
continue;
|
|
772
791
|
}
|
|
773
|
-
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 }));
|
|
774
793
|
result.successCount += 1;
|
|
775
794
|
}
|
|
795
|
+
if (changed)
|
|
796
|
+
result.riskRecalculatedAt = null;
|
|
776
797
|
const saved = !appending || changed ? await batchRepo.save(result) : result;
|
|
777
798
|
if (!appending) {
|
|
778
799
|
task.status = 'SUCCEEDED';
|
|
@@ -782,7 +803,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
782
803
|
}
|
|
783
804
|
return saved;
|
|
784
805
|
});
|
|
785
|
-
await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
|
|
806
|
+
await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED', batch.id);
|
|
786
807
|
return batch;
|
|
787
808
|
}
|
|
788
809
|
async beginFastCatalogImport(scope, input) {
|
|
@@ -799,25 +820,17 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
799
820
|
return { committedCursor: task.progress.committedCursor, batchId: task.resultEntityId };
|
|
800
821
|
}
|
|
801
822
|
assertImportTaskResultWritable(task);
|
|
802
|
-
const now = new Date();
|
|
803
823
|
let batchId;
|
|
804
824
|
if (task.documentType === 'CONTROLLED_CATALOG') {
|
|
805
825
|
const catalogType = input.catalogType;
|
|
806
826
|
if (!catalogType)
|
|
807
827
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '管控目录缺少目录类型');
|
|
808
828
|
const batchRepo = manager.getRepository(ControlledCatalogBatch);
|
|
809
|
-
const recordRepo = manager.getRepository(ControlledGoodsRecord);
|
|
810
829
|
if (input.replaceBatchId) {
|
|
811
|
-
|
|
812
|
-
old.deletedAt = now;
|
|
813
|
-
old.deletedById = scope.userId ?? null;
|
|
814
|
-
old.deleteOperation = 'CATALOG_REPLACE';
|
|
815
|
-
old.updatedById = scope.userId ?? null;
|
|
816
|
-
await batchRepo.save(old);
|
|
817
|
-
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() } }), '被覆盖目录批次不存在');
|
|
818
831
|
}
|
|
819
832
|
const batch = await batchRepo.save(batchRepo.create({
|
|
820
|
-
...auditScope(scope), catalogType, sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName,
|
|
833
|
+
...auditScope(scope), lifecycleStatus: 'DRAFT', catalogType, sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName,
|
|
821
834
|
importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null,
|
|
822
835
|
totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0, skippedCount: 0,
|
|
823
836
|
inferenceMetadata: input.inferenceMetadata ?? null,
|
|
@@ -827,21 +840,14 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
827
840
|
}
|
|
828
841
|
else {
|
|
829
842
|
const batchRepo = manager.getRepository(SanctionCatalogBatch);
|
|
830
|
-
const recordRepo = manager.getRepository(SanctionedCompanyRecord);
|
|
831
843
|
if (input.replaceBatchId) {
|
|
832
|
-
|
|
833
|
-
old.deletedAt = now;
|
|
834
|
-
old.deletedById = scope.userId ?? null;
|
|
835
|
-
old.deleteOperation = 'SANCTION_CATALOG_REPLACE';
|
|
836
|
-
old.updatedById = scope.userId ?? null;
|
|
837
|
-
await batchRepo.save(old);
|
|
838
|
-
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() } }), '被覆盖制裁目录批次不存在');
|
|
839
845
|
}
|
|
840
846
|
const batch = await batchRepo.save(batchRepo.create({
|
|
841
|
-
...auditScope(scope), sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName,
|
|
847
|
+
...auditScope(scope), lifecycleStatus: 'DRAFT', sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName,
|
|
842
848
|
importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null,
|
|
843
849
|
totalCount: 0, successCount: 0, duplicateCount: 0, invalidCount: 0, skippedCount: 0,
|
|
844
|
-
inferenceMetadata: input.inferenceMetadata ?? null, importTaskId: task.id
|
|
850
|
+
inferenceMetadata: input.inferenceMetadata ?? null, importTaskId: task.id, riskRecalculatedAt: null
|
|
845
851
|
}));
|
|
846
852
|
batchId = batch.id;
|
|
847
853
|
}
|
|
@@ -854,43 +860,47 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
854
860
|
});
|
|
855
861
|
}
|
|
856
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;
|
|
857
875
|
const controlledRows = input.candidates.flatMap(candidate => candidate.kind === 'CONTROLLED'
|
|
858
876
|
? candidateToControlledRows(candidate)
|
|
859
877
|
: []);
|
|
860
878
|
const localizedRows = controlledRows.length ? await this.localizeControlledCatalogRows(controlledRows, scope.organizationId) : [];
|
|
861
879
|
return this.dataSource.transaction(async (manager) => {
|
|
880
|
+
const { task, controlledBatch, sanctionBatch } = await this.lockFastCatalogAppend(manager, scope, input.taskId);
|
|
862
881
|
const taskRepo = manager.getRepository(ImportTask);
|
|
863
|
-
const task = await required(taskRepo.findOne({
|
|
864
|
-
where: { ...activeScope(scope), id: input.taskId },
|
|
865
|
-
lock: { mode: 'pessimistic_write' }
|
|
866
|
-
}), '解析任务不存在');
|
|
867
|
-
if (!task.progress || task.progress.mode !== 'FAST_PIPELINE' || !task.resultEntityId) {
|
|
868
|
-
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入尚未初始化');
|
|
869
|
-
}
|
|
870
882
|
if (task.progress.committedCursor >= input.endCursor && input.endCursor > input.startCursor)
|
|
871
883
|
return task.progress;
|
|
872
884
|
if (task.progress.committedCursor !== input.startCursor) {
|
|
873
885
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入游标不一致');
|
|
874
886
|
}
|
|
875
887
|
if (task.documentType === 'CONTROLLED_CATALOG') {
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
await
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
task.progress.
|
|
882
|
-
task.progress.
|
|
883
|
-
task.progress.invalidCount = batch.invalidCount;
|
|
884
|
-
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;
|
|
885
895
|
}
|
|
886
896
|
else {
|
|
887
|
-
const
|
|
888
|
-
await this.appendCatalogSkippedSources(manager.getRepository(SanctionCatalogImportIssue), scope,
|
|
889
|
-
await manager.getRepository(SanctionCatalogBatch).save(
|
|
890
|
-
task.progress.successCount =
|
|
891
|
-
task.progress.duplicateCount =
|
|
892
|
-
task.progress.invalidCount =
|
|
893
|
-
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;
|
|
894
904
|
}
|
|
895
905
|
let insertedReviewCount = 0;
|
|
896
906
|
if (input.reviewBlocks.length) {
|
|
@@ -907,7 +917,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
907
917
|
confidence: block.confidence, status: 'PENDING', ordinal: block.ordinal,
|
|
908
918
|
sourceLocation: block.sourceLocation || null, sourceContent: block.sourceContent || null,
|
|
909
919
|
payload: block.payload, reasons: block.reasons,
|
|
910
|
-
leaseToken: null, leaseExpiresAt: null, resolvedAt: null
|
|
920
|
+
leaseToken: null, leaseExpiresAt: null, resolvedAt: null, resolutionFingerprint: null
|
|
911
921
|
}));
|
|
912
922
|
insertedReviewCount += 1;
|
|
913
923
|
}
|
|
@@ -923,6 +933,33 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
923
933
|
return task.progress;
|
|
924
934
|
});
|
|
925
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
|
+
}
|
|
926
963
|
async finalizeFastCatalogImport(scope, taskId) {
|
|
927
964
|
const task = await this.dataSource.transaction(async (manager) => {
|
|
928
965
|
const repo = manager.getRepository(ImportTask);
|
|
@@ -932,7 +969,12 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
932
969
|
if (!current.progress || current.progress.mode !== 'FAST_PIPELINE') {
|
|
933
970
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '快速目录导入进度不存在');
|
|
934
971
|
}
|
|
972
|
+
if (current.status === 'SUCCEEDED') {
|
|
973
|
+
await this.assertCatalogBatchPublished(manager, scope, current);
|
|
974
|
+
return current;
|
|
975
|
+
}
|
|
935
976
|
if (current.progress.reviewPendingCount > 0) {
|
|
977
|
+
await this.markCatalogDraftReviewing(manager, scope, current);
|
|
936
978
|
current.status = 'AWAITING_REVIEW';
|
|
937
979
|
current.progress.stage = 'REVIEWING';
|
|
938
980
|
current.progress.observerStatus = 'PENDING';
|
|
@@ -950,6 +992,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
950
992
|
await this.applyImportTaskFailure(manager, scope, current, 'FAILED', outcome.errorCode, outcome.errorMessage);
|
|
951
993
|
}
|
|
952
994
|
else {
|
|
995
|
+
await this.publishCatalogDraft(manager, scope, current);
|
|
953
996
|
current.status = 'SUCCEEDED';
|
|
954
997
|
current.progress.stage = 'SUCCEEDED';
|
|
955
998
|
current.progress.observerStatus = 'COMPLETED';
|
|
@@ -966,10 +1009,125 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
966
1009
|
if (task.documentType === 'CONTROLLED_CATALOG')
|
|
967
1010
|
await this.recalculateProductRisks(scope, 'CATALOG_CHANGED', task.resultEntityId ?? undefined);
|
|
968
1011
|
else
|
|
969
|
-
await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
|
|
1012
|
+
await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED', task.resultEntityId ?? undefined);
|
|
970
1013
|
}
|
|
971
1014
|
return task;
|
|
972
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
|
+
}
|
|
973
1131
|
async waitImportProgress(scope, taskId, afterProgressVersion = 0, waitMs = 10_000) {
|
|
974
1132
|
const deadline = Date.now() + Math.max(0, Math.min(waitMs, 10_000));
|
|
975
1133
|
while (true) {
|
|
@@ -986,8 +1144,8 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
986
1144
|
await new Promise(resolve => setTimeout(resolve, Math.min(250, remaining)));
|
|
987
1145
|
}
|
|
988
1146
|
}
|
|
989
|
-
async nextCatalogReviewBatch(scope, taskId, requestedLimit =
|
|
990
|
-
const limit = Math.max(1, Math.min(requestedLimit,
|
|
1147
|
+
async nextCatalogReviewBatch(scope, taskId, requestedLimit = CATALOG_REVIEW_BATCH_LIMIT) {
|
|
1148
|
+
const limit = Math.max(1, Math.min(requestedLimit, CATALOG_REVIEW_BATCH_LIMIT));
|
|
991
1149
|
return this.dataSource.transaction(async (manager) => {
|
|
992
1150
|
const task = await required(manager.getRepository(ImportTask).findOne({
|
|
993
1151
|
where: { ...activeScope(scope), id: taskId }, lock: { mode: 'pessimistic_write' }
|
|
@@ -999,6 +1157,13 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
999
1157
|
await repo.update({
|
|
1000
1158
|
...activeScope(scope), importTaskId: taskId, status: 'LEASED', leaseExpiresAt: LessThanOrEqual(new Date())
|
|
1001
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
|
+
}
|
|
1002
1167
|
const blocks = await repo.createQueryBuilder('block')
|
|
1003
1168
|
.setLock('pessimistic_write')
|
|
1004
1169
|
.setOnLocked('skip_locked')
|
|
@@ -1031,10 +1196,10 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1031
1196
|
});
|
|
1032
1197
|
}
|
|
1033
1198
|
async submitCatalogReviewBatch(scope, taskId, leaseToken, items) {
|
|
1034
|
-
if (!leaseToken.trim() || items.length === 0 || items.length >
|
|
1199
|
+
if (!leaseToken.trim() || items.length === 0 || items.length > CATALOG_REVIEW_BATCH_LIMIT) {
|
|
1035
1200
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '复核提交参数无效');
|
|
1036
1201
|
}
|
|
1037
|
-
|
|
1202
|
+
const result = await this.dataSource.transaction(async (manager) => {
|
|
1038
1203
|
const taskRepo = manager.getRepository(ImportTask);
|
|
1039
1204
|
const task = await required(taskRepo.findOne({
|
|
1040
1205
|
where: { ...activeScope(scope), id: taskId }, lock: { mode: 'pessimistic_write' }
|
|
@@ -1042,6 +1207,30 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1042
1207
|
if (!task.progress || !task.resultEntityId)
|
|
1043
1208
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录复核任务状态无效');
|
|
1044
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
|
+
}
|
|
1045
1234
|
const blocks = await reviewRepo.find({ where: {
|
|
1046
1235
|
...activeScope(scope), importTaskId: taskId, id: In(items.map(item => item.blockId)),
|
|
1047
1236
|
status: 'LEASED', leaseToken
|
|
@@ -1051,6 +1240,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1051
1240
|
}
|
|
1052
1241
|
const acceptedCandidates = [];
|
|
1053
1242
|
const rejectedBlocks = [];
|
|
1243
|
+
const errors = [];
|
|
1054
1244
|
let resolved = 0;
|
|
1055
1245
|
let rejected = 0;
|
|
1056
1246
|
for (const item of items) {
|
|
@@ -1059,22 +1249,35 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1059
1249
|
block.status = 'REJECTED';
|
|
1060
1250
|
rejectedBlocks.push(block);
|
|
1061
1251
|
rejected += 1;
|
|
1252
|
+
block.resolvedAt = new Date();
|
|
1253
|
+
block.leaseExpiresAt = null;
|
|
1254
|
+
block.resolutionFingerprint = catalogReviewResolutionFingerprint(item);
|
|
1062
1255
|
}
|
|
1063
1256
|
else {
|
|
1064
|
-
const
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
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);
|
|
1266
|
+
}
|
|
1267
|
+
catch {
|
|
1268
|
+
restorePendingReviewBlock(block);
|
|
1269
|
+
errors.push({ blockId: block.id, reasons: ['REVIEW_PAYLOAD_INVALID'] });
|
|
1270
|
+
continue;
|
|
1069
1271
|
}
|
|
1272
|
+
const candidate = evaluation.candidate;
|
|
1070
1273
|
acceptedCandidates.push(candidate);
|
|
1071
|
-
block.payload =
|
|
1274
|
+
block.payload = candidate.values;
|
|
1072
1275
|
block.status = 'RESOLVED';
|
|
1073
1276
|
resolved += 1;
|
|
1277
|
+
block.resolvedAt = new Date();
|
|
1278
|
+
block.leaseExpiresAt = null;
|
|
1279
|
+
block.resolutionFingerprint = resolutionFingerprint;
|
|
1074
1280
|
}
|
|
1075
|
-
block.resolvedAt = new Date();
|
|
1076
|
-
block.leaseToken = null;
|
|
1077
|
-
block.leaseExpiresAt = null;
|
|
1078
1281
|
}
|
|
1079
1282
|
if (task.documentType === 'CONTROLLED_CATALOG' && acceptedCandidates.length) {
|
|
1080
1283
|
const batchRepo = manager.getRepository(ControlledCatalogBatch);
|
|
@@ -1103,7 +1306,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1103
1306
|
await reviewRepo.save(blocks);
|
|
1104
1307
|
task.progress.reviewResolvedCount += resolved;
|
|
1105
1308
|
task.progress.reviewRejectedCount += rejected;
|
|
1106
|
-
|
|
1309
|
+
await this.reconcileCatalogReviewPendingCount(reviewRepo, scope, task);
|
|
1107
1310
|
task.progress.progressVersion += 1;
|
|
1108
1311
|
task.progress.lastProgressAt = new Date().toISOString();
|
|
1109
1312
|
if (task.progress.reviewPendingCount === 0) {
|
|
@@ -1118,6 +1321,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1118
1321
|
await this.applyImportTaskFailure(manager, scope, task, 'FAILED', outcome.errorCode, outcome.errorMessage);
|
|
1119
1322
|
}
|
|
1120
1323
|
else {
|
|
1324
|
+
await this.publishCatalogDraft(manager, scope, task);
|
|
1121
1325
|
task.status = 'SUCCEEDED';
|
|
1122
1326
|
task.progress.stage = 'SUCCEEDED';
|
|
1123
1327
|
task.progress.observerStatus = 'COMPLETED';
|
|
@@ -1127,12 +1331,28 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1127
1331
|
}
|
|
1128
1332
|
}
|
|
1129
1333
|
else {
|
|
1334
|
+
await this.markCatalogDraftReviewing(manager, scope, task);
|
|
1130
1335
|
task.status = 'AWAITING_REVIEW';
|
|
1131
1336
|
task.progress.stage = 'REVIEWING';
|
|
1132
1337
|
}
|
|
1133
1338
|
await taskRepo.save(task);
|
|
1134
|
-
return { resolved, rejected, remaining: task.progress.reviewPendingCount, task: publicImportTaskDto(task) };
|
|
1339
|
+
return { resolved, rejected, remaining: task.progress.reviewPendingCount, errors, task: publicImportTaskDto(task) };
|
|
1135
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;
|
|
1136
1356
|
}
|
|
1137
1357
|
async setImportObserverConversation(scope, taskId, conversationId) {
|
|
1138
1358
|
const normalizedConversationId = requiredId(conversationId, '观察会话标识');
|
|
@@ -1153,12 +1373,12 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1153
1373
|
return repo.save(task);
|
|
1154
1374
|
});
|
|
1155
1375
|
}
|
|
1156
|
-
async appendFastSanctionCandidates(manager, scope, batchId, candidates) {
|
|
1376
|
+
async appendFastSanctionCandidates(manager, scope, batchId, candidates, validatedBatch) {
|
|
1157
1377
|
const batchRepo = manager.getRepository(SanctionCatalogBatch);
|
|
1158
1378
|
const recordRepo = manager.getRepository(SanctionedCompanyRecord);
|
|
1159
1379
|
const aliasRepo = manager.getRepository(SanctionedCompanyAlias);
|
|
1160
1380
|
const issueRepo = manager.getRepository(SanctionCatalogImportIssue);
|
|
1161
|
-
const batch = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: batchId } }), '制裁目录批次不存在');
|
|
1381
|
+
const batch = validatedBatch ?? await required(batchRepo.findOne({ where: { ...activeScope(scope), id: batchId } }), '制裁目录批次不存在');
|
|
1162
1382
|
for (const candidate of candidates.filter(value => value.kind === 'SANCTION')) {
|
|
1163
1383
|
const companyName = String(candidate.values.companyName ?? '').trim();
|
|
1164
1384
|
const sourceLocation = candidate.evidence[0]?.sourceLocation ?? null;
|
|
@@ -1168,7 +1388,11 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1168
1388
|
const normalizedCompanyName = normalizeExactName(companyName);
|
|
1169
1389
|
const sanctionListSource = optionalString(candidate.values.sanctionListSource);
|
|
1170
1390
|
const normalizedSanctionListSource = normalizeSanctionSource(sanctionListSource);
|
|
1171
|
-
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
|
+
] });
|
|
1172
1396
|
batch.totalCount += 1;
|
|
1173
1397
|
if (duplicate) {
|
|
1174
1398
|
batch.duplicateCount += 1;
|
|
@@ -1180,7 +1404,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1180
1404
|
continue;
|
|
1181
1405
|
}
|
|
1182
1406
|
const record = await recordRepo.save(recordRepo.create({
|
|
1183
|
-
...auditScope(scope), batchId: batch.id, sourceType: 'FILE_IMPORT', companyName, normalizedCompanyName,
|
|
1407
|
+
...auditScope(scope), batchId: batch.id, lifecycleStatus: batch.lifecycleStatus, sourceType: 'FILE_IMPORT', companyName, normalizedCompanyName,
|
|
1184
1408
|
countryOrRegion: optionalString(candidate.values.countryOrRegion), sanctionListSource,
|
|
1185
1409
|
normalizedSanctionListSource, sanctionReason: optionalString(candidate.values.sanctionReason),
|
|
1186
1410
|
effectiveDate: optionalString(candidate.values.effectiveDate), remarks: optionalString(candidate.values.remarks),
|
|
@@ -1245,6 +1469,34 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1245
1469
|
}
|
|
1246
1470
|
return batchRepo.save(batch);
|
|
1247
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
|
+
}
|
|
1248
1500
|
async catalogBatchCounts(manager, scope, task) {
|
|
1249
1501
|
if (!task.resultEntityId)
|
|
1250
1502
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录导入批次不存在');
|
|
@@ -1262,7 +1514,224 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1262
1514
|
skippedCount: batch.skippedCount ?? 0
|
|
1263
1515
|
};
|
|
1264
1516
|
}
|
|
1265
|
-
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) {
|
|
1266
1735
|
if (!task.resultEntityId || (task.documentType !== 'CONTROLLED_CATALOG' && task.documentType !== 'SANCTION_CATALOG'))
|
|
1267
1736
|
return;
|
|
1268
1737
|
const now = new Date();
|
|
@@ -1270,11 +1739,9 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1270
1739
|
const batchRepo = manager.getRepository(ControlledCatalogBatch);
|
|
1271
1740
|
const recordRepo = manager.getRepository(ControlledGoodsRecord);
|
|
1272
1741
|
const current = await batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId } });
|
|
1273
|
-
if (!current
|
|
1274
|
-
return;
|
|
1275
|
-
const replaced = await batchRepo.findOne({ where: { ...activeScope(scope), id: current.replacedBatchId } });
|
|
1276
|
-
if (!replaced || replaced.deleteOperation !== 'CATALOG_REPLACE')
|
|
1742
|
+
if (!current || (current.lifecycleStatus !== 'DRAFT' && current.lifecycleStatus !== 'REVIEWING' && (!allowActive || current.lifecycleStatus !== 'ACTIVE')))
|
|
1277
1743
|
return;
|
|
1744
|
+
current.lifecycleStatus = 'FAILED';
|
|
1278
1745
|
current.deletedAt = now;
|
|
1279
1746
|
current.deletedById = scope.userId ?? null;
|
|
1280
1747
|
current.deleteOperation = 'IMPORT_FAILED';
|
|
@@ -1282,26 +1749,16 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1282
1749
|
await batchRepo.save(current);
|
|
1283
1750
|
await recordRepo.update({
|
|
1284
1751
|
...activeScope(scope), batchId: current.id, deletedAt: IsNull()
|
|
1285
|
-
}, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'IMPORT_FAILED', updatedById: scope.userId ?? null });
|
|
1286
|
-
replaced.deletedAt = null;
|
|
1287
|
-
replaced.deletedById = null;
|
|
1288
|
-
replaced.deleteOperation = null;
|
|
1289
|
-
replaced.updatedById = scope.userId ?? null;
|
|
1290
|
-
await batchRepo.save(replaced);
|
|
1291
|
-
await recordRepo.update({
|
|
1292
|
-
...activeScope(scope), batchId: replaced.id, deleteOperation: 'CATALOG_REPLACE'
|
|
1293
|
-
}, { 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 });
|
|
1294
1753
|
return;
|
|
1295
1754
|
}
|
|
1296
1755
|
const batchRepo = manager.getRepository(SanctionCatalogBatch);
|
|
1297
1756
|
const recordRepo = manager.getRepository(SanctionedCompanyRecord);
|
|
1298
1757
|
const aliasRepo = manager.getRepository(SanctionedCompanyAlias);
|
|
1299
1758
|
const current = await batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId } });
|
|
1300
|
-
if (!current
|
|
1301
|
-
return;
|
|
1302
|
-
const replaced = await batchRepo.findOne({ where: { ...activeScope(scope), id: current.replacedBatchId } });
|
|
1303
|
-
if (!replaced || replaced.deleteOperation !== 'SANCTION_CATALOG_REPLACE')
|
|
1759
|
+
if (!current || (current.lifecycleStatus !== 'DRAFT' && current.lifecycleStatus !== 'REVIEWING' && (!allowActive || current.lifecycleStatus !== 'ACTIVE')))
|
|
1304
1760
|
return;
|
|
1761
|
+
current.lifecycleStatus = 'FAILED';
|
|
1305
1762
|
current.deletedAt = now;
|
|
1306
1763
|
current.deletedById = scope.userId ?? null;
|
|
1307
1764
|
current.deleteOperation = 'IMPORT_FAILED';
|
|
@@ -1317,15 +1774,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1317
1774
|
}
|
|
1318
1775
|
await recordRepo.update({
|
|
1319
1776
|
...activeScope(scope), batchId: current.id, deletedAt: IsNull()
|
|
1320
|
-
}, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'IMPORT_FAILED', updatedById: scope.userId ?? null });
|
|
1321
|
-
replaced.deletedAt = null;
|
|
1322
|
-
replaced.deletedById = null;
|
|
1323
|
-
replaced.deleteOperation = null;
|
|
1324
|
-
replaced.updatedById = scope.userId ?? null;
|
|
1325
|
-
await batchRepo.save(replaced);
|
|
1326
|
-
await recordRepo.update({
|
|
1327
|
-
...activeScope(scope), batchId: replaced.id, deleteOperation: 'SANCTION_CATALOG_REPLACE'
|
|
1328
|
-
}, { 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 });
|
|
1329
1778
|
}
|
|
1330
1779
|
async applyImportTaskFailure(manager, scope, task, status, errorCode, errorMessage, completedAt = new Date()) {
|
|
1331
1780
|
task.status = status;
|
|
@@ -1335,7 +1784,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1335
1784
|
if (task.progress?.mode === 'FAST_PIPELINE') {
|
|
1336
1785
|
task.progress.stage = 'FAILED';
|
|
1337
1786
|
task.progress.observerStatus = 'FAILED';
|
|
1338
|
-
await this.
|
|
1787
|
+
await this.failCatalogDraft(manager, scope, task);
|
|
1339
1788
|
}
|
|
1340
1789
|
}
|
|
1341
1790
|
async createImportTask(scope, input) {
|
|
@@ -1767,7 +2216,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1767
2216
|
const lineRepo = manager.getRepository(PurchaseOrderLine);
|
|
1768
2217
|
for (let index = 0; index < draft.lines.length; index += 1) {
|
|
1769
2218
|
const line = draft.lines[index];
|
|
1770
|
-
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:
|
|
2219
|
+
await lineRepo.save(lineRepo.create({ ...auditScope(scope), purchaseOrderId: entity.id, lineNumber: line.lineNumber ?? index + 1, sourceType: line.sourceType ?? 'CONTRACT_EXTRACTED', productName: line.productName.trim(), normalizedProductName: normalizeForProductMatch(line.productName), englishName: line.englishName ?? null, englishNameSource: line.englishNameSource ?? null, productDescription: line.productDescription ?? null, modelSpecification: line.modelSpecification ?? null, unit: line.unit ?? null, quantity: defaultDecimalText(line.quantity), unitPriceTaxIncluded: defaultDecimalText(line.unitPriceTaxIncluded), lineAmountTaxIncluded: purchaseLineAmountText(line), vatRate: optionalString(line.vatRate), vatRateSource: line.vatRateSource ?? null, hsCode: line.hsCode ?? null, hsCodeStatus: line.hsCodeStatus ?? 'UNCONFIRMED', hsCodeSource: line.hsCodeSource ?? null, hsCodeReferenceSnapshotId: line.hsCodeReferenceSnapshotId ?? null, originCountry: line.originCountry ?? null, domesticSourceLocation: line.domesticSourceLocation ?? null, refundRate: optionalString(line.refundRate), refundRateSource: line.refundRateSource ?? null, refundPolicyReference: line.refundPolicyReference ?? null, refundPolicyDate: optionalString(line.refundPolicyDate), refundValidationMethod: line.refundValidationMethod ?? null, refundConfirmedAt: confirmedAt, refundConfirmedById: confirmedById, amountMismatchConfirmedAt: line.amountMismatch === true ? confirmedAt : null, amountMismatchConfirmedById: line.amountMismatch === true ? confirmedById : null, sourceLocation: line.sourceLocation ?? null, sourceContent: line.sourceContent ?? null }));
|
|
1771
2220
|
}
|
|
1772
2221
|
task.status = 'SUCCEEDED';
|
|
1773
2222
|
task.resultEntityId = entity.id;
|
|
@@ -1842,7 +2291,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1842
2291
|
const currentOrders = orders.filter(order => String(order.orderDate ?? '').startsWith(`${currentYear}-`));
|
|
1843
2292
|
const top = new Map();
|
|
1844
2293
|
for (const line of lines)
|
|
1845
|
-
top.set(line.productName, (top.get(line.productName) ?? new Decimal(0)).plus(line
|
|
2294
|
+
top.set(line.productName, (top.get(line.productName) ?? new Decimal(0)).plus(purchaseLineMetricAmount(line)));
|
|
1846
2295
|
const orderBalances = {};
|
|
1847
2296
|
for (const order of orders) {
|
|
1848
2297
|
const orderLines = lines.filter(line => line.purchaseOrderId === order.id);
|
|
@@ -1893,6 +2342,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1893
2342
|
const saved = await this.withLockedPurchaseLines(scope, existing.purchaseOrderId, async ({ lineRepo, lines, contracts, customers, manuals }) => {
|
|
1894
2343
|
const item = await required(Promise.resolve(lines.find(line => line.id === id) ?? null), '采购商品不存在');
|
|
1895
2344
|
Object.assign(item, pick(input, ['productName', 'englishName', 'englishNameSource', 'productDescription', 'modelSpecification', 'unit', 'quantity', 'unitPriceTaxIncluded', 'lineAmountTaxIncluded', 'vatRate', 'vatRateSource', 'hsCode', 'hsCodeStatus', 'hsCodeSource', 'originCountry', 'domesticSourceLocation', 'refundRate', 'refundRateSource', 'refundPolicyReference', 'refundPolicyDate', 'refundValidationMethod']));
|
|
2345
|
+
item.lineAmountTaxIncluded = purchaseLineAmountText(item);
|
|
1896
2346
|
validatePurchaseLineDiscriminators(item);
|
|
1897
2347
|
const rates = validateTaxRates(item, { purchaseOrderLineId: item.id, lineNumber: item.lineNumber, productName: item.productName });
|
|
1898
2348
|
item.vatRate = rates.vatRate;
|
|
@@ -1915,7 +2365,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1915
2365
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '采购商品数量必须大于 0');
|
|
1916
2366
|
const saved = await this.withLockedPurchaseLines(scope, purchaseOrderId, async ({ lineRepo, lines, contracts, customers, manuals }) => {
|
|
1917
2367
|
const lineNumber = lines.reduce((maximum, line) => Math.max(maximum, line.lineNumber), 0) + 1;
|
|
1918
|
-
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
|
|
2368
|
+
const item = lineRepo.create({ ...auditScope(scope), purchaseOrderId, lineNumber, sourceType: discriminators.sourceType ?? 'MANUAL_ENTRY', productName: input.productName.trim(), normalizedProductName: normalizeForProductMatch(input.productName), englishName: input.englishName ?? null, englishNameSource: discriminators.englishNameSource ?? null, productDescription: input.productDescription ?? null, modelSpecification: input.modelSpecification ?? null, unit: input.unit ?? null, quantity: input.quantity, unitPriceTaxIncluded: input.unitPriceTaxIncluded, lineAmountTaxIncluded: purchaseLineAmountText(input), vatRate: rates.vatRate, vatRateSource: discriminators.vatRateSource ?? 'MANUAL', hsCode: input.hsCode ?? null, hsCodeStatus: discriminators.hsCodeStatus ?? 'UNCONFIRMED', hsCodeSource: discriminators.hsCodeSource ?? null, hsCodeReferenceSnapshotId: null, originCountry: input.originCountry ?? null, domesticSourceLocation: input.domesticSourceLocation ?? null, refundRate: rates.refundRate, refundRateSource: discriminators.refundRateSource ?? null, refundPolicyReference: input.refundPolicyReference ?? null, refundPolicyDate: input.refundPolicyDate ?? null, refundValidationMethod: discriminators.refundValidationMethod ?? null, refundConfirmedAt: null, refundConfirmedById: null, amountMismatchConfirmedAt: null, amountMismatchConfirmedById: null, sourceLocation: null, sourceContent: null });
|
|
1919
2369
|
this.assertLockedPurchaseReceipts([...lines, item], contracts, customers, manuals);
|
|
1920
2370
|
return lineRepo.save(item);
|
|
1921
2371
|
});
|
|
@@ -2524,7 +2974,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
2524
2974
|
const linesByPurchase = groupBy(lines, line => line.purchaseOrderId);
|
|
2525
2975
|
const riskRows = lines.length ? await this.dataSource.getRepository(ProductComplianceMatch).find({ where: { ...activeScope(scope), purchaseOrderLineId: In(lines.map(line => line.id)), deletedAt: IsNull() } }) : [];
|
|
2526
2976
|
const controlledGoodsIds = [...new Set(riskRows.map(match => match.controlledGoodsId))];
|
|
2527
|
-
const controlledGoods = controlledGoodsIds.length ? await this.dataSource.getRepository(ControlledGoodsRecord).find({ where: { ...activeScope(scope), id: In(controlledGoodsIds), deletedAt: IsNull() } }) : [];
|
|
2977
|
+
const controlledGoods = controlledGoodsIds.length ? await this.dataSource.getRepository(ControlledGoodsRecord).find({ where: { ...activeScope(scope), id: In(controlledGoodsIds), lifecycleStatus: 'ACTIVE', deletedAt: IsNull() } }) : [];
|
|
2528
2978
|
const controlledGoodsById = new Map(controlledGoods.map(item => [item.id, item]));
|
|
2529
2979
|
const risksByLine = groupBy(riskRows, match => match.purchaseOrderLineId);
|
|
2530
2980
|
const purchasesByBaseCode = groupBy(purchases, purchase => parsePurchaseContractNo(purchase.contractNo).baseCode);
|
|
@@ -2715,7 +3165,8 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
2715
3165
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '供应商存在有效采购订单,不能删除', { orderCount: count });
|
|
2716
3166
|
}
|
|
2717
3167
|
const repo = manager.getRepository(entity);
|
|
2718
|
-
const
|
|
3168
|
+
const lifecycle = entity === ControlledGoodsRecord || entity === SanctionedCompanyRecord ? { lifecycleStatus: 'ACTIVE' } : {};
|
|
3169
|
+
const target = await required(repo.findOne({ where: { ...activeScope(scope), id, ...lifecycle, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } }), '记录不存在');
|
|
2719
3170
|
const now = new Date();
|
|
2720
3171
|
target.deletedAt = now;
|
|
2721
3172
|
target.deletedById = scope.userId ?? null;
|
|
@@ -3091,7 +3542,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
3091
3542
|
});
|
|
3092
3543
|
await matchRepo.update({ ...activeScope(scope), deletedAt: IsNull() }, { deletedAt: now, invalidationReason: reason });
|
|
3093
3544
|
const goods = await manager.getRepository(ControlledGoodsRecord).find({
|
|
3094
|
-
where: { ...activeScope(scope), deletedAt: IsNull() },
|
|
3545
|
+
where: { ...activeScope(scope), lifecycleStatus: 'ACTIVE', deletedAt: IsNull() },
|
|
3095
3546
|
order: { id: 'ASC' },
|
|
3096
3547
|
lock: { mode: 'pessimistic_read' }
|
|
3097
3548
|
});
|
|
@@ -3115,17 +3566,28 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
3115
3566
|
return { scanned: lines.length, matches };
|
|
3116
3567
|
});
|
|
3117
3568
|
}
|
|
3118
|
-
async recalculateCompanyRisks(scope, reason) {
|
|
3569
|
+
async recalculateCompanyRisks(scope, reason, sanctionBatchId) {
|
|
3119
3570
|
return this.dataSource.transaction(async (manager) => {
|
|
3120
3571
|
const repo = manager.getRepository(CompanySanctionMatch);
|
|
3121
3572
|
const supplierRepo = manager.getRepository(Supplier);
|
|
3122
3573
|
const customerRepo = manager.getRepository(Customer);
|
|
3123
3574
|
const now = new Date();
|
|
3575
|
+
if (this.dataSource.options?.type === 'postgres') {
|
|
3576
|
+
await manager.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [`trade-compliance:company-risk:${scope.tenantId}:${scope.organizationId}`, 'recalculate']);
|
|
3577
|
+
}
|
|
3578
|
+
const sanctionBatch = sanctionBatchId
|
|
3579
|
+
? await required(manager.getRepository(SanctionCatalogBatch).findOne({
|
|
3580
|
+
where: { ...activeScope(scope), id: sanctionBatchId }, lock: { mode: 'pessimistic_write' }
|
|
3581
|
+
}), '制裁目录批次不存在')
|
|
3582
|
+
: null;
|
|
3583
|
+
if (sanctionBatch?.riskRecalculatedAt) {
|
|
3584
|
+
return { scanned: 0, matches: 0, riskRecalculatedAt: sanctionBatch.riskRecalculatedAt };
|
|
3585
|
+
}
|
|
3124
3586
|
await repo.update({ ...activeScope(scope), deletedAt: IsNull() }, { deletedAt: now, invalidationReason: reason });
|
|
3125
3587
|
const [suppliers, customers, sanctions, aliases] = await Promise.all([
|
|
3126
3588
|
supplierRepo.find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
|
|
3127
3589
|
customerRepo.find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
|
|
3128
|
-
manager.getRepository(SanctionedCompanyRecord).find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
|
|
3590
|
+
manager.getRepository(SanctionedCompanyRecord).find({ where: { ...activeScope(scope), lifecycleStatus: 'ACTIVE', deletedAt: IsNull() } }),
|
|
3129
3591
|
manager.getRepository(SanctionedCompanyAlias).find({ where: { ...activeScope(scope), deletedAt: IsNull() } })
|
|
3130
3592
|
]);
|
|
3131
3593
|
const sanctionsById = new Map(sanctions.map(item => [item.id, item]));
|
|
@@ -3147,7 +3609,14 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
3147
3609
|
await repo.save(repo.create({ ...createdScope(scope), subjectType: 'CUSTOMER', supplierId: null, customerId: customer.id, sanctionedCompanyId: item.id, matchedNormalizedName: customer.normalizedName, checkedAt: now, deletedAt: null, invalidationReason: null }));
|
|
3148
3610
|
await customerRepo.update({ ...activeScope(scope), id: customer.id, deletedAt: IsNull() }, { complianceCheckStatus: 'CHECKED', complianceCheckedAt: now, updatedById: scope.userId ?? null, updatedAt: now });
|
|
3149
3611
|
}
|
|
3150
|
-
|
|
3612
|
+
const matches = await repo.count({ where: { ...activeScope(scope), deletedAt: IsNull() } });
|
|
3613
|
+
if (sanctionBatch) {
|
|
3614
|
+
sanctionBatch.riskRecalculatedAt = now;
|
|
3615
|
+
sanctionBatch.updatedById = scope.userId ?? null;
|
|
3616
|
+
await manager.getRepository(SanctionCatalogBatch).save(sanctionBatch);
|
|
3617
|
+
return { scanned: suppliers.length + customers.length, matches, riskRecalculatedAt: now };
|
|
3618
|
+
}
|
|
3619
|
+
return { scanned: suppliers.length + customers.length, matches };
|
|
3151
3620
|
});
|
|
3152
3621
|
}
|
|
3153
3622
|
async pagedQuery(entity, scope, query, searchFields, exactFilters = {}, filters = {}) {
|
|
@@ -3181,8 +3650,12 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
3181
3650
|
const pageSize = Math.min(200, Math.max(1, Number(query.pageSize ?? 20)));
|
|
3182
3651
|
const qb = this.dataSource.getRepository(entity).createQueryBuilder('row')
|
|
3183
3652
|
.leftJoin(batchEntity, 'batch', 'batch.id = row.batchId AND batch.tenantId = :tenantId AND batch.organizationId = :organizationId', activeScope(scope))
|
|
3184
|
-
.where('row.tenantId = :tenantId AND row.organizationId = :organizationId', activeScope(scope))
|
|
3185
|
-
|
|
3653
|
+
.where('row.tenantId = :tenantId AND row.organizationId = :organizationId', activeScope(scope));
|
|
3654
|
+
const catalogBatchId = String(query.parameters?.batchId ?? '').trim();
|
|
3655
|
+
if (catalogBatchId)
|
|
3656
|
+
qb.andWhere('row.batchId = :catalogBatchId', { catalogBatchId });
|
|
3657
|
+
else
|
|
3658
|
+
qb.andWhere('row.deletedAt IS NULL').andWhere("row.lifecycleStatus = 'ACTIVE'");
|
|
3186
3659
|
for (const [key, value] of Object.entries(filters.exact ?? {}))
|
|
3187
3660
|
if (hasQueryValue(value))
|
|
3188
3661
|
qb.andWhere(`row.${key} = :exact_${key}`, { [`exact_${key}`]: value });
|
|
@@ -3204,6 +3677,21 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
3204
3677
|
const fileNames = new Map(batches.map(batch => [batch.id, batch.sourceFileName]));
|
|
3205
3678
|
return { items: items.map(item => ({ ...item, sourceFileName: item.batchId ? fileNames.get(item.batchId) : undefined })), total, page, pageSize };
|
|
3206
3679
|
}
|
|
3680
|
+
async pagedBatchHistoryQuery(entity, scope, query, exactFilters = {}) {
|
|
3681
|
+
const page = Math.max(1, Number(query.page ?? 1));
|
|
3682
|
+
const pageSize = Math.min(200, Math.max(1, Number(query.pageSize ?? 20)));
|
|
3683
|
+
const qb = this.dataSource.getRepository(entity).createQueryBuilder('row')
|
|
3684
|
+
.where('row.tenantId = :tenantId AND row.organizationId = :organizationId', activeScope(scope));
|
|
3685
|
+
const search = String(query.search ?? '').trim();
|
|
3686
|
+
if (search)
|
|
3687
|
+
qb.andWhere('CAST(row.sourceFileName AS TEXT) ILIKE :search', { search: `%${search}%` });
|
|
3688
|
+
for (const [key, value] of Object.entries(exactFilters)) {
|
|
3689
|
+
if (value !== undefined && value !== null && value !== '')
|
|
3690
|
+
qb.andWhere(`row.${key} = :filter_${key}`, { [`filter_${key}`]: value });
|
|
3691
|
+
}
|
|
3692
|
+
const [items, total] = await qb.orderBy('row.updatedAt', 'DESC').addOrderBy('row.id', 'DESC').skip((page - 1) * pageSize).take(pageSize).getManyAndCount();
|
|
3693
|
+
return { items, total, page, pageSize };
|
|
3694
|
+
}
|
|
3207
3695
|
async upsertSupplier(manager, scope, input) {
|
|
3208
3696
|
const repo = manager.getRepository(Supplier);
|
|
3209
3697
|
const normalizedName = normalizeExactName(input.name);
|
|
@@ -3437,6 +3925,14 @@ function handoffCallbackIdentityMatches(input, expectedSourceMessageId, persiste
|
|
|
3437
3925
|
async function lockOrganizationTaskClaim(manager, scope) {
|
|
3438
3926
|
await manager.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [scope.tenantId, scope.organizationId]);
|
|
3439
3927
|
}
|
|
3928
|
+
async function lockCatalogMutation(manager, scope, enabled) {
|
|
3929
|
+
if (!enabled)
|
|
3930
|
+
return;
|
|
3931
|
+
await manager.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [
|
|
3932
|
+
`trade-compliance:catalog:${scope.tenantId}:${scope.organizationId}`,
|
|
3933
|
+
'mutate'
|
|
3934
|
+
]);
|
|
3935
|
+
}
|
|
3440
3936
|
async function organizationHasActiveParsing(manager, scope) {
|
|
3441
3937
|
const activeImport = await manager.getRepository(ImportTask).findOne({
|
|
3442
3938
|
where: { ...activeScope(scope), status: In([...RUNNING_IMPORT_TASK_STATUSES]), deletedAt: IsNull() },
|
|
@@ -3654,10 +4150,6 @@ function assertOptionalEnum(value, allowed, label) { if (hasQueryValue(value) &&
|
|
|
3654
4150
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, `${label}无效`); }
|
|
3655
4151
|
function fuzzyValue(value, filter) { return !hasQueryValue(filter) || String(value ?? '').toLocaleLowerCase().includes(String(filter).trim().toLocaleLowerCase()); }
|
|
3656
4152
|
function pageRows(rows, query) { const pageSize = Math.min(200, Math.max(1, Number(query.pageSize ?? 20))); const pageCount = Math.max(1, Math.ceil(rows.length / pageSize)); const page = Math.min(pageCount, Math.max(1, Number(query.page ?? 1))); return { items: rows.slice((page - 1) * pageSize, page * pageSize), total: rows.length, page, pageSize }; }
|
|
3657
|
-
function filterSalesOrders(rows, parameters, includeParties = false) {
|
|
3658
|
-
const date = parameters.orderDate && typeof parameters.orderDate === 'object' && !Array.isArray(parameters.orderDate) ? parameters.orderDate : {};
|
|
3659
|
-
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)));
|
|
3660
|
-
}
|
|
3661
4153
|
function salesMetrics(rows) {
|
|
3662
4154
|
const currentYear = String(new Date().getFullYear());
|
|
3663
4155
|
const currentYearRows = rows.filter(row => String(row.orderDate ?? '').slice(0, 4) === currentYear);
|
|
@@ -3763,7 +4255,7 @@ function assertPurchaseDraft(draft) {
|
|
|
3763
4255
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, `${prefix}名称为必填项`);
|
|
3764
4256
|
const quantity = optionalDecimalInput(line.quantity, `${prefix}数量必须为有效数值`);
|
|
3765
4257
|
const unitPrice = optionalDecimalInput(line.unitPriceTaxIncluded, `${prefix}含税单价必须为有效金额`);
|
|
3766
|
-
const lineAmount = optionalDecimalInput(line
|
|
4258
|
+
const lineAmount = optionalDecimalInput(purchaseLineAmountText(line), `${prefix}含税金额必须为有效金额`);
|
|
3767
4259
|
validateOptionalTaxRate(line.vatRate, 'vatRate', '增值税率', { lineNumber: index + 1, productName: line.productName });
|
|
3768
4260
|
const refundRate = validateOptionalTaxRate(line.refundRate, 'refundRate', '退税率', { lineNumber: index + 1, productName: line.productName });
|
|
3769
4261
|
if (hasQueryValue(line.refundPolicyDate) && hasQueryValue(draft.order.orderDate) && String(line.refundPolicyDate) > String(draft.order.orderDate))
|
|
@@ -3806,6 +4298,21 @@ function optionalDecimalInput(value, message) {
|
|
|
3806
4298
|
function defaultDecimalText(value) {
|
|
3807
4299
|
return optionalDecimalInput(value, '数值必须是有限金额')?.toString() ?? '0';
|
|
3808
4300
|
}
|
|
4301
|
+
function purchaseLineAmountText(line) {
|
|
4302
|
+
if (hasQueryValue(line.lineAmountTaxIncluded))
|
|
4303
|
+
return defaultDecimalText(line.lineAmountTaxIncluded);
|
|
4304
|
+
const quantity = optionalDecimalInput(line.quantity, '采购商品数量必须为有效数值');
|
|
4305
|
+
const unitPrice = optionalDecimalInput(line.unitPriceTaxIncluded, '采购商品含税单价必须为有效金额');
|
|
4306
|
+
return quantity && unitPrice ? quantity.mul(unitPrice).toString() : '0';
|
|
4307
|
+
}
|
|
4308
|
+
function purchaseLineMetricAmount(line) {
|
|
4309
|
+
const lineAmount = optionalDecimalInput(line.lineAmountTaxIncluded, '采购商品含税金额必须为有效金额') ?? new Decimal(0);
|
|
4310
|
+
if (lineAmount.gt(0))
|
|
4311
|
+
return lineAmount;
|
|
4312
|
+
const quantity = optionalDecimalInput(line.quantity, '采购商品数量必须为有效数值');
|
|
4313
|
+
const unitPrice = optionalDecimalInput(line.unitPriceTaxIncluded, '采购商品含税单价必须为有效金额');
|
|
4314
|
+
return quantity && unitPrice ? quantity.mul(unitPrice) : lineAmount;
|
|
4315
|
+
}
|
|
3809
4316
|
function validateOptionalTaxRate(value, field, label, context) {
|
|
3810
4317
|
if (!hasQueryValue(value))
|
|
3811
4318
|
return null;
|
|
@@ -3864,6 +4371,11 @@ function candidateToControlledRows(candidate) {
|
|
|
3864
4371
|
const hsCodes = Array.isArray(candidate.values.hsCodes) && candidate.values.hsCodes.length
|
|
3865
4372
|
? candidate.values.hsCodes
|
|
3866
4373
|
: [undefined];
|
|
4374
|
+
const translationSource = candidate.values.translationSource === 'AGENT_TRANSLATED' || candidate.values.translationSource === 'SOURCE_CHINESE'
|
|
4375
|
+
? candidate.values.translationSource
|
|
4376
|
+
: candidate.values.translationRequired || (!candidate.values.productName && candidate.values.originalProductName)
|
|
4377
|
+
? 'AGENT_TRANSLATED'
|
|
4378
|
+
: 'SOURCE_CHINESE';
|
|
3867
4379
|
return hsCodes.map(hsCode => ({
|
|
3868
4380
|
productName: optionalString(candidate.values.productName) ?? undefined,
|
|
3869
4381
|
originalProductName: optionalString(candidate.values.originalProductName) ?? undefined,
|
|
@@ -3874,9 +4386,7 @@ function candidateToControlledRows(candidate) {
|
|
|
3874
4386
|
sourceOrdinal: optionalString(candidate.values.sourceOrdinal) ?? String(candidate.ordinal),
|
|
3875
4387
|
sourceLocation: evidence?.sourceLocation,
|
|
3876
4388
|
sourceContent: evidence?.sourceContent,
|
|
3877
|
-
translationSource
|
|
3878
|
-
? 'AGENT_TRANSLATED'
|
|
3879
|
-
: 'SOURCE_CHINESE'
|
|
4389
|
+
translationSource
|
|
3880
4390
|
}));
|
|
3881
4391
|
}
|
|
3882
4392
|
function candidateAliases(candidate) {
|
|
@@ -3905,6 +4415,133 @@ function reviewCandidate(documentType, block, payload) {
|
|
|
3905
4415
|
warnings: block.reasons
|
|
3906
4416
|
};
|
|
3907
4417
|
}
|
|
4418
|
+
function mergeReviewPayload(base, patch) {
|
|
4419
|
+
const merged = { ...base };
|
|
4420
|
+
for (const [key, value] of Object.entries(patch ?? {})) {
|
|
4421
|
+
if (value === null)
|
|
4422
|
+
delete merged[key];
|
|
4423
|
+
else
|
|
4424
|
+
merged[key] = value;
|
|
4425
|
+
}
|
|
4426
|
+
return merged;
|
|
4427
|
+
}
|
|
4428
|
+
function restorePendingReviewBlock(block) {
|
|
4429
|
+
block.status = 'PENDING';
|
|
4430
|
+
block.leaseToken = null;
|
|
4431
|
+
block.leaseExpiresAt = null;
|
|
4432
|
+
block.resolvedAt = null;
|
|
4433
|
+
block.resolutionFingerprint = null;
|
|
4434
|
+
}
|
|
4435
|
+
function catalogReviewRetryMatches(blocks, items) {
|
|
4436
|
+
if (blocks.length !== items.length)
|
|
4437
|
+
return false;
|
|
4438
|
+
return items.every(item => {
|
|
4439
|
+
const block = blocks.find(value => value.id === item.blockId);
|
|
4440
|
+
if (!block || block.status !== (item.decision === 'ACCEPT' ? 'RESOLVED' : 'REJECTED'))
|
|
4441
|
+
return false;
|
|
4442
|
+
if (!block.resolutionFingerprint)
|
|
4443
|
+
return false;
|
|
4444
|
+
try {
|
|
4445
|
+
return block.resolutionFingerprint === catalogReviewResolutionFingerprint(item);
|
|
4446
|
+
}
|
|
4447
|
+
catch {
|
|
4448
|
+
return false;
|
|
4449
|
+
}
|
|
4450
|
+
});
|
|
4451
|
+
}
|
|
4452
|
+
function catalogReviewResolutionFingerprint(item) {
|
|
4453
|
+
const resolution = item.decision === 'REJECT'
|
|
4454
|
+
? { decision: item.decision }
|
|
4455
|
+
: Object.prototype.hasOwnProperty.call(item, 'payload')
|
|
4456
|
+
? { decision: item.decision, payload: item.payload, payloadPresent: true }
|
|
4457
|
+
: { decision: item.decision, payloadPresent: false };
|
|
4458
|
+
return createHash('sha256').update(canonicalJson(resolution)).digest('hex');
|
|
4459
|
+
}
|
|
4460
|
+
function canonicalJson(value) {
|
|
4461
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
4462
|
+
return JSON.stringify(value);
|
|
4463
|
+
if (typeof value === 'number') {
|
|
4464
|
+
if (!Number.isFinite(value))
|
|
4465
|
+
throw new TypeError('Resolution payload is not JSON serializable');
|
|
4466
|
+
return JSON.stringify(value);
|
|
4467
|
+
}
|
|
4468
|
+
if (Array.isArray(value))
|
|
4469
|
+
return `[${value.map(item => item === undefined ? 'null' : canonicalJson(item)).join(',')}]`;
|
|
4470
|
+
if (value && typeof value === 'object') {
|
|
4471
|
+
const entries = Object.entries(value)
|
|
4472
|
+
.filter(([, item]) => item !== undefined)
|
|
4473
|
+
.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
|
|
4474
|
+
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}`;
|
|
4475
|
+
}
|
|
4476
|
+
throw new TypeError('Resolution payload is not JSON serializable');
|
|
4477
|
+
}
|
|
4478
|
+
function evaluateCatalogReviewAccept(documentType, block, patch) {
|
|
4479
|
+
try {
|
|
4480
|
+
const payload = mergeReviewPayload(block.payload, patch);
|
|
4481
|
+
let candidate = reviewCandidate(documentType, block, payload);
|
|
4482
|
+
if (!validCatalogReviewCandidateValues(candidate))
|
|
4483
|
+
return { candidate: null, reasons: ['REVIEW_PAYLOAD_INVALID'] };
|
|
4484
|
+
candidate = normalizeCatalogCandidate(candidate);
|
|
4485
|
+
const decision = classifyCandidate(candidate);
|
|
4486
|
+
if (decision.confidence === 'LOW')
|
|
4487
|
+
return { candidate: null, reasons: decision.reasons };
|
|
4488
|
+
if (candidate.kind === 'SANCTION' && typeof candidate.values.effectiveDate === 'string' && !isRealIsoDate(candidate.values.effectiveDate)) {
|
|
4489
|
+
return { candidate: null, reasons: ['EFFECTIVE_DATE_INVALID'] };
|
|
4490
|
+
}
|
|
4491
|
+
return { candidate, reasons: [] };
|
|
4492
|
+
}
|
|
4493
|
+
catch {
|
|
4494
|
+
return { candidate: null, reasons: ['REVIEW_PAYLOAD_INVALID'] };
|
|
4495
|
+
}
|
|
4496
|
+
}
|
|
4497
|
+
const CATALOG_REVIEW_VARCHAR_MAX = 255;
|
|
4498
|
+
const CATALOG_REVIEW_TEXT_MAX = 100_000;
|
|
4499
|
+
const CATALOG_REVIEW_ARRAY_MAX = 100;
|
|
4500
|
+
function validCatalogReviewCandidateValues(candidate) {
|
|
4501
|
+
const values = candidate.values;
|
|
4502
|
+
const varcharFields = candidate.kind === 'CONTROLLED'
|
|
4503
|
+
? ['productName', 'hsCode', 'controlCode', 'sourceSection', 'sourceOrdinal']
|
|
4504
|
+
: ['companyName', 'countryOrRegion', 'sanctionListSource', 'effectiveDate'];
|
|
4505
|
+
const textFields = candidate.kind === 'CONTROLLED'
|
|
4506
|
+
? ['originalProductName', 'controlDescription', 'originalControlDescription']
|
|
4507
|
+
: ['sanctionReason', 'remarks'];
|
|
4508
|
+
if (!validOptionalStrings(values, varcharFields, CATALOG_REVIEW_VARCHAR_MAX))
|
|
4509
|
+
return false;
|
|
4510
|
+
if (!validOptionalStrings(values, textFields, CATALOG_REVIEW_TEXT_MAX))
|
|
4511
|
+
return false;
|
|
4512
|
+
if (values.translationRequired !== undefined && typeof values.translationRequired !== 'boolean')
|
|
4513
|
+
return false;
|
|
4514
|
+
if (values.translationSource !== undefined && values.translationSource !== 'SOURCE_CHINESE' && values.translationSource !== 'AGENT_TRANSLATED')
|
|
4515
|
+
return false;
|
|
4516
|
+
if (values.hsCodes !== undefined && (!Array.isArray(values.hsCodes) || values.hsCodes.length > CATALOG_REVIEW_ARRAY_MAX ||
|
|
4517
|
+
values.hsCodes.some(value => typeof value !== 'string' || value.length > CATALOG_REVIEW_VARCHAR_MAX)))
|
|
4518
|
+
return false;
|
|
4519
|
+
if (values.aliases !== undefined && (!Array.isArray(values.aliases) || values.aliases.length > CATALOG_REVIEW_ARRAY_MAX ||
|
|
4520
|
+
values.aliases.some(value => !validCatalogReviewAlias(value))))
|
|
4521
|
+
return false;
|
|
4522
|
+
return true;
|
|
4523
|
+
}
|
|
4524
|
+
function validOptionalStrings(values, fields, maxLength) {
|
|
4525
|
+
return fields.every(field => values[field] === undefined || (typeof values[field] === 'string' && values[field].length <= maxLength));
|
|
4526
|
+
}
|
|
4527
|
+
function validCatalogReviewAlias(value) {
|
|
4528
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
4529
|
+
return false;
|
|
4530
|
+
const alias = value.alias;
|
|
4531
|
+
const aliasType = value.aliasType;
|
|
4532
|
+
return typeof alias === 'string' && alias.length > 0 && alias.length <= CATALOG_REVIEW_VARCHAR_MAX &&
|
|
4533
|
+
(aliasType === 'AKA' || aliasType === 'LOCAL_LANGUAGE');
|
|
4534
|
+
}
|
|
4535
|
+
function isRealIsoDate(value) {
|
|
4536
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
4537
|
+
if (!match)
|
|
4538
|
+
return false;
|
|
4539
|
+
const year = Number(match[1]);
|
|
4540
|
+
const month = Number(match[2]);
|
|
4541
|
+
const day = Number(match[3]);
|
|
4542
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
4543
|
+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
|
4544
|
+
}
|
|
3908
4545
|
function appendMapValue(map, key, value) {
|
|
3909
4546
|
const values = map.get(key);
|
|
3910
4547
|
if (values) {
|
|
@@ -3929,7 +4566,6 @@ function assertCompleteClauseAnalyses(analyses) {
|
|
|
3929
4566
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析结果必须完整包含七类关键条款');
|
|
3930
4567
|
}
|
|
3931
4568
|
function decimalSum(rows, read) { return rows.reduce((value, row) => value.plus(read(row)), new Decimal(0)).toNumber(); }
|
|
3932
|
-
function fuzzyMatches(value, filter) { return !filter || String(value ?? '').trim().toLocaleLowerCase().includes(filter.trim().toLocaleLowerCase()); }
|
|
3933
4569
|
function shanghaiCurrentYear(now = new Date()) {
|
|
3934
4570
|
return Number(new Intl.DateTimeFormat('en-US', { timeZone: 'Asia/Shanghai', year: 'numeric' }).format(now));
|
|
3935
4571
|
}
|