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