@chenchaolong/plugin-trade-compliance-workbench 1.0.6 → 1.0.7
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/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/adapters/hsbianma.provider.d.ts +6 -8
- package/dist/lib/adapters/hsbianma.provider.d.ts.map +1 -1
- package/dist/lib/adapters/hsbianma.provider.js +159 -53
- package/dist/lib/adapters/hsbianma.provider.js.map +1 -1
- package/dist/lib/adapters/translation.provider.d.ts +7 -1
- package/dist/lib/adapters/translation.provider.d.ts.map +1 -1
- package/dist/lib/adapters/translation.provider.js +15 -5
- package/dist/lib/adapters/translation.provider.js.map +1 -1
- package/dist/lib/domain/catalog-localization.d.ts +14 -0
- package/dist/lib/domain/catalog-localization.d.ts.map +1 -0
- package/dist/lib/domain/catalog-localization.js +19 -0
- package/dist/lib/domain/catalog-localization.js.map +1 -0
- package/dist/lib/domain/file-name.d.ts +8 -0
- package/dist/lib/domain/file-name.d.ts.map +1 -0
- package/dist/lib/domain/file-name.js +111 -0
- package/dist/lib/domain/file-name.js.map +1 -0
- package/dist/lib/domain/import-issues.d.ts +4 -0
- package/dist/lib/domain/import-issues.d.ts.map +1 -1
- package/dist/lib/domain/import-issues.js +14 -3
- package/dist/lib/domain/import-issues.js.map +1 -1
- package/dist/lib/entities/catalog.entity.d.ts +5 -1
- package/dist/lib/entities/catalog.entity.d.ts.map +1 -1
- package/dist/lib/entities/catalog.entity.js +16 -0
- package/dist/lib/entities/catalog.entity.js.map +1 -1
- package/dist/lib/entities/customer.entity.d.ts +3 -0
- package/dist/lib/entities/customer.entity.d.ts.map +1 -1
- package/dist/lib/entities/customer.entity.js +12 -0
- package/dist/lib/entities/customer.entity.js.map +1 -1
- package/dist/lib/entities/shared.entity.d.ts +9 -0
- package/dist/lib/entities/shared.entity.d.ts.map +1 -1
- package/dist/lib/entities/shared.entity.js +33 -0
- package/dist/lib/entities/shared.entity.js.map +1 -1
- package/dist/lib/import-jobs/agent-handoff.dispatcher.d.ts +15 -0
- package/dist/lib/import-jobs/agent-handoff.dispatcher.d.ts.map +1 -0
- package/dist/lib/import-jobs/agent-handoff.dispatcher.js +114 -0
- package/dist/lib/import-jobs/agent-handoff.dispatcher.js.map +1 -0
- package/dist/lib/import-jobs/contracts.d.ts +76 -0
- package/dist/lib/import-jobs/contracts.d.ts.map +1 -0
- package/dist/lib/import-jobs/contracts.js +31 -0
- package/dist/lib/import-jobs/contracts.js.map +1 -0
- package/dist/lib/import-jobs/import-command.builder.d.ts +30 -0
- package/dist/lib/import-jobs/import-command.builder.d.ts.map +1 -0
- package/dist/lib/import-jobs/import-command.builder.js +97 -0
- package/dist/lib/import-jobs/import-command.builder.js.map +1 -0
- package/dist/lib/import-jobs/import-job-callback.processor.d.ts +15 -0
- package/dist/lib/import-jobs/import-job-callback.processor.d.ts.map +1 -0
- package/dist/lib/import-jobs/import-job-callback.processor.js +107 -0
- package/dist/lib/import-jobs/import-job-callback.processor.js.map +1 -0
- package/dist/lib/import-jobs/import-job.coordinator.d.ts +17 -0
- package/dist/lib/import-jobs/import-job.coordinator.d.ts.map +1 -0
- package/dist/lib/import-jobs/import-job.coordinator.js +256 -0
- package/dist/lib/import-jobs/import-job.coordinator.js.map +1 -0
- package/dist/lib/import-jobs/import-job.processor.d.ts +24 -0
- package/dist/lib/import-jobs/import-job.processor.d.ts.map +1 -0
- package/dist/lib/import-jobs/import-job.processor.js +213 -0
- package/dist/lib/import-jobs/import-job.processor.js.map +1 -0
- package/dist/lib/import-jobs/import-timeout.processor.d.ts +12 -0
- package/dist/lib/import-jobs/import-timeout.processor.d.ts.map +1 -0
- package/dist/lib/import-jobs/import-timeout.processor.js +37 -0
- package/dist/lib/import-jobs/import-timeout.processor.js.map +1 -0
- package/dist/lib/import-jobs/organization-lease.d.ts +39 -0
- package/dist/lib/import-jobs/organization-lease.d.ts.map +1 -0
- package/dist/lib/import-jobs/organization-lease.js +136 -0
- package/dist/lib/import-jobs/organization-lease.js.map +1 -0
- package/dist/lib/plugin-context.token.d.ts +4 -0
- package/dist/lib/plugin-context.token.d.ts.map +1 -0
- package/dist/lib/plugin-context.token.js +2 -0
- package/dist/lib/plugin-context.token.js.map +1 -0
- package/dist/lib/public-dto.d.ts +6 -0
- package/dist/lib/public-dto.d.ts.map +1 -0
- package/dist/lib/public-dto.js +33 -0
- package/dist/lib/public-dto.js.map +1 -0
- package/dist/lib/remote-components/trade-compliance-workbench/app.css +1 -1
- package/dist/lib/remote-components/trade-compliance-workbench/app.js +5 -5
- package/dist/lib/remote-ui/action-execution.d.ts +26 -0
- package/dist/lib/remote-ui/action-execution.d.ts.map +1 -0
- package/dist/lib/remote-ui/action-execution.js +85 -0
- package/dist/lib/remote-ui/action-execution.js.map +1 -0
- package/dist/lib/remote-ui/analytics-contracts.d.ts +3 -2
- package/dist/lib/remote-ui/analytics-contracts.d.ts.map +1 -1
- package/dist/lib/remote-ui/analytics-contracts.js.map +1 -1
- package/dist/lib/remote-ui/app-source.d.ts.map +1 -1
- package/dist/lib/remote-ui/app-source.js +426 -169
- package/dist/lib/remote-ui/app-source.js.map +1 -1
- package/dist/lib/remote-ui/company-logo-lifecycle.d.ts +14 -0
- package/dist/lib/remote-ui/company-logo-lifecycle.d.ts.map +1 -0
- package/dist/lib/remote-ui/company-logo-lifecycle.js +52 -0
- package/dist/lib/remote-ui/company-logo-lifecycle.js.map +1 -0
- package/dist/lib/remote-ui/components/data-grid.d.ts +9 -4
- package/dist/lib/remote-ui/components/data-grid.d.ts.map +1 -1
- package/dist/lib/remote-ui/components/data-grid.js +41 -9
- package/dist/lib/remote-ui/components/data-grid.js.map +1 -1
- package/dist/lib/remote-ui/components/forms.d.ts +6 -1
- package/dist/lib/remote-ui/components/forms.d.ts.map +1 -1
- package/dist/lib/remote-ui/components/forms.js +6 -1
- package/dist/lib/remote-ui/components/forms.js.map +1 -1
- package/dist/lib/remote-ui/contract-import.d.ts +37 -32
- package/dist/lib/remote-ui/contract-import.d.ts.map +1 -1
- package/dist/lib/remote-ui/contract-import.js +51 -59
- package/dist/lib/remote-ui/contract-import.js.map +1 -1
- package/dist/lib/remote-ui/contracts.d.ts +20 -27
- package/dist/lib/remote-ui/contracts.d.ts.map +1 -1
- package/dist/lib/remote-ui/contracts.js +8 -0
- package/dist/lib/remote-ui/contracts.js.map +1 -1
- package/dist/lib/remote-ui/customer-contract-contracts.d.ts +5 -11
- package/dist/lib/remote-ui/customer-contract-contracts.d.ts.map +1 -1
- package/dist/lib/remote-ui/customer-contract-contracts.js.map +1 -1
- package/dist/lib/remote-ui/date-time.d.ts +3 -0
- package/dist/lib/remote-ui/date-time.d.ts.map +1 -0
- package/dist/lib/remote-ui/date-time.js +58 -0
- package/dist/lib/remote-ui/date-time.js.map +1 -0
- package/dist/lib/remote-ui/model.d.ts +26 -1
- package/dist/lib/remote-ui/model.d.ts.map +1 -1
- package/dist/lib/remote-ui/model.js +151 -15
- package/dist/lib/remote-ui/model.js.map +1 -1
- package/dist/lib/remote-ui/pages/analytics.d.ts +2 -0
- package/dist/lib/remote-ui/pages/analytics.d.ts.map +1 -1
- package/dist/lib/remote-ui/pages/analytics.js +11 -6
- package/dist/lib/remote-ui/pages/analytics.js.map +1 -1
- package/dist/lib/remote-ui/pages/catalogs.d.ts +35 -14
- package/dist/lib/remote-ui/pages/catalogs.d.ts.map +1 -1
- package/dist/lib/remote-ui/pages/catalogs.js +132 -85
- package/dist/lib/remote-ui/pages/catalogs.js.map +1 -1
- package/dist/lib/remote-ui/pages/contracts.d.ts +8 -5
- package/dist/lib/remote-ui/pages/contracts.d.ts.map +1 -1
- package/dist/lib/remote-ui/pages/contracts.js +23 -22
- package/dist/lib/remote-ui/pages/contracts.js.map +1 -1
- package/dist/lib/remote-ui/pages/files.d.ts +11 -3
- package/dist/lib/remote-ui/pages/files.d.ts.map +1 -1
- package/dist/lib/remote-ui/pages/files.js +50 -12
- package/dist/lib/remote-ui/pages/files.js.map +1 -1
- package/dist/lib/remote-ui/pages/procurement.d.ts +6 -4
- package/dist/lib/remote-ui/pages/procurement.d.ts.map +1 -1
- package/dist/lib/remote-ui/pages/procurement.js +44 -50
- package/dist/lib/remote-ui/pages/procurement.js.map +1 -1
- package/dist/lib/remote-ui/pages/sales.d.ts +5 -0
- package/dist/lib/remote-ui/pages/sales.d.ts.map +1 -1
- package/dist/lib/remote-ui/pages/sales.js +34 -15
- package/dist/lib/remote-ui/pages/sales.js.map +1 -1
- package/dist/lib/remote-ui/pages/settings.d.ts +15 -13
- package/dist/lib/remote-ui/pages/settings.d.ts.map +1 -1
- package/dist/lib/remote-ui/pages/settings.js +50 -68
- package/dist/lib/remote-ui/pages/settings.js.map +1 -1
- package/dist/lib/remote-ui/polling.d.ts +41 -0
- package/dist/lib/remote-ui/polling.d.ts.map +1 -0
- package/dist/lib/remote-ui/polling.js +148 -0
- package/dist/lib/remote-ui/polling.js.map +1 -0
- package/dist/lib/remote-ui/procurement-contracts.d.ts +4 -9
- package/dist/lib/remote-ui/procurement-contracts.d.ts.map +1 -1
- package/dist/lib/remote-ui/sales-contracts.d.ts +2 -2
- package/dist/lib/remote-ui/sales-contracts.d.ts.map +1 -1
- package/dist/lib/remote-ui/settings-contracts.d.ts +9 -1
- package/dist/lib/remote-ui/settings-contracts.d.ts.map +1 -1
- package/dist/lib/remote-ui/settings-contracts.js.map +1 -1
- package/dist/lib/remote-ui/toast-queue.d.ts +29 -0
- package/dist/lib/remote-ui/toast-queue.d.ts.map +1 -0
- package/dist/lib/remote-ui/toast-queue.js +71 -0
- package/dist/lib/remote-ui/toast-queue.js.map +1 -0
- package/dist/lib/sales-file.service.d.ts.map +1 -1
- package/dist/lib/sales-file.service.js +5 -5
- package/dist/lib/sales-file.service.js.map +1 -1
- package/dist/lib/types.d.ts +5 -0
- package/dist/lib/types.d.ts.map +1 -1
- package/dist/lib/types.js +3 -0
- package/dist/lib/types.js.map +1 -1
- package/dist/lib/workbench-action-contract.d.ts +4 -0
- package/dist/lib/workbench-action-contract.d.ts.map +1 -0
- package/dist/lib/workbench-action-contract.js +43 -0
- package/dist/lib/workbench-action-contract.js.map +1 -0
- package/dist/lib/workbench-view.provider.d.ts +57 -78
- package/dist/lib/workbench-view.provider.d.ts.map +1 -1
- package/dist/lib/workbench-view.provider.js +89 -91
- package/dist/lib/workbench-view.provider.js.map +1 -1
- package/dist/lib/workbench.middleware.d.ts +129 -72
- package/dist/lib/workbench.middleware.d.ts.map +1 -1
- package/dist/lib/workbench.middleware.js +16 -3
- package/dist/lib/workbench.middleware.js.map +1 -1
- package/dist/lib/workbench.plugin.d.ts +3 -1
- package/dist/lib/workbench.plugin.d.ts.map +1 -1
- package/dist/lib/workbench.plugin.js +13 -4
- package/dist/lib/workbench.plugin.js.map +1 -1
- package/dist/lib/workbench.service.d.ts +136 -40
- package/dist/lib/workbench.service.d.ts.map +1 -1
- package/dist/lib/workbench.service.js +985 -71
- package/dist/lib/workbench.service.js.map +1 -1
- package/package.json +6 -5
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { __decorate, __metadata, __param } from "tslib";
|
|
2
2
|
import { Injectable, Optional } from '@nestjs/common';
|
|
3
3
|
import { InjectDataSource } from '@nestjs/typeorm';
|
|
4
|
-
import { Brackets, DataSource, In, IsNull } from 'typeorm';
|
|
4
|
+
import { Brackets, DataSource, In, IsNull, Not } from 'typeorm';
|
|
5
5
|
import { Decimal } from 'decimal.js';
|
|
6
6
|
import ExcelJS from 'exceljs';
|
|
7
7
|
import { createWriteStream } from 'node:fs';
|
|
@@ -9,20 +9,34 @@ 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
13
|
import { BankAccountSettings, CompanySanctionMatch, CompanySettings, ControlledCatalogBatch, ControlledCatalogImportIssue, ControlledGoodsRecord, Customer, CustomerContract, CustomerContractAnalysisRun, CustomerContractClause, CustomerContractClauseAnalysis, DerivedSalesOrderManualData, GeneratedSalesFile, ImportTask, ManagedAsset, MonthlySafeExchangeRate, ProductComplianceMatch, ProfitRuleSettings, PurchaseOrder, PurchaseOrderLine, SanctionCatalogBatch, SanctionCatalogImportIssue, SanctionedCompanyRecord, SettingsHistory, Supplier } from './entities/index.js';
|
|
13
14
|
import { CatalogTypes, ClauseTypes, CustomerProfitRateStatuses, DomainError, DomainErrorCodes, RefundValidationMethods, SalesPaymentStatuses, SanctionHitStatuses } from './types.js';
|
|
14
15
|
import { deriveSalesLine, assertReceivedAmount } from './domain/finance.js';
|
|
15
16
|
import { parseCustomerContractNo, parsePurchaseContractNo } from './domain/contract-number.js';
|
|
16
17
|
import { isProductNameMatch, normalizeExactName, normalizeForProductMatch, normalizeHsCode, normalizeSanctionSource } from './domain/normalization.js';
|
|
17
18
|
import { importTaskTimedOut, nextSettingsRevision, normalizeOptionalDecimalInput } from './domain/workflow.js';
|
|
18
|
-
import { renderImportIssuesWorkbook } from './domain/import-issues.js';
|
|
19
|
+
import { importIssueDto, renderImportIssuesWorkbook } from './domain/import-issues.js';
|
|
20
|
+
import { catalogLocalizationInputIssues } from './domain/catalog-localization.js';
|
|
19
21
|
import { BUILTIN_COMPANY_LOGO_STORAGE_ID, DEFAULT_BANKS, DEFAULT_COMPANY, DEFAULT_PROFIT_RULE } from './domain/default-settings.js';
|
|
20
22
|
import { TradeComplianceFileStorage } from './adapters/file-storage.js';
|
|
23
|
+
import { TranslationProvider } from './adapters/translation.provider.js';
|
|
21
24
|
import { parseBankAccountType, parseExchangeRateInput, parseExpectedRevision, validateBankSettingsInput, validateProfitSettingsInput, validatePurchaseLineDiscriminators, validateTaxRates } from './runtime-validation.js';
|
|
25
|
+
import { publicCustomerContractReanalysisDto, publicImportTaskDto } from './public-dto.js';
|
|
22
26
|
const DEFAULT_IMPORT_ISSUE_EXPORT_MAX_ROWS = 50_000;
|
|
23
27
|
const HARD_IMPORT_ISSUE_EXPORT_MAX_ROWS = 100_000;
|
|
24
28
|
const CUSTOMER_REANALYSIS_TIMEOUT_MS = 20 * 60 * 1000;
|
|
25
29
|
const ANALYTICS_EXPORT_MAX_ROWS = 100_000;
|
|
30
|
+
const CONTROLLED_CATALOG_TRANSLATION_CONCURRENCY = 4;
|
|
31
|
+
const CONTROLLED_CATALOG_TRANSLATION_TIMEOUT_MS = 60_000;
|
|
32
|
+
const CONTROLLED_CATALOG_PROCESSING_CLAIM_LEASE_MS = 90_000;
|
|
33
|
+
const CONTROLLED_CATALOG_PROCESSING_HEARTBEAT_MS = 30_000;
|
|
34
|
+
const CONTROLLED_CATALOG_PROCESSING_CLAIM_INITIAL_POLL_MS = 250;
|
|
35
|
+
const CONTROLLED_CATALOG_PROCESSING_CLAIM_MAX_POLL_MS = 1_000;
|
|
36
|
+
const RUNNING_IMPORT_TASK_STATUSES = ['EXTRACTING', 'AI_PARSING', 'VALIDATING', 'PERSISTING'];
|
|
37
|
+
const RECOVERABLE_IMPORT_TASK_STATUSES = ['QUEUED', 'EXTRACTING', 'AI_PARSING', 'VALIDATING', 'PERSISTING'];
|
|
38
|
+
const RECOVERABLE_REANALYSIS_STATUSES = ['PENDING', 'RUNNING'];
|
|
39
|
+
const TERMINAL_IMPORT_TASK_STATUSES = ['AWAITING_REVIEW', 'SUCCEEDED', 'FAILED', 'TIMED_OUT'];
|
|
26
40
|
const CATALOG_RISK_RECOMMENDATIONS = {
|
|
27
41
|
DUAL_USE: '建议核验军民两用物项许可要求,并在出口前完成合规审批',
|
|
28
42
|
OVERSEAS_SANCTION: '建议核验海外制裁限制及交易对手风险,并在交易前完成合规审批'
|
|
@@ -44,10 +58,24 @@ export async function writeWorkbookFile(filePath, populate, openOutput = createW
|
|
|
44
58
|
throw error;
|
|
45
59
|
}
|
|
46
60
|
}
|
|
61
|
+
function controlledGoodsDto(item) {
|
|
62
|
+
const dto = { ...item };
|
|
63
|
+
delete dto.originalProductName;
|
|
64
|
+
delete dto.originalControlDescription;
|
|
65
|
+
delete dto.translationSource;
|
|
66
|
+
delete dto.sourceContent;
|
|
67
|
+
return dto;
|
|
68
|
+
}
|
|
69
|
+
function sanctionedCompanyDto(item) {
|
|
70
|
+
const dto = { ...item };
|
|
71
|
+
delete dto.sourceContent;
|
|
72
|
+
return dto;
|
|
73
|
+
}
|
|
47
74
|
let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
48
|
-
constructor(dataSource, storage) {
|
|
75
|
+
constructor(dataSource, storage, translation) {
|
|
49
76
|
this.dataSource = dataSource;
|
|
50
77
|
this.storage = storage;
|
|
78
|
+
this.translation = translation;
|
|
51
79
|
}
|
|
52
80
|
async bootstrapSettings(scope) {
|
|
53
81
|
const profitRepo = this.dataSource.getRepository(ProfitRuleSettings);
|
|
@@ -93,15 +121,70 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
93
121
|
return { controlledRiskCount, sanctionRiskCount, unlinkedPurchaseCount, unlinkedCustomerCount };
|
|
94
122
|
}
|
|
95
123
|
async listControlledGoods(scope, query = {}) {
|
|
96
|
-
|
|
124
|
+
const page = await this.pagedCatalogQuery(ControlledGoodsRecord, ControlledCatalogBatch, scope, query, {
|
|
97
125
|
exact: { catalogType: query.parameters?.catalogType },
|
|
98
126
|
fuzzy: { productName: query.parameters?.productName, hsCode: query.parameters?.hsCode },
|
|
99
127
|
batchSourceFileName: query.parameters?.sourceFileName
|
|
100
128
|
});
|
|
129
|
+
return { ...page, items: page.items.map(controlledGoodsDto) };
|
|
101
130
|
}
|
|
102
131
|
async listImportTasks(scope, query = {}) {
|
|
103
132
|
await this.expireStaleImportTasks(scope);
|
|
104
|
-
|
|
133
|
+
const page = await this.pagedQuery(ImportTask, scope, query, ['sourceFileName', 'documentType', 'status', 'errorMessage'], { documentType: query.parameters?.documentType, status: query.parameters?.status });
|
|
134
|
+
return { ...page, items: page.items.map(publicImportTaskDto) };
|
|
135
|
+
}
|
|
136
|
+
async listActiveCustomerContractReanalyses(scope) {
|
|
137
|
+
const [contracts, tasks] = await Promise.all([
|
|
138
|
+
this.dataSource.getRepository(CustomerContract).find({ where: { ...activeScope(scope), currentReanalysisRunId: Not(IsNull()), deletedAt: IsNull() } }),
|
|
139
|
+
this.dataSource.getRepository(ImportTask).find({ where: { ...activeScope(scope), currentReanalysisRunId: Not(IsNull()) } })
|
|
140
|
+
]);
|
|
141
|
+
const unconsumedIds = new Set([...contracts, ...tasks].map(item => item.currentReanalysisRunId).filter((id) => Boolean(id)));
|
|
142
|
+
const where = [
|
|
143
|
+
{ ...activeScope(scope), status: In(['PENDING', 'RUNNING']) }
|
|
144
|
+
];
|
|
145
|
+
if (unconsumedIds.size)
|
|
146
|
+
where.push({ ...activeScope(scope), id: In([...unconsumedIds]), status: 'SUCCEEDED' });
|
|
147
|
+
const runs = await this.dataSource.getRepository(CustomerContractAnalysisRun).find({
|
|
148
|
+
where,
|
|
149
|
+
order: { createdAt: 'DESC', id: 'DESC' }
|
|
150
|
+
});
|
|
151
|
+
const recoveries = runs
|
|
152
|
+
.filter(run => run.status === 'PENDING' || run.status === 'RUNNING' || (run.status === 'SUCCEEDED' && unconsumedIds.has(run.id)))
|
|
153
|
+
.map(run => ({
|
|
154
|
+
recoveryType: run.status === 'SUCCEEDED' ? 'SUCCEEDED_RESULT' : 'ACTIVE_POLL',
|
|
155
|
+
run: publicCustomerContractReanalysisDto(run)
|
|
156
|
+
}));
|
|
157
|
+
return { items: recoveries, total: recoveries.length, page: 1, pageSize: 20 };
|
|
158
|
+
}
|
|
159
|
+
async consumeCustomerContractReanalysisResult(scope, id) {
|
|
160
|
+
return this.dataSource.transaction(async (manager) => {
|
|
161
|
+
const run = await required(manager.getRepository(CustomerContractAnalysisRun).findOne({
|
|
162
|
+
where: { ...activeScope(scope), id },
|
|
163
|
+
lock: { mode: 'pessimistic_write' }
|
|
164
|
+
}), '重新分析任务不存在');
|
|
165
|
+
if (run.status !== 'SUCCEEDED')
|
|
166
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '只有成功的重新分析结果可以确认消费');
|
|
167
|
+
let consumed = false;
|
|
168
|
+
if (run.customerContractId) {
|
|
169
|
+
const repo = manager.getRepository(CustomerContract);
|
|
170
|
+
const contract = await required(repo.findOne({ where: { ...activeScope(scope), id: run.customerContractId, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } }), '客户合同不存在');
|
|
171
|
+
if (contract.currentReanalysisRunId === run.id) {
|
|
172
|
+
contract.currentReanalysisRunId = null;
|
|
173
|
+
await repo.save(contract);
|
|
174
|
+
consumed = true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
const repo = manager.getRepository(ImportTask);
|
|
179
|
+
const task = await required(repo.findOne({ where: { ...activeScope(scope), id: run.importTaskId }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
|
|
180
|
+
if (task.currentReanalysisRunId === run.id) {
|
|
181
|
+
task.currentReanalysisRunId = null;
|
|
182
|
+
await repo.save(task);
|
|
183
|
+
consumed = true;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { run: publicCustomerContractReanalysisDto(run), consumed };
|
|
187
|
+
});
|
|
105
188
|
}
|
|
106
189
|
async listControlledBatches(scope, query = {}) { return this.pagedQuery(ControlledCatalogBatch, scope, query, ['sourceFileName'], { catalogType: query.parameters?.catalogType }); }
|
|
107
190
|
async listSanctionBatches(scope, query = {}) { return this.pagedQuery(SanctionCatalogBatch, scope, query, ['sourceFileName']); }
|
|
@@ -113,7 +196,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
113
196
|
const duplicate = await repo.findOne({ where: { ...activeScope(scope), catalogType: input.catalogType, normalizedProductName, normalizedHsCode, deletedAt: IsNull() } });
|
|
114
197
|
if (duplicate)
|
|
115
198
|
throw new DomainError(DomainErrorCodes.DUPLICATE_CATALOG_RECORD, '相同目录类型、商品名称和海关编码的记录已存在', { id: duplicate.id });
|
|
116
|
-
return repo.save(repo.create({ ...auditScope(scope), 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 }));
|
|
199
|
+
return controlledGoodsDto(await repo.save(repo.create({ ...auditScope(scope), 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 })));
|
|
117
200
|
}
|
|
118
201
|
async updateControlledGoods(scope, id, input) {
|
|
119
202
|
const repo = this.dataSource.getRepository(ControlledGoodsRecord);
|
|
@@ -124,9 +207,133 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
124
207
|
item.updatedById = scope.userId ?? null;
|
|
125
208
|
const saved = await repo.save(item);
|
|
126
209
|
await this.recalculateProductRisks(scope, 'CATALOG_CHANGED');
|
|
127
|
-
return saved;
|
|
210
|
+
return controlledGoodsDto(saved);
|
|
128
211
|
}
|
|
129
212
|
async importControlledCatalog(scope, input) {
|
|
213
|
+
while (true) {
|
|
214
|
+
const claim = await this.claimControlledCatalogProcessing(scope, input.importTaskId);
|
|
215
|
+
if (claim.kind === 'COMPLETED')
|
|
216
|
+
return this.completeControlledCatalogPostProcessing(scope, claim.batch);
|
|
217
|
+
if (claim.kind === 'BUSY') {
|
|
218
|
+
await this.waitForControlledCatalogProcessing(scope, input.importTaskId, claim.claimId, claim.expiresAt);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const heartbeat = this.startControlledCatalogProcessingHeartbeat(scope, input.importTaskId, claim.claimId);
|
|
222
|
+
try {
|
|
223
|
+
let result;
|
|
224
|
+
try {
|
|
225
|
+
const localizedRows = await this.localizeControlledCatalogRows(input.rows, claim.deadline, heartbeat.signal);
|
|
226
|
+
heartbeat.assertOwned();
|
|
227
|
+
result = await this.persistControlledCatalog(scope, input, localizedRows, claim.claimId);
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
await heartbeat.stop();
|
|
231
|
+
}
|
|
232
|
+
return this.completeControlledCatalogPostProcessing(scope, result);
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
try {
|
|
236
|
+
await this.releaseControlledCatalogProcessing(scope, input.importTaskId, claim.claimId);
|
|
237
|
+
}
|
|
238
|
+
catch { }
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async claimControlledCatalogProcessing(scope, importTaskId) {
|
|
244
|
+
return this.dataSource.transaction(async (manager) => {
|
|
245
|
+
const taskRepo = manager.getRepository(ImportTask);
|
|
246
|
+
const task = await required(taskRepo.findOne({ where: { ...activeScope(scope), id: importTaskId }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
|
|
247
|
+
if (task.status === 'SUCCEEDED' && task.resultEntityId) {
|
|
248
|
+
const batch = await required(manager.getRepository(ControlledCatalogBatch).findOne({ where: { ...activeScope(scope), id: task.resultEntityId } }), '已完成导入批次不存在');
|
|
249
|
+
return { kind: 'COMPLETED', batch };
|
|
250
|
+
}
|
|
251
|
+
assertImportTaskResultWritable(task);
|
|
252
|
+
const now = Date.now();
|
|
253
|
+
const claimExpiresAt = task.resultProcessingClaimExpiresAt;
|
|
254
|
+
if (task.resultProcessingClaimId && claimExpiresAt && claimExpiresAt.getTime() > now) {
|
|
255
|
+
return { kind: 'BUSY', claimId: task.resultProcessingClaimId, expiresAt: claimExpiresAt };
|
|
256
|
+
}
|
|
257
|
+
const claimId = randomUUID();
|
|
258
|
+
const deadline = now + CONTROLLED_CATALOG_TRANSLATION_TIMEOUT_MS;
|
|
259
|
+
task.resultProcessingClaimId = claimId;
|
|
260
|
+
task.resultProcessingClaimExpiresAt = new Date(now + CONTROLLED_CATALOG_PROCESSING_CLAIM_LEASE_MS);
|
|
261
|
+
await taskRepo.save(task);
|
|
262
|
+
return { kind: 'CLAIMED', claimId, deadline };
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
async waitForControlledCatalogProcessing(scope, importTaskId, claimId, expiresAt) {
|
|
266
|
+
let observedExpiry = expiresAt.getTime();
|
|
267
|
+
let pollMs = CONTROLLED_CATALOG_PROCESSING_CLAIM_INITIAL_POLL_MS;
|
|
268
|
+
while (true) {
|
|
269
|
+
const task = await required(this.dataSource.getRepository(ImportTask).findOne({ where: { ...activeScope(scope), id: importTaskId } }), '解析任务不存在');
|
|
270
|
+
if (task.status === 'SUCCEEDED')
|
|
271
|
+
return;
|
|
272
|
+
if (TERMINAL_IMPORT_TASK_STATUSES.includes(task.status))
|
|
273
|
+
throwImportTaskTerminalState(task);
|
|
274
|
+
if (task.resultProcessingClaimId !== claimId)
|
|
275
|
+
return;
|
|
276
|
+
observedExpiry = task.resultProcessingClaimExpiresAt?.getTime() ?? observedExpiry;
|
|
277
|
+
const remaining = observedExpiry - Date.now();
|
|
278
|
+
if (remaining <= 0)
|
|
279
|
+
return;
|
|
280
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(pollMs, remaining)));
|
|
281
|
+
pollMs = Math.min(pollMs * 2, CONTROLLED_CATALOG_PROCESSING_CLAIM_MAX_POLL_MS);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
startControlledCatalogProcessingHeartbeat(scope, importTaskId, claimId) {
|
|
285
|
+
const stopController = new AbortController();
|
|
286
|
+
const ownerController = new AbortController();
|
|
287
|
+
let lostError = null;
|
|
288
|
+
const completion = (async () => {
|
|
289
|
+
while (await cancellableDelay(CONTROLLED_CATALOG_PROCESSING_HEARTBEAT_MS, stopController.signal)) {
|
|
290
|
+
try {
|
|
291
|
+
const renewed = await this.renewControlledCatalogProcessing(scope, importTaskId, claimId);
|
|
292
|
+
if (renewed)
|
|
293
|
+
continue;
|
|
294
|
+
if (stopController.signal.aborted)
|
|
295
|
+
return;
|
|
296
|
+
lostError = new DomainError(DomainErrorCodes.INVALID_INPUT, '目录导入处理权已失效');
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
lostError = error;
|
|
300
|
+
}
|
|
301
|
+
ownerController.abort(lostError);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
})();
|
|
305
|
+
return {
|
|
306
|
+
signal: ownerController.signal,
|
|
307
|
+
assertOwned: () => { if (lostError)
|
|
308
|
+
throw lostError; },
|
|
309
|
+
stop: async () => { stopController.abort(); await completion; }
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
async renewControlledCatalogProcessing(scope, importTaskId, claimId) {
|
|
313
|
+
return this.dataSource.transaction(async (manager) => {
|
|
314
|
+
const taskRepo = manager.getRepository(ImportTask);
|
|
315
|
+
const task = await required(taskRepo.findOne({ where: { ...activeScope(scope), id: importTaskId }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
|
|
316
|
+
if (task.resultProcessingClaimId !== claimId)
|
|
317
|
+
return false;
|
|
318
|
+
if (TERMINAL_IMPORT_TASK_STATUSES.includes(task.status))
|
|
319
|
+
return false;
|
|
320
|
+
task.resultProcessingClaimExpiresAt = new Date(Date.now() + CONTROLLED_CATALOG_PROCESSING_CLAIM_LEASE_MS);
|
|
321
|
+
await taskRepo.save(task);
|
|
322
|
+
return true;
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
async releaseControlledCatalogProcessing(scope, importTaskId, claimId) {
|
|
326
|
+
await this.dataSource.transaction(async (manager) => {
|
|
327
|
+
const taskRepo = manager.getRepository(ImportTask);
|
|
328
|
+
const task = await required(taskRepo.findOne({ where: { ...activeScope(scope), id: importTaskId }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
|
|
329
|
+
if (task.resultProcessingClaimId !== claimId)
|
|
330
|
+
return;
|
|
331
|
+
task.resultProcessingClaimId = null;
|
|
332
|
+
task.resultProcessingClaimExpiresAt = null;
|
|
333
|
+
await taskRepo.save(task);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
async persistControlledCatalog(scope, input, localizedRows, claimId) {
|
|
130
337
|
return this.dataSource.transaction(async (manager) => {
|
|
131
338
|
const batchRepo = manager.getRepository(ControlledCatalogBatch);
|
|
132
339
|
const recordRepo = manager.getRepository(ControlledGoodsRecord);
|
|
@@ -135,6 +342,9 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
135
342
|
const task = await required(taskRepo.findOne({ where: { ...activeScope(scope), id: input.importTaskId }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
|
|
136
343
|
if (task.status === 'SUCCEEDED' && task.resultEntityId)
|
|
137
344
|
return required(batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId } }), '已完成导入批次不存在');
|
|
345
|
+
if (task.resultProcessingClaimId !== claimId)
|
|
346
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目录导入处理权已失效');
|
|
347
|
+
assertImportTaskResultWritable(task);
|
|
138
348
|
task.status = 'PERSISTING';
|
|
139
349
|
await taskRepo.save(task);
|
|
140
350
|
const now = new Date();
|
|
@@ -147,14 +357,15 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
147
357
|
await batchRepo.save(old);
|
|
148
358
|
await recordRepo.update({ ...activeScope(scope), batchId: old.id, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'CATALOG_REPLACE' });
|
|
149
359
|
}
|
|
150
|
-
const batch = await batchRepo.save(batchRepo.create({ ...auditScope(scope), catalogType: input.catalogType, sourceFileId:
|
|
360
|
+
const batch = await batchRepo.save(batchRepo.create({ ...auditScope(scope), catalogType: input.catalogType, sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName, importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null, totalCount: input.rows.length, successCount: 0, duplicateCount: 0, invalidCount: 0, importTaskId: input.importTaskId, riskRecalculatedAt: null }));
|
|
151
361
|
for (let index = 0; index < input.rows.length; index += 1) {
|
|
152
362
|
const row = input.rows[index];
|
|
153
|
-
const
|
|
363
|
+
const localized = localizedRows[index];
|
|
364
|
+
const productName = localized.productName;
|
|
154
365
|
const normalizedHsCode = String(row.hsCode ?? '').replace(/\s/g, '');
|
|
155
|
-
if (
|
|
366
|
+
if (localized.error) {
|
|
156
367
|
batch.invalidCount += 1;
|
|
157
|
-
await issueRepo.save(issueRepo.create({ ...createdScope(scope), batchId: batch.id, issueType: 'INVALID', sourceSequence: index + 1, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null, parsedPayload: row, message:
|
|
368
|
+
await issueRepo.save(issueRepo.create({ ...createdScope(scope), batchId: batch.id, issueType: 'INVALID', sourceSequence: index + 1, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null, parsedPayload: row, message: localized.error }));
|
|
158
369
|
continue;
|
|
159
370
|
}
|
|
160
371
|
const duplicate = await recordRepo.findOne({ where: { ...activeScope(scope), catalogType: input.catalogType, normalizedProductName: productName, normalizedHsCode, deletedAt: IsNull() } });
|
|
@@ -163,19 +374,105 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
163
374
|
await issueRepo.save(issueRepo.create({ ...createdScope(scope), batchId: batch.id, issueType: 'DUPLICATE', sourceSequence: index + 1, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null, parsedPayload: row, message: '目录中已存在相同商品名称和海关编码' }));
|
|
164
375
|
continue;
|
|
165
376
|
}
|
|
166
|
-
await recordRepo.save(recordRepo.create({ ...auditScope(scope), batchId: batch.id, sourceType: 'FILE_IMPORT', catalogType: input.catalogType, productName, normalizedProductName: productName, hsCode: row.hsCode ?? null, normalizedHsCode, controlDescription:
|
|
377
|
+
await recordRepo.save(recordRepo.create({ ...auditScope(scope), batchId: batch.id, sourceType: 'FILE_IMPORT', catalogType: input.catalogType, productName, normalizedProductName: productName, hsCode: row.hsCode ?? null, normalizedHsCode, controlDescription: localized.controlDescription, originalProductName: localized.originalProductName, originalControlDescription: localized.originalControlDescription, translationSource: localized.translationSource, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null }));
|
|
167
378
|
batch.successCount += 1;
|
|
168
379
|
}
|
|
169
380
|
await batchRepo.save(batch);
|
|
170
381
|
task.status = 'SUCCEEDED';
|
|
171
382
|
task.resultEntityId = batch.id;
|
|
172
383
|
task.completedAt = new Date();
|
|
384
|
+
task.resultProcessingClaimId = null;
|
|
385
|
+
task.resultProcessingClaimExpiresAt = null;
|
|
173
386
|
await taskRepo.save(task);
|
|
174
387
|
return batch;
|
|
175
|
-
})
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
async completeControlledCatalogPostProcessing(scope, batch) {
|
|
391
|
+
if (batch.riskRecalculatedAt)
|
|
392
|
+
return batch;
|
|
393
|
+
const result = await this.recalculateProductRisks(scope, 'CATALOG_CHANGED', batch.id);
|
|
394
|
+
batch.riskRecalculatedAt = result.riskRecalculatedAt ?? new Date();
|
|
395
|
+
return batch;
|
|
396
|
+
}
|
|
397
|
+
async localizeControlledCatalogRows(rows, deadline = Date.now() + CONTROLLED_CATALOG_TRANSLATION_TIMEOUT_MS, ownerSignal) {
|
|
398
|
+
const localized = new Array(rows.length);
|
|
399
|
+
const controller = new AbortController();
|
|
400
|
+
const abortFromOwner = () => controller.abort(ownerSignal?.reason);
|
|
401
|
+
ownerSignal?.addEventListener('abort', abortFromOwner, { once: true });
|
|
402
|
+
if (ownerSignal?.aborted)
|
|
403
|
+
abortFromOwner();
|
|
404
|
+
const remaining = Math.max(0, deadline - Date.now());
|
|
405
|
+
const timeout = setTimeout(() => controller.abort(), remaining);
|
|
406
|
+
let nextIndex = 0;
|
|
407
|
+
const worker = async () => {
|
|
408
|
+
while (nextIndex < rows.length) {
|
|
409
|
+
const index = nextIndex;
|
|
410
|
+
nextIndex += 1;
|
|
411
|
+
localized[index] = await this.localizeControlledCatalogRow(rows[index], deadline, controller.signal);
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
try {
|
|
415
|
+
await Promise.all(Array.from({ length: Math.min(CONTROLLED_CATALOG_TRANSLATION_CONCURRENCY, rows.length) }, worker));
|
|
416
|
+
return localized;
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
clearTimeout(timeout);
|
|
420
|
+
ownerSignal?.removeEventListener('abort', abortFromOwner);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
async localizeControlledCatalogRow(row, deadline, signal) {
|
|
424
|
+
const originalProductName = String(row.originalProductName ?? '').trim() || null;
|
|
425
|
+
const originalControlDescription = String(row.originalControlDescription ?? '').trim() || null;
|
|
426
|
+
let productName = String(row.productName ?? '').trim();
|
|
427
|
+
let controlDescription = String(row.controlDescription ?? '').trim() || null;
|
|
428
|
+
let translationSource = row.translationSource ?? null;
|
|
429
|
+
let serviceTranslated = false;
|
|
430
|
+
const inputIssue = catalogLocalizationInputIssues(row)[0];
|
|
431
|
+
if (inputIssue)
|
|
432
|
+
return { productName, controlDescription, originalProductName, originalControlDescription, translationSource, error: inputIssue.message };
|
|
433
|
+
if (!productName) {
|
|
434
|
+
if (!originalProductName)
|
|
435
|
+
return { productName: '', controlDescription, originalProductName, originalControlDescription, translationSource, error: '商品名称为空' };
|
|
436
|
+
productName = await this.translateCatalogValue(originalProductName, deadline, signal) ?? '';
|
|
437
|
+
if (!productName)
|
|
438
|
+
return { productName: '', controlDescription, originalProductName, originalControlDescription, translationSource, error: '商品名称翻译失败' };
|
|
439
|
+
serviceTranslated = true;
|
|
440
|
+
}
|
|
441
|
+
if (!controlDescription && originalControlDescription) {
|
|
442
|
+
controlDescription = await this.translateCatalogValue(originalControlDescription, deadline, signal);
|
|
443
|
+
if (!controlDescription)
|
|
444
|
+
return { productName, controlDescription: null, originalProductName, originalControlDescription, translationSource, error: '管控说明翻译失败' };
|
|
445
|
+
serviceTranslated = true;
|
|
446
|
+
}
|
|
447
|
+
if (!translationSource)
|
|
448
|
+
return { productName, controlDescription, originalProductName, originalControlDescription, translationSource, error: '翻译来源为空' };
|
|
449
|
+
if (serviceTranslated)
|
|
450
|
+
translationSource = 'SERVICE_TRANSLATED';
|
|
451
|
+
return { productName, controlDescription, originalProductName, originalControlDescription, translationSource };
|
|
452
|
+
}
|
|
453
|
+
async translateCatalogValue(value, deadline, signal) {
|
|
454
|
+
const remaining = deadline - Date.now();
|
|
455
|
+
if (!this.translation || remaining <= 0 || signal.aborted)
|
|
456
|
+
return null;
|
|
457
|
+
const translated = Promise.resolve(this.translation.toChinese(value, { signal, deadline }))
|
|
458
|
+
.then(result => String(result ?? '').trim() || null, () => null);
|
|
459
|
+
let onAbort;
|
|
460
|
+
try {
|
|
461
|
+
const aborted = new Promise(resolve => {
|
|
462
|
+
onAbort = () => resolve(null);
|
|
463
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
464
|
+
if (signal.aborted)
|
|
465
|
+
resolve(null);
|
|
466
|
+
});
|
|
467
|
+
return await Promise.race([translated, aborted]);
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
if (onAbort)
|
|
471
|
+
signal.removeEventListener('abort', onAbort);
|
|
472
|
+
}
|
|
176
473
|
}
|
|
177
474
|
async listSanctionedCompanies(scope, query = {}) {
|
|
178
|
-
|
|
475
|
+
const page = await this.pagedCatalogQuery(SanctionedCompanyRecord, SanctionCatalogBatch, scope, query, {
|
|
179
476
|
fuzzy: {
|
|
180
477
|
companyName: query.parameters?.companyName,
|
|
181
478
|
countryOrRegion: query.parameters?.countryOrRegion,
|
|
@@ -184,22 +481,23 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
184
481
|
range: { effectiveDate: query.parameters?.effectiveDate },
|
|
185
482
|
batchSourceFileName: query.parameters?.sourceFileName
|
|
186
483
|
});
|
|
484
|
+
return { ...page, items: page.items.map(sanctionedCompanyDto) };
|
|
187
485
|
}
|
|
188
486
|
async getImportTaskResult(scope, taskId) {
|
|
189
487
|
await this.expireStaleImportTasks(scope);
|
|
190
488
|
const task = await required(this.dataSource.getRepository(ImportTask).findOne({ where: { ...activeScope(scope), id: taskId } }), '解析任务不存在');
|
|
191
|
-
const
|
|
489
|
+
const publicTask = publicImportTaskDto(task);
|
|
192
490
|
if (!task.resultEntityId)
|
|
193
|
-
return { task
|
|
491
|
+
return { task: publicTask };
|
|
194
492
|
if (task.documentType === 'CONTROLLED_CATALOG') {
|
|
195
493
|
const batch = await this.dataSource.getRepository(ControlledCatalogBatch).findOne({ where: { ...activeScope(scope), id: task.resultEntityId } });
|
|
196
|
-
return { task, batch: batch ?? undefined
|
|
494
|
+
return { task: publicTask, batch: batch ?? undefined };
|
|
197
495
|
}
|
|
198
496
|
if (task.documentType === 'SANCTION_CATALOG') {
|
|
199
497
|
const batch = await this.dataSource.getRepository(SanctionCatalogBatch).findOne({ where: { ...activeScope(scope), id: task.resultEntityId } });
|
|
200
|
-
return { task, batch: batch ?? undefined
|
|
498
|
+
return { task: publicTask, batch: batch ?? undefined };
|
|
201
499
|
}
|
|
202
|
-
return { task
|
|
500
|
+
return { task: publicTask };
|
|
203
501
|
}
|
|
204
502
|
async listImportIssues(scope, query) {
|
|
205
503
|
const page = Math.max(1, Number(query.page ?? 1));
|
|
@@ -209,7 +507,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
209
507
|
where: { ...activeScope(scope), batchId: query.batchId, issueType: query.issueType },
|
|
210
508
|
order: { sourceSequence: 'ASC' }, skip: (page - 1) * pageSize, take: pageSize
|
|
211
509
|
});
|
|
212
|
-
return { items, total, page, pageSize };
|
|
510
|
+
return { items: items.map(importIssueDto), total, page, pageSize };
|
|
213
511
|
}
|
|
214
512
|
async downloadImportIssues(scope, documentType, batchId) {
|
|
215
513
|
const batchEntity = documentType === 'CONTROLLED_CATALOG' ? ControlledCatalogBatch : SanctionCatalogBatch;
|
|
@@ -233,7 +531,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
233
531
|
throw new DomainError(DomainErrorCodes.DUPLICATE_CATALOG_RECORD, '相同公司名称和制裁名单来源的记录已存在', { id: duplicate.id });
|
|
234
532
|
const saved = await repo.save(repo.create({ ...auditScope(scope), 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 }));
|
|
235
533
|
await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
|
|
236
|
-
return saved;
|
|
534
|
+
return sanctionedCompanyDto(saved);
|
|
237
535
|
}
|
|
238
536
|
async updateSanctionedCompany(scope, id, input) {
|
|
239
537
|
const repo = this.dataSource.getRepository(SanctionedCompanyRecord);
|
|
@@ -244,7 +542,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
244
542
|
item.updatedById = scope.userId ?? null;
|
|
245
543
|
const saved = await repo.save(item);
|
|
246
544
|
await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
|
|
247
|
-
return saved;
|
|
545
|
+
return sanctionedCompanyDto(saved);
|
|
248
546
|
}
|
|
249
547
|
async importSanctionCatalog(scope, input) {
|
|
250
548
|
const batch = await this.dataSource.transaction(async (manager) => {
|
|
@@ -255,6 +553,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
255
553
|
const task = await required(taskRepo.findOne({ where: { ...activeScope(scope), id: input.importTaskId }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
|
|
256
554
|
if (task.status === 'SUCCEEDED' && task.resultEntityId)
|
|
257
555
|
return required(batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId } }), '已完成导入批次不存在');
|
|
556
|
+
assertImportTaskResultWritable(task);
|
|
258
557
|
task.status = 'PERSISTING';
|
|
259
558
|
await taskRepo.save(task);
|
|
260
559
|
const now = new Date();
|
|
@@ -267,7 +566,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
267
566
|
await batchRepo.save(old);
|
|
268
567
|
await recordRepo.update({ ...activeScope(scope), batchId: old.id, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SANCTION_CATALOG_REPLACE' });
|
|
269
568
|
}
|
|
270
|
-
const result = await batchRepo.save(batchRepo.create({ ...auditScope(scope), sourceFileId:
|
|
569
|
+
const 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: input.rows.length, successCount: 0, duplicateCount: 0, invalidCount: 0, importTaskId: input.importTaskId }));
|
|
271
570
|
for (let index = 0; index < input.rows.length; index += 1) {
|
|
272
571
|
const row = input.rows[index];
|
|
273
572
|
const companyName = String(row.companyName ?? '').trim();
|
|
@@ -299,31 +598,360 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
299
598
|
}
|
|
300
599
|
async createImportTask(scope, input) {
|
|
301
600
|
const repo = this.dataSource.getRepository(ImportTask);
|
|
302
|
-
|
|
601
|
+
const targetAssistantId = requiredId(input.targetAssistantId, '目标智能体标识');
|
|
602
|
+
return repo.save(repo.create({ ...createdScope(scope), ...input, targetAssistantId, status: 'QUEUED', queuedAt: new Date(), createdById: scope.userId ?? null }));
|
|
303
603
|
}
|
|
304
|
-
async
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
604
|
+
async findNextQueuedImportTaskCandidate(scope) {
|
|
605
|
+
return this.dataSource.getRepository(ImportTask).findOne({
|
|
606
|
+
where: { ...activeScope(scope), status: 'QUEUED' },
|
|
607
|
+
order: { queuedAt: 'ASC', id: 'ASC' }
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
async claimQueuedImportTaskById(scope, id, claimAttemptToken) {
|
|
611
|
+
const attemptToken = requiredId(claimAttemptToken, '领取尝试标识');
|
|
612
|
+
return this.dataSource.transaction(async (manager) => {
|
|
613
|
+
await lockOrganizationTaskClaim(manager, scope);
|
|
614
|
+
if (await organizationHasActiveParsing(manager, scope))
|
|
615
|
+
return null;
|
|
616
|
+
const repo = manager.getRepository(ImportTask);
|
|
617
|
+
const task = await repo.findOne({
|
|
618
|
+
where: { ...activeScope(scope), id, status: 'QUEUED' },
|
|
619
|
+
lock: { mode: 'pessimistic_write', onLocked: 'skip_locked' }
|
|
620
|
+
});
|
|
621
|
+
if (!task)
|
|
622
|
+
return null;
|
|
623
|
+
if (!task.targetAssistantId) {
|
|
624
|
+
task.status = 'FAILED';
|
|
625
|
+
task.errorCode = 'TARGET_ASSISTANT_MISSING';
|
|
626
|
+
task.errorMessage = '历史解析任务缺少目标智能体,无法自动恢复';
|
|
627
|
+
task.completedAt = new Date();
|
|
628
|
+
await repo.save(task);
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
task.status = 'EXTRACTING';
|
|
632
|
+
task.claimAttemptToken = attemptToken;
|
|
633
|
+
task.startedAt ??= new Date();
|
|
634
|
+
return repo.save(task);
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
async claimNextQueuedImportTask(scope) {
|
|
638
|
+
return this.dataSource.transaction(async (manager) => {
|
|
639
|
+
await lockOrganizationTaskClaim(manager, scope);
|
|
640
|
+
const repo = manager.getRepository(ImportTask);
|
|
641
|
+
if (await organizationHasActiveParsing(manager, scope))
|
|
642
|
+
return null;
|
|
643
|
+
const task = await repo.findOne({
|
|
644
|
+
where: { ...activeScope(scope), status: 'QUEUED' },
|
|
645
|
+
order: { queuedAt: 'ASC', id: 'ASC' },
|
|
646
|
+
lock: { mode: 'pessimistic_write', onLocked: 'skip_locked' }
|
|
647
|
+
});
|
|
648
|
+
if (!task)
|
|
649
|
+
return null;
|
|
650
|
+
if (!task.targetAssistantId) {
|
|
651
|
+
task.status = 'FAILED';
|
|
652
|
+
task.errorCode = 'TARGET_ASSISTANT_MISSING';
|
|
653
|
+
task.errorMessage = '历史解析任务缺少目标智能体,无法自动恢复';
|
|
654
|
+
task.completedAt = new Date();
|
|
655
|
+
await repo.save(task);
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
task.status = 'EXTRACTING';
|
|
659
|
+
task.startedAt ??= new Date();
|
|
660
|
+
return repo.save(task);
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
async listOrganizationsWithRecoverableTasks() {
|
|
664
|
+
const workItems = await this.listRecoverableImportWorkItems();
|
|
665
|
+
const organizations = new Map();
|
|
666
|
+
for (const item of workItems) {
|
|
667
|
+
const organization = { tenantId: item.tenantId, organizationId: item.organizationId };
|
|
668
|
+
organizations.set(`${organization.tenantId}:${organization.organizationId}`, organization);
|
|
669
|
+
}
|
|
670
|
+
return [...organizations.values()];
|
|
671
|
+
}
|
|
672
|
+
async listRecoverableImportWorkItems(scope) {
|
|
673
|
+
const recoveryScope = scope ? activeScope(scope) : {};
|
|
674
|
+
const [tasks, runs] = await Promise.all([
|
|
675
|
+
this.dataSource.getRepository(ImportTask).find({
|
|
676
|
+
where: { ...recoveryScope, status: In([...RECOVERABLE_IMPORT_TASK_STATUSES]) },
|
|
677
|
+
select: {
|
|
678
|
+
id: true, tenantId: true, organizationId: true, targetAssistantId: true,
|
|
679
|
+
status: true, claimAttemptToken: true, commandDispatchedAt: true, startedAt: true
|
|
680
|
+
}
|
|
681
|
+
}),
|
|
682
|
+
this.dataSource.getRepository(CustomerContractAnalysisRun).find({
|
|
683
|
+
where: {
|
|
684
|
+
...recoveryScope,
|
|
685
|
+
analysisType: 'REANALYSIS', status: In([...RECOVERABLE_REANALYSIS_STATUSES])
|
|
686
|
+
},
|
|
687
|
+
select: {
|
|
688
|
+
id: true, importTaskId: true, customerContractId: true,
|
|
689
|
+
tenantId: true, organizationId: true, targetAssistantId: true,
|
|
690
|
+
status: true, claimAttemptToken: true, commandDispatchedAt: true, startedAt: true, expiresAt: true
|
|
691
|
+
}
|
|
692
|
+
})
|
|
693
|
+
]);
|
|
694
|
+
for (const task of tasks.filter(task => !task.targetAssistantId))
|
|
695
|
+
await this.failRecoverableImportWithoutTarget(task);
|
|
696
|
+
for (const run of runs.filter(run => !run.targetAssistantId))
|
|
697
|
+
await this.failRecoverableReanalysisWithoutTarget(run);
|
|
698
|
+
return [
|
|
699
|
+
...tasks.filter(task => task.targetAssistantId).map(task => ({
|
|
700
|
+
type: 'IMPORT_TASK',
|
|
701
|
+
importTaskId: task.id,
|
|
702
|
+
tenantId: task.tenantId,
|
|
703
|
+
organizationId: task.organizationId,
|
|
704
|
+
status: task.status,
|
|
705
|
+
claimAttemptToken: task.claimAttemptToken,
|
|
706
|
+
commandDispatchedAt: task.commandDispatchedAt,
|
|
707
|
+
startedAt: task.startedAt
|
|
708
|
+
})),
|
|
709
|
+
...runs.filter(run => run.targetAssistantId).map(run => ({
|
|
710
|
+
type: 'CUSTOMER_REANALYSIS',
|
|
711
|
+
importTaskId: run.importTaskId,
|
|
712
|
+
analysisRunId: run.id,
|
|
713
|
+
customerContractId: run.customerContractId,
|
|
714
|
+
tenantId: run.tenantId,
|
|
715
|
+
organizationId: run.organizationId,
|
|
716
|
+
status: run.status,
|
|
717
|
+
claimAttemptToken: run.claimAttemptToken,
|
|
718
|
+
commandDispatchedAt: run.commandDispatchedAt,
|
|
719
|
+
startedAt: run.startedAt,
|
|
720
|
+
expiresAt: run.expiresAt
|
|
721
|
+
}))
|
|
722
|
+
];
|
|
723
|
+
}
|
|
724
|
+
async failRecoverableImportWithoutTarget(snapshot) {
|
|
725
|
+
await this.dataSource.transaction(async (manager) => {
|
|
726
|
+
const repo = manager.getRepository(ImportTask);
|
|
727
|
+
const task = await repo.findOne({ where: { ...activeScope(snapshot), id: snapshot.id }, lock: { mode: 'pessimistic_write' } });
|
|
728
|
+
if (!task || task.targetAssistantId || !RECOVERABLE_IMPORT_TASK_STATUSES.includes(task.status))
|
|
729
|
+
return;
|
|
730
|
+
task.status = 'FAILED';
|
|
731
|
+
task.errorCode = 'TARGET_ASSISTANT_MISSING';
|
|
732
|
+
task.errorMessage = '历史解析任务缺少目标智能体,无法自动恢复';
|
|
733
|
+
task.completedAt = new Date();
|
|
734
|
+
await repo.save(task);
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
async failRecoverableReanalysisWithoutTarget(snapshot) {
|
|
738
|
+
await this.dataSource.transaction(async (manager) => {
|
|
739
|
+
const repo = manager.getRepository(CustomerContractAnalysisRun);
|
|
740
|
+
const run = await repo.findOne({ where: { ...activeScope(snapshot), id: snapshot.id }, lock: { mode: 'pessimistic_write' } });
|
|
741
|
+
if (!run || run.targetAssistantId || !RECOVERABLE_REANALYSIS_STATUSES.includes(run.status))
|
|
742
|
+
return;
|
|
743
|
+
run.status = 'FAILED';
|
|
744
|
+
run.errorCode = 'TARGET_ASSISTANT_MISSING';
|
|
745
|
+
run.errorMessage = '历史重新分析任务缺少目标智能体,无法自动恢复';
|
|
746
|
+
run.completedAt = new Date();
|
|
747
|
+
await repo.save(run);
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
async markImportTaskTimedOut(scope, id, claimAttemptToken) {
|
|
751
|
+
const attemptToken = requiredId(claimAttemptToken, '领取尝试标识');
|
|
752
|
+
return this.dataSource.transaction(async (manager) => {
|
|
753
|
+
const repo = manager.getRepository(ImportTask);
|
|
754
|
+
const task = await required(repo.findOne({
|
|
755
|
+
where: { ...activeScope(scope), id },
|
|
756
|
+
lock: { mode: 'pessimistic_write' }
|
|
757
|
+
}), '解析任务不存在');
|
|
758
|
+
if (!RUNNING_IMPORT_TASK_STATUSES.includes(task.status))
|
|
759
|
+
return task;
|
|
760
|
+
if (task.claimAttemptToken !== attemptToken)
|
|
761
|
+
return task;
|
|
762
|
+
task.status = 'TIMED_OUT';
|
|
763
|
+
task.errorCode = 'AI_PARSING_TIMEOUT';
|
|
764
|
+
task.errorMessage = '解析任务超过 20 分钟未完成';
|
|
765
|
+
task.completedAt = new Date();
|
|
766
|
+
return repo.save(task);
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
async findNextPendingReanalysisCandidate(scope) {
|
|
770
|
+
return this.dataSource.getRepository(CustomerContractAnalysisRun).findOne({
|
|
771
|
+
where: { ...activeScope(scope), status: 'PENDING' },
|
|
772
|
+
order: { createdAt: 'ASC', id: 'ASC' }
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
async claimPendingReanalysisById(scope, id, claimAttemptToken) {
|
|
776
|
+
const attemptToken = requiredId(claimAttemptToken, '领取尝试标识');
|
|
777
|
+
return this.dataSource.transaction(async (manager) => {
|
|
778
|
+
await lockOrganizationTaskClaim(manager, scope);
|
|
779
|
+
if (await organizationHasActiveParsing(manager, scope))
|
|
780
|
+
return null;
|
|
781
|
+
const locked = await lockCustomerContractReanalysis(manager, scope, id);
|
|
782
|
+
if (await timeoutCustomerContractReanalysis(manager, locked))
|
|
783
|
+
return null;
|
|
784
|
+
const repo = manager.getRepository(CustomerContractAnalysisRun);
|
|
785
|
+
const run = locked.run;
|
|
786
|
+
if (run.status !== 'PENDING')
|
|
787
|
+
return null;
|
|
788
|
+
if (!run.targetAssistantId) {
|
|
789
|
+
run.status = 'FAILED';
|
|
790
|
+
run.errorCode = 'TARGET_ASSISTANT_MISSING';
|
|
791
|
+
run.errorMessage = '历史重新分析任务缺少目标智能体,无法自动恢复';
|
|
792
|
+
run.completedAt = new Date();
|
|
793
|
+
await repo.save(run);
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
run.status = 'RUNNING';
|
|
797
|
+
run.claimAttemptToken = attemptToken;
|
|
798
|
+
run.startedAt ??= new Date();
|
|
799
|
+
return repo.save(run);
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
async expireCustomerContractReanalysisIfNeeded(scope, id) {
|
|
803
|
+
return this.dataSource.transaction(async (manager) => {
|
|
804
|
+
const locked = await lockCustomerContractReanalysis(manager, scope, id);
|
|
805
|
+
await timeoutCustomerContractReanalysis(manager, locked);
|
|
806
|
+
return locked.run;
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
async claimNextPendingReanalysis(scope) {
|
|
810
|
+
return this.dataSource.transaction(async (manager) => {
|
|
811
|
+
await lockOrganizationTaskClaim(manager, scope);
|
|
812
|
+
const repo = manager.getRepository(CustomerContractAnalysisRun);
|
|
813
|
+
if (await organizationHasActiveParsing(manager, scope))
|
|
814
|
+
return null;
|
|
815
|
+
const run = await repo.findOne({
|
|
816
|
+
where: { ...activeScope(scope), status: 'PENDING' },
|
|
817
|
+
order: { createdAt: 'ASC', id: 'ASC' },
|
|
818
|
+
lock: { mode: 'pessimistic_write', onLocked: 'skip_locked' }
|
|
819
|
+
});
|
|
820
|
+
if (!run)
|
|
821
|
+
return null;
|
|
822
|
+
if (!run.targetAssistantId) {
|
|
823
|
+
run.status = 'FAILED';
|
|
824
|
+
run.errorCode = 'TARGET_ASSISTANT_MISSING';
|
|
825
|
+
run.errorMessage = '历史重新分析任务缺少目标智能体,无法自动恢复';
|
|
826
|
+
run.completedAt = new Date();
|
|
827
|
+
await repo.save(run);
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
run.status = 'RUNNING';
|
|
831
|
+
run.startedAt ??= new Date();
|
|
832
|
+
return repo.save(run);
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
async saveImportTaskExecution(scope, id, claimAttemptToken, command) {
|
|
836
|
+
const attemptToken = requiredId(claimAttemptToken, '领取尝试标识');
|
|
837
|
+
return this.dataSource.transaction(async (manager) => {
|
|
838
|
+
const repo = manager.getRepository(ImportTask);
|
|
839
|
+
const task = await required(repo.findOne({
|
|
840
|
+
where: { ...activeScope(scope), id },
|
|
841
|
+
lock: { mode: 'pessimistic_write' }
|
|
842
|
+
}), '解析任务不存在');
|
|
843
|
+
if (task.status !== 'EXTRACTING' || task.claimAttemptToken !== attemptToken)
|
|
844
|
+
return null;
|
|
845
|
+
task.executionCommand = command;
|
|
846
|
+
task.commandDispatchedAt = null;
|
|
847
|
+
return repo.save(task);
|
|
848
|
+
});
|
|
310
849
|
}
|
|
311
850
|
async markImportCommandDispatched(scope, id) {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
851
|
+
return this.dataSource.transaction(async (manager) => {
|
|
852
|
+
const repo = manager.getRepository(ImportTask);
|
|
853
|
+
const task = await required(repo.findOne({
|
|
854
|
+
where: { ...activeScope(scope), id },
|
|
855
|
+
lock: { mode: 'pessimistic_write' }
|
|
856
|
+
}), '解析任务不存在');
|
|
857
|
+
if (!RUNNING_IMPORT_TASK_STATUSES.includes(task.status)) {
|
|
858
|
+
return task;
|
|
859
|
+
}
|
|
860
|
+
task.commandDispatchedAt ??= new Date();
|
|
861
|
+
if (task.status === 'EXTRACTING')
|
|
862
|
+
task.status = 'AI_PARSING';
|
|
863
|
+
return repo.save(task);
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
async assertImportTaskCompleted(scope, id) {
|
|
867
|
+
return this.dataSource.transaction(async (manager) => {
|
|
868
|
+
const repo = manager.getRepository(ImportTask);
|
|
869
|
+
const task = await required(repo.findOne({
|
|
870
|
+
where: { ...activeScope(scope), id },
|
|
871
|
+
lock: { mode: 'pessimistic_write' }
|
|
872
|
+
}), '解析任务不存在');
|
|
873
|
+
if (task.status === 'AWAITING_REVIEW' || task.status === 'SUCCEEDED')
|
|
874
|
+
return task;
|
|
875
|
+
if (task.status === 'FAILED' || task.status === 'TIMED_OUT')
|
|
876
|
+
return task;
|
|
877
|
+
task.status = 'FAILED';
|
|
878
|
+
task.errorCode = 'AGENT_HANDOFF_RESULT_MISSING';
|
|
879
|
+
task.errorMessage = '智能体已结束,但解析工具未保存业务结果';
|
|
880
|
+
task.completedAt = new Date();
|
|
881
|
+
return repo.save(task);
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
async processImportTaskHandoffCallback(scope, id, input) {
|
|
885
|
+
validateHandoffCallbackInput(input);
|
|
886
|
+
return this.dataSource.transaction(async (manager) => {
|
|
887
|
+
const repo = manager.getRepository(ImportTask);
|
|
888
|
+
const task = await repo.findOne({
|
|
889
|
+
where: { ...activeScope(scope), id },
|
|
890
|
+
lock: { mode: 'pessimistic_write' }
|
|
891
|
+
});
|
|
892
|
+
if (!task)
|
|
893
|
+
return { accepted: false, reason: 'scope_mismatch', sequence: 0 };
|
|
894
|
+
if (!handoffCallbackIdentityMatches(input, `trade-compliance-import-task-${task.id}`, task.targetAssistantId))
|
|
895
|
+
return { accepted: false, reason: 'identity_mismatch', sequence: task.handoffCallbackSequence ?? 0 };
|
|
896
|
+
const currentSequence = task.handoffCallbackSequence ?? 0;
|
|
897
|
+
if (input.sequence <= currentSequence) {
|
|
898
|
+
return { accepted: false, reason: 'stale_sequence', sequence: currentSequence };
|
|
899
|
+
}
|
|
900
|
+
task.handoffCallbackSequence = input.sequence;
|
|
901
|
+
if (input.kind === 'complete' &&
|
|
902
|
+
task.status !== 'AWAITING_REVIEW' && task.status !== 'SUCCEEDED' &&
|
|
903
|
+
task.status !== 'FAILED' && task.status !== 'TIMED_OUT') {
|
|
904
|
+
task.status = 'FAILED';
|
|
905
|
+
task.errorCode = 'AGENT_HANDOFF_RESULT_MISSING';
|
|
906
|
+
task.errorMessage = '智能体已结束,但解析工具未保存业务结果';
|
|
907
|
+
task.completedAt = new Date();
|
|
908
|
+
}
|
|
909
|
+
else if (input.kind === 'error' &&
|
|
910
|
+
!TERMINAL_IMPORT_TASK_STATUSES.includes(task.status)) {
|
|
911
|
+
task.status = 'FAILED';
|
|
912
|
+
task.errorCode = requiredId(input.errorCode, '错误代码');
|
|
913
|
+
task.errorMessage = requiredId(input.errorMessage, '错误信息');
|
|
914
|
+
task.completedAt = new Date();
|
|
915
|
+
}
|
|
916
|
+
await repo.save(task);
|
|
917
|
+
return { accepted: true, sequence: input.sequence };
|
|
918
|
+
});
|
|
316
919
|
}
|
|
317
920
|
async markImportTaskFailed(scope, id, failure) {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
921
|
+
return this.dataSource.transaction(async (manager) => {
|
|
922
|
+
const repo = manager.getRepository(ImportTask);
|
|
923
|
+
const task = await required(repo.findOne({ where: { ...activeScope(scope), id }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
|
|
924
|
+
if (TERMINAL_IMPORT_TASK_STATUSES.includes(task.status))
|
|
925
|
+
return task;
|
|
926
|
+
task.status = 'FAILED';
|
|
927
|
+
task.errorCode = failure.errorCode;
|
|
928
|
+
task.errorMessage = failure.errorMessage;
|
|
929
|
+
task.completedAt = new Date();
|
|
930
|
+
return repo.save(task);
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
async markImportTaskRecoveryStateInvalidIfUnchanged(scope, id, snapshot, failure) {
|
|
934
|
+
return this.dataSource.transaction(async (manager) => {
|
|
935
|
+
const repo = manager.getRepository(ImportTask);
|
|
936
|
+
const task = await required(repo.findOne({
|
|
937
|
+
where: { ...activeScope(scope), id },
|
|
938
|
+
lock: { mode: 'pessimistic_write' }
|
|
939
|
+
}), '解析任务不存在');
|
|
940
|
+
if (!RUNNING_IMPORT_TASK_STATUSES.includes(task.status))
|
|
941
|
+
return task;
|
|
942
|
+
if (task.claimAttemptToken !== snapshot.claimAttemptToken)
|
|
943
|
+
return task;
|
|
944
|
+
if (!snapshotMissingRecoveryMetadataStillMissing(snapshot, task))
|
|
945
|
+
return task;
|
|
946
|
+
task.status = 'FAILED';
|
|
947
|
+
task.errorCode = failure.errorCode;
|
|
948
|
+
task.errorMessage = failure.errorMessage;
|
|
949
|
+
task.completedAt = new Date();
|
|
950
|
+
return repo.save(task);
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
async createRetryImportTask(scope, sourceTaskId, requestedTargetAssistantId) {
|
|
954
|
+
const targetAssistantId = requiredId(requestedTargetAssistantId, '目标智能体标识');
|
|
327
955
|
return this.dataSource.transaction(async (manager) => {
|
|
328
956
|
const repo = manager.getRepository(ImportTask);
|
|
329
957
|
const source = await required(repo.findOne({ where: { ...activeScope(scope), id: sourceTaskId }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
|
|
@@ -334,21 +962,30 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
334
962
|
if (!source.executionInput)
|
|
335
963
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '原任务缺少可恢复执行参数');
|
|
336
964
|
const existing = await repo.findOne({ where: { ...activeScope(scope), retryOfTaskId: source.id } });
|
|
337
|
-
if (existing)
|
|
338
|
-
return existing
|
|
339
|
-
|
|
965
|
+
if (existing) {
|
|
966
|
+
return TERMINAL_IMPORT_TASK_STATUSES.includes(existing.status)
|
|
967
|
+
? { kind: 'EXISTING_TERMINAL', task: existing }
|
|
968
|
+
: { kind: 'EXISTING_ACTIVE', task: existing };
|
|
969
|
+
}
|
|
970
|
+
const task = await repo.save(repo.create({
|
|
340
971
|
...createdScope(scope), documentType: source.documentType, sourceFileId: source.sourceFileId,
|
|
341
972
|
sourceFileName: source.sourceFileName, sourceFileFormat: source.sourceFileFormat,
|
|
973
|
+
originalSourceFileName: source.originalSourceFileName,
|
|
974
|
+
fileNameEncodingStatus: source.fileNameEncodingStatus,
|
|
342
975
|
sourcePlatformFileId: source.sourcePlatformFileId, sourceWorkspacePath: source.sourceWorkspacePath,
|
|
343
|
-
executionInput: source.executionInput,
|
|
344
|
-
retryOfTaskId: source.id, status: '
|
|
976
|
+
executionInput: source.executionInput, targetAssistantId,
|
|
977
|
+
retryOfTaskId: source.id, status: 'QUEUED', queuedAt: new Date(), createdById: scope.userId ?? null
|
|
345
978
|
}));
|
|
979
|
+
return { kind: 'CREATED_REDISPATCHABLE', task };
|
|
346
980
|
});
|
|
347
981
|
}
|
|
348
982
|
async updateImportTaskDraft(scope, id, payload, version = 'v1') {
|
|
349
983
|
return this.dataSource.transaction(async (manager) => {
|
|
350
984
|
const repo = manager.getRepository(ImportTask);
|
|
351
985
|
const task = await required(repo.findOne({ where: { ...activeScope(scope), id }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
|
|
986
|
+
if (task.status === 'AWAITING_REVIEW')
|
|
987
|
+
return task;
|
|
988
|
+
assertImportTaskResultWritable(task);
|
|
352
989
|
await invalidateCustomerContractReanalysis(manager, scope, { reviewSource: 'IMPORT_DRAFT', task });
|
|
353
990
|
task.resultPayload = payload;
|
|
354
991
|
task.resultPayloadVersion = version;
|
|
@@ -358,12 +995,19 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
358
995
|
});
|
|
359
996
|
}
|
|
360
997
|
async markImportTaskParsing(scope, id) {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
998
|
+
return this.dataSource.transaction(async (manager) => {
|
|
999
|
+
await lockOrganizationTaskClaim(manager, scope);
|
|
1000
|
+
if (await organizationHasActiveParsing(manager, scope))
|
|
1001
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '组织内已有解析任务运行中');
|
|
1002
|
+
const repo = manager.getRepository(ImportTask);
|
|
1003
|
+
const task = await required(repo.findOne({ where: { ...activeScope(scope), id }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
|
|
1004
|
+
if (task.status !== 'QUEUED')
|
|
1005
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '只有排队中的解析任务可以开始解析');
|
|
1006
|
+
task.status = 'AI_PARSING';
|
|
1007
|
+
task.startedAt = new Date();
|
|
1008
|
+
task.completedAt = null;
|
|
1009
|
+
return repo.save(task);
|
|
1010
|
+
});
|
|
367
1011
|
}
|
|
368
1012
|
async savePurchaseDraft(scope, importTaskId, draft) {
|
|
369
1013
|
assertPurchaseDraft(draft);
|
|
@@ -549,6 +1193,8 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
549
1193
|
const contract = await this.dataSource.transaction(async (manager) => {
|
|
550
1194
|
const taskRepo = manager.getRepository(ImportTask);
|
|
551
1195
|
const task = await required(taskRepo.findOne({ where: { ...activeScope(scope), id: importTaskId }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
|
|
1196
|
+
if (task.documentType !== 'CUSTOMER_CONTRACT' || task.status !== 'AWAITING_REVIEW' || task.resultEntityId != null)
|
|
1197
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '仅待审核且未入库的客户合同任务可以保存审核');
|
|
552
1198
|
await invalidateCustomerContractReanalysis(manager, scope, { reviewSource: 'IMPORT_DRAFT', task });
|
|
553
1199
|
const parsed = parseCustomerContractNo(draft.contract.contractNo);
|
|
554
1200
|
const contractRepo = manager.getRepository(CustomerContract);
|
|
@@ -675,9 +1321,10 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
675
1321
|
const latestRun = contract.latestAnalysisRunId ? runs.find(run => run.id === contract.latestAnalysisRunId && run.status === 'SUCCEEDED') ?? null : null;
|
|
676
1322
|
const analyses = latestRun ? await this.dataSource.getRepository(CustomerContractClauseAnalysis).find({ where: { ...activeScope(scope), analysisRunId: latestRun.id } }) : [];
|
|
677
1323
|
const sanctionMatchCount = customer ? await this.dataSource.getRepository(CompanySanctionMatch).count({ where: { ...activeScope(scope), subjectType: 'CUSTOMER', customerId: customer.id, deletedAt: IsNull() } }) : 0;
|
|
678
|
-
return { contract, customer, clauses, analyses, latestRun, analysisRuns: runs, sanctionStatus: customer?.complianceCheckStatus === 'CHECKED' ? sanctionMatchCount ? 'HIT' : 'CLEAR' : 'UNCHECKED' };
|
|
1324
|
+
return { contract, customer, clauses, analyses, latestRun: latestRun ? publicCustomerContractReanalysisDto(latestRun) : null, analysisRuns: runs.map(publicCustomerContractReanalysisDto), sanctionStatus: customer?.complianceCheckStatus === 'CHECKED' ? sanctionMatchCount ? 'HIT' : 'CLEAR' : 'UNCHECKED' };
|
|
679
1325
|
}
|
|
680
|
-
async startCustomerContractReanalysis(scope, input) {
|
|
1326
|
+
async startCustomerContractReanalysis(scope, input, requestedTargetAssistantId) {
|
|
1327
|
+
const targetAssistantId = requiredId(requestedTargetAssistantId, '目标智能体标识');
|
|
681
1328
|
return this.dataSource.transaction(async (manager) => {
|
|
682
1329
|
const now = new Date();
|
|
683
1330
|
let importTaskId;
|
|
@@ -715,6 +1362,23 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
715
1362
|
if (activeRun) {
|
|
716
1363
|
if (targetCurrentRunId && targetCurrentRunId !== activeRun.id)
|
|
717
1364
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析任务已不是当前运行');
|
|
1365
|
+
if (!activeRun.targetAssistantId) {
|
|
1366
|
+
activeRun.status = 'FAILED';
|
|
1367
|
+
activeRun.errorCode = 'TARGET_ASSISTANT_MISSING';
|
|
1368
|
+
activeRun.errorMessage = '历史重新分析任务缺少目标智能体,无法自动恢复';
|
|
1369
|
+
activeRun.completedAt = now;
|
|
1370
|
+
await runRepo.save(activeRun);
|
|
1371
|
+
if (contractTarget?.currentReanalysisRunId === activeRun.id)
|
|
1372
|
+
contractTarget.currentReanalysisRunId = null;
|
|
1373
|
+
if (taskTarget?.currentReanalysisRunId === activeRun.id)
|
|
1374
|
+
taskTarget.currentReanalysisRunId = null;
|
|
1375
|
+
activeRun = null;
|
|
1376
|
+
}
|
|
1377
|
+
else if (activeRun.targetAssistantId !== targetAssistantId) {
|
|
1378
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析任务的目标智能体不一致');
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
if (activeRun) {
|
|
718
1382
|
const expiresAt = activeRun.expiresAt ?? new Date((activeRun.createdAt ?? now).getTime() + CUSTOMER_REANALYSIS_TIMEOUT_MS);
|
|
719
1383
|
activeRun.expiresAt = expiresAt;
|
|
720
1384
|
if (!await timeoutCustomerContractReanalysis(manager, { run: activeRun, contract: contractTarget, task: taskTarget }, now)) {
|
|
@@ -739,7 +1403,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
739
1403
|
await runRepo.save(activeRun);
|
|
740
1404
|
}
|
|
741
1405
|
}
|
|
742
|
-
const run = await runRepo.save(runRepo.create({ ...createdScope(scope), customerContractId, importTaskId, analysisType: 'REANALYSIS', status: 'PENDING', promptVersion: 'CUSTOMER_CLAUSE_V1', modelName: null, errorCode: null, errorMessage: null, analysisInput, executionCommand: null, expiresAt: new Date(now.getTime() + CUSTOMER_REANALYSIS_TIMEOUT_MS), commandDispatchedAt: null, startedAt: null, completedAt: null, createdById: scope.userId ?? null }));
|
|
1406
|
+
const run = await runRepo.save(runRepo.create({ ...createdScope(scope), customerContractId, importTaskId, analysisType: 'REANALYSIS', status: 'PENDING', targetAssistantId, promptVersion: 'CUSTOMER_CLAUSE_V1', modelName: null, errorCode: null, errorMessage: null, analysisInput, executionCommand: null, expiresAt: new Date(now.getTime() + CUSTOMER_REANALYSIS_TIMEOUT_MS), commandDispatchedAt: null, startedAt: null, completedAt: null, createdById: scope.userId ?? null }));
|
|
743
1407
|
if (contractTarget) {
|
|
744
1408
|
contractTarget.currentReanalysisRunId = run.id;
|
|
745
1409
|
await manager.getRepository(CustomerContract).save(contractTarget);
|
|
@@ -772,6 +1436,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
772
1436
|
}
|
|
773
1437
|
async markCustomerContractReanalysisDispatched(scope, id) {
|
|
774
1438
|
const outcome = await this.dataSource.transaction(async (manager) => {
|
|
1439
|
+
await lockOrganizationTaskClaim(manager, scope);
|
|
775
1440
|
const locked = await lockCustomerContractReanalysis(manager, scope, id);
|
|
776
1441
|
const run = locked.run;
|
|
777
1442
|
if (await timeoutCustomerContractReanalysis(manager, locked))
|
|
@@ -782,6 +1447,8 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
782
1447
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析任务尚未持久化执行命令');
|
|
783
1448
|
if (lockedCurrentReanalysisRunId(locked) !== run.id)
|
|
784
1449
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析任务已不是当前运行');
|
|
1450
|
+
if (await organizationHasActiveParsing(manager, scope))
|
|
1451
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '组织内已有解析任务运行中');
|
|
785
1452
|
const dispatchedAt = new Date();
|
|
786
1453
|
run.commandDispatchedAt = dispatchedAt;
|
|
787
1454
|
run.startedAt = dispatchedAt;
|
|
@@ -792,6 +1459,92 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
792
1459
|
throw reanalysisTimeoutError();
|
|
793
1460
|
return outcome.run;
|
|
794
1461
|
}
|
|
1462
|
+
async markCustomerContractReanalysisCommandDispatched(scope, id) {
|
|
1463
|
+
return this.dataSource.transaction(async (manager) => {
|
|
1464
|
+
const repo = manager.getRepository(CustomerContractAnalysisRun);
|
|
1465
|
+
const run = await required(repo.findOne({
|
|
1466
|
+
where: { ...activeScope(scope), id },
|
|
1467
|
+
lock: { mode: 'pessimistic_write' }
|
|
1468
|
+
}), '重新分析任务不存在');
|
|
1469
|
+
if (run.status !== 'RUNNING')
|
|
1470
|
+
return run;
|
|
1471
|
+
if (!run.executionCommand) {
|
|
1472
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析任务尚未持久化执行命令');
|
|
1473
|
+
}
|
|
1474
|
+
run.commandDispatchedAt ??= new Date();
|
|
1475
|
+
return repo.save(run);
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
async assertCustomerContractReanalysisCompleted(scope, id) {
|
|
1479
|
+
return this.dataSource.transaction(async (manager) => {
|
|
1480
|
+
const repo = manager.getRepository(CustomerContractAnalysisRun);
|
|
1481
|
+
const locked = await lockCustomerContractReanalysis(manager, scope, id);
|
|
1482
|
+
const run = locked.run;
|
|
1483
|
+
if (run.status === 'SUCCEEDED' || run.status === 'FAILED' || run.status === 'TIMED_OUT')
|
|
1484
|
+
return run;
|
|
1485
|
+
run.status = 'FAILED';
|
|
1486
|
+
run.errorCode = 'AGENT_HANDOFF_RESULT_MISSING';
|
|
1487
|
+
run.errorMessage = '智能体已结束,但重新分析工具未保存业务结果';
|
|
1488
|
+
run.completedAt = new Date();
|
|
1489
|
+
await clearCurrentReanalysis(manager, locked);
|
|
1490
|
+
return repo.save(run);
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
async processCustomerContractReanalysisHandoffCallback(scope, id, input) {
|
|
1494
|
+
validateHandoffCallbackInput(input);
|
|
1495
|
+
return this.dataSource.transaction(async (manager) => {
|
|
1496
|
+
const repo = manager.getRepository(CustomerContractAnalysisRun);
|
|
1497
|
+
const snapshot = await repo.findOne({ where: { ...activeScope(scope), id } });
|
|
1498
|
+
if (!snapshot)
|
|
1499
|
+
return { accepted: false, reason: 'scope_mismatch', sequence: 0 };
|
|
1500
|
+
const locked = await lockCustomerContractReanalysis(manager, scope, id);
|
|
1501
|
+
const run = locked.run;
|
|
1502
|
+
if (!handoffCallbackIdentityMatches(input, `trade-compliance-customer-reanalysis-${run.id}`, run.targetAssistantId))
|
|
1503
|
+
return { accepted: false, reason: 'identity_mismatch', sequence: run.handoffCallbackSequence ?? 0 };
|
|
1504
|
+
const currentSequence = run.handoffCallbackSequence ?? 0;
|
|
1505
|
+
if (input.sequence <= currentSequence) {
|
|
1506
|
+
return { accepted: false, reason: 'stale_sequence', sequence: currentSequence };
|
|
1507
|
+
}
|
|
1508
|
+
run.handoffCallbackSequence = input.sequence;
|
|
1509
|
+
if (input.kind === 'complete' &&
|
|
1510
|
+
run.status !== 'SUCCEEDED' && run.status !== 'FAILED' && run.status !== 'TIMED_OUT') {
|
|
1511
|
+
run.status = 'FAILED';
|
|
1512
|
+
run.errorCode = 'AGENT_HANDOFF_RESULT_MISSING';
|
|
1513
|
+
run.errorMessage = '智能体已结束,但重新分析工具未保存业务结果';
|
|
1514
|
+
run.completedAt = new Date();
|
|
1515
|
+
await clearCurrentReanalysis(manager, locked);
|
|
1516
|
+
}
|
|
1517
|
+
else if (input.kind === 'error' &&
|
|
1518
|
+
run.status !== 'SUCCEEDED' && run.status !== 'FAILED' && run.status !== 'TIMED_OUT') {
|
|
1519
|
+
if (!await timeoutCustomerContractReanalysis(manager, locked)) {
|
|
1520
|
+
run.status = 'FAILED';
|
|
1521
|
+
run.errorCode = requiredId(input.errorCode, '错误代码');
|
|
1522
|
+
run.errorMessage = requiredId(input.errorMessage, '错误信息');
|
|
1523
|
+
run.completedAt = new Date();
|
|
1524
|
+
await clearCurrentReanalysis(manager, locked);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
await repo.save(run);
|
|
1528
|
+
return { accepted: true, sequence: input.sequence };
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
async markCustomerContractReanalysisTimedOut(scope, id, claimAttemptToken) {
|
|
1532
|
+
const attemptToken = requiredId(claimAttemptToken, '领取尝试标识');
|
|
1533
|
+
return this.dataSource.transaction(async (manager) => {
|
|
1534
|
+
const locked = await lockCustomerContractReanalysis(manager, scope, id);
|
|
1535
|
+
const run = locked.run;
|
|
1536
|
+
if (run.status !== 'RUNNING')
|
|
1537
|
+
return run;
|
|
1538
|
+
if (run.claimAttemptToken !== attemptToken)
|
|
1539
|
+
return run;
|
|
1540
|
+
run.status = 'TIMED_OUT';
|
|
1541
|
+
run.errorCode = 'AI_REANALYSIS_TIMEOUT';
|
|
1542
|
+
run.errorMessage = 'AI 重新分析超过 20 分钟';
|
|
1543
|
+
run.completedAt = new Date();
|
|
1544
|
+
await clearCurrentReanalysis(manager, locked);
|
|
1545
|
+
return manager.getRepository(CustomerContractAnalysisRun).save(run);
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
795
1548
|
async markCustomerContractReanalysisFailed(scope, id, input) {
|
|
796
1549
|
const errorCode = requiredId(input.errorCode, '错误代码');
|
|
797
1550
|
const errorMessage = requiredId(input.errorMessage, '错误信息');
|
|
@@ -807,6 +1560,49 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
807
1560
|
run.errorCode = errorCode;
|
|
808
1561
|
run.errorMessage = errorMessage;
|
|
809
1562
|
run.completedAt = new Date();
|
|
1563
|
+
await clearCurrentReanalysis(manager, locked);
|
|
1564
|
+
return repo.save(run);
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
async markCustomerContractReanalysisFailedIfActive(scope, id, input) {
|
|
1568
|
+
const errorCode = requiredId(input.errorCode, '错误代码');
|
|
1569
|
+
const errorMessage = requiredId(input.errorMessage, '错误信息');
|
|
1570
|
+
return this.dataSource.transaction(async (manager) => {
|
|
1571
|
+
const repo = manager.getRepository(CustomerContractAnalysisRun);
|
|
1572
|
+
const locked = await lockCustomerContractReanalysis(manager, scope, id);
|
|
1573
|
+
const run = locked.run;
|
|
1574
|
+
if (run.status === 'SUCCEEDED' || run.status === 'FAILED' || run.status === 'TIMED_OUT')
|
|
1575
|
+
return run;
|
|
1576
|
+
if (await timeoutCustomerContractReanalysis(manager, locked))
|
|
1577
|
+
return run;
|
|
1578
|
+
run.status = 'FAILED';
|
|
1579
|
+
run.errorCode = errorCode;
|
|
1580
|
+
run.errorMessage = errorMessage;
|
|
1581
|
+
run.completedAt = new Date();
|
|
1582
|
+
await clearCurrentReanalysis(manager, locked);
|
|
1583
|
+
return repo.save(run);
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
async markCustomerContractReanalysisRecoveryStateInvalidIfUnchanged(scope, id, snapshot, input) {
|
|
1587
|
+
const errorCode = requiredId(input.errorCode, '错误代码');
|
|
1588
|
+
const errorMessage = requiredId(input.errorMessage, '错误信息');
|
|
1589
|
+
return this.dataSource.transaction(async (manager) => {
|
|
1590
|
+
const repo = manager.getRepository(CustomerContractAnalysisRun);
|
|
1591
|
+
const locked = await lockCustomerContractReanalysis(manager, scope, id);
|
|
1592
|
+
const run = locked.run;
|
|
1593
|
+
if (run.status !== 'RUNNING')
|
|
1594
|
+
return run;
|
|
1595
|
+
if (await timeoutCustomerContractReanalysis(manager, locked))
|
|
1596
|
+
return run;
|
|
1597
|
+
if (run.claimAttemptToken !== snapshot.claimAttemptToken)
|
|
1598
|
+
return run;
|
|
1599
|
+
if (!snapshotMissingRecoveryMetadataStillMissing(snapshot, run))
|
|
1600
|
+
return run;
|
|
1601
|
+
run.status = 'FAILED';
|
|
1602
|
+
run.errorCode = errorCode;
|
|
1603
|
+
run.errorMessage = errorMessage;
|
|
1604
|
+
run.completedAt = new Date();
|
|
1605
|
+
await clearCurrentReanalysis(manager, locked);
|
|
810
1606
|
return repo.save(run);
|
|
811
1607
|
});
|
|
812
1608
|
}
|
|
@@ -816,12 +1612,13 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
816
1612
|
await timeoutCustomerContractReanalysis(manager, locked);
|
|
817
1613
|
return locked.run;
|
|
818
1614
|
});
|
|
1615
|
+
const publicRun = publicCustomerContractReanalysisDto(run);
|
|
819
1616
|
if (run.status !== 'SUCCEEDED')
|
|
820
|
-
return { run };
|
|
1617
|
+
return { run: publicRun };
|
|
821
1618
|
if (run.customerContractId)
|
|
822
|
-
return { run, review: await this.getCustomerContractReview(scope, run.customerContractId) };
|
|
1619
|
+
return { run: publicRun, review: await this.getCustomerContractReview(scope, run.customerContractId) };
|
|
823
1620
|
const task = await required(this.dataSource.getRepository(ImportTask).findOne({ where: { ...activeScope(scope), id: run.importTaskId } }), '解析任务不存在');
|
|
824
|
-
return { run, task };
|
|
1621
|
+
return { run: publicRun, task: publicImportTaskDto(task) };
|
|
825
1622
|
}
|
|
826
1623
|
async saveCustomerContractReanalysis(scope, analysisRunId, analyses) {
|
|
827
1624
|
assertCompleteClauseAnalyses(analyses);
|
|
@@ -1200,16 +1997,32 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1200
1997
|
await this.recalculateProductRisks(scope, 'CATALOG_CHANGED');
|
|
1201
1998
|
if (entity === SanctionedCompanyRecord)
|
|
1202
1999
|
await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
|
|
2000
|
+
if (entity === ControlledGoodsRecord)
|
|
2001
|
+
return controlledGoodsDto(row);
|
|
2002
|
+
if (entity === SanctionedCompanyRecord)
|
|
2003
|
+
return sanctionedCompanyDto(row);
|
|
1203
2004
|
return row;
|
|
1204
2005
|
}
|
|
1205
2006
|
async listGeneratedFiles(scope, query = {}) { return this.pagedQuery(GeneratedSalesFile, scope, query, ['salesOrderNoSnapshot', 'documentNo', 'fileName'], { customerContractId: query.parameters?.customerContractId, purchaseOrderId: query.parameters?.purchaseOrderId, fileType: query.parameters?.fileType }); }
|
|
1206
|
-
async
|
|
2007
|
+
async listMonthlyRates(scope, query = {}) {
|
|
2008
|
+
const page = Math.max(1, Math.trunc(Number(query.page)) || 1);
|
|
2009
|
+
const requestedPageSize = Number(query.pageSize);
|
|
2010
|
+
const pageSize = [10, 20, 50, 100].includes(requestedPageSize) ? requestedPageSize : 20;
|
|
2011
|
+
const [items, total] = await this.dataSource.getRepository(MonthlySafeExchangeRate).findAndCount({
|
|
2012
|
+
where: { ...activeScope(scope), deletedAt: IsNull() },
|
|
2013
|
+
order: { year: 'DESC', month: 'DESC' },
|
|
2014
|
+
skip: (page - 1) * pageSize,
|
|
2015
|
+
take: pageSize
|
|
2016
|
+
});
|
|
2017
|
+
return { items, total, page, pageSize, pageCount: Math.max(1, Math.ceil(total / pageSize)) };
|
|
2018
|
+
}
|
|
2019
|
+
async settings(scope, rateQuery = {}) {
|
|
1207
2020
|
await this.bootstrapSettings(scope);
|
|
1208
2021
|
const [profit, company, banks, rates] = await Promise.all([
|
|
1209
2022
|
this.dataSource.getRepository(ProfitRuleSettings).findOne({ where: activeScope(scope) }),
|
|
1210
2023
|
this.dataSource.getRepository(CompanySettings).findOne({ where: activeScope(scope) }),
|
|
1211
2024
|
this.dataSource.getRepository(BankAccountSettings).find({ where: activeScope(scope), order: { bankType: 'ASC' } }),
|
|
1212
|
-
this.
|
|
2025
|
+
this.listMonthlyRates(scope, rateQuery)
|
|
1213
2026
|
]);
|
|
1214
2027
|
return { company, banks, profit, rates };
|
|
1215
2028
|
}
|
|
@@ -1485,7 +2298,7 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1485
2298
|
await consume(row);
|
|
1486
2299
|
});
|
|
1487
2300
|
}
|
|
1488
|
-
async recalculateProductRisks(scope, reason) {
|
|
2301
|
+
async recalculateProductRisks(scope, reason, controlledBatchId) {
|
|
1489
2302
|
return this.dataSource.transaction(async (manager) => {
|
|
1490
2303
|
const matchRepo = manager.getRepository(ProductComplianceMatch);
|
|
1491
2304
|
const lineRepo = manager.getRepository(PurchaseOrderLine);
|
|
@@ -1493,6 +2306,11 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1493
2306
|
if (this.dataSource.options?.type === 'postgres') {
|
|
1494
2307
|
await manager.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [`trade-compliance:product-risk:${scope.tenantId}:${scope.organizationId}`, 'recalculate']);
|
|
1495
2308
|
}
|
|
2309
|
+
const controlledBatch = controlledBatchId
|
|
2310
|
+
? await required(manager.getRepository(ControlledCatalogBatch).findOne({ where: { ...activeScope(scope), id: controlledBatchId }, lock: { mode: 'pessimistic_write' } }), '管控目录批次不存在')
|
|
2311
|
+
: null;
|
|
2312
|
+
if (controlledBatch?.riskRecalculatedAt)
|
|
2313
|
+
return { scanned: 0, matches: 0, riskRecalculatedAt: controlledBatch.riskRecalculatedAt };
|
|
1496
2314
|
const lines = await lineRepo.find({
|
|
1497
2315
|
where: { ...activeScope(scope), deletedAt: IsNull() },
|
|
1498
2316
|
order: { id: 'ASC' },
|
|
@@ -1514,7 +2332,14 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1514
2332
|
}
|
|
1515
2333
|
await lineRepo.update({ ...activeScope(scope), id: line.id, deletedAt: IsNull() }, { complianceCheckedAt: now, updatedById: scope.userId ?? null, updatedAt: now });
|
|
1516
2334
|
}
|
|
1517
|
-
|
|
2335
|
+
const matches = await matchRepo.count({ where: { ...activeScope(scope), deletedAt: IsNull() } });
|
|
2336
|
+
if (controlledBatch) {
|
|
2337
|
+
controlledBatch.riskRecalculatedAt = now;
|
|
2338
|
+
controlledBatch.updatedById = scope.userId ?? null;
|
|
2339
|
+
await manager.getRepository(ControlledCatalogBatch).save(controlledBatch);
|
|
2340
|
+
return { scanned: lines.length, matches, riskRecalculatedAt: now };
|
|
2341
|
+
}
|
|
2342
|
+
return { scanned: lines.length, matches };
|
|
1518
2343
|
});
|
|
1519
2344
|
}
|
|
1520
2345
|
async recalculateCompanyRisks(scope, reason) {
|
|
@@ -1769,12 +2594,21 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
|
|
|
1769
2594
|
const startedAt = task.startedAt ?? task.queuedAt ?? task.createdAt;
|
|
1770
2595
|
if (!startedAt || !importTaskTimedOut(startedAt, now))
|
|
1771
2596
|
continue;
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
2597
|
+
await this.dataSource.transaction(async (manager) => {
|
|
2598
|
+
const lockedRepo = manager.getRepository(ImportTask);
|
|
2599
|
+
const locked = await lockedRepo.findOne({ where: { ...activeScope(scope), id: task.id }, lock: { mode: 'pessimistic_write' } });
|
|
2600
|
+
if (!locked || !RECOVERABLE_IMPORT_TASK_STATUSES.includes(locked.status))
|
|
2601
|
+
return;
|
|
2602
|
+
const lockedStartedAt = locked.startedAt ?? locked.queuedAt ?? locked.createdAt;
|
|
2603
|
+
if (!lockedStartedAt || !importTaskTimedOut(lockedStartedAt, now))
|
|
2604
|
+
return;
|
|
2605
|
+
locked.status = 'TIMED_OUT';
|
|
2606
|
+
locked.errorCode = DomainErrorCodes.IMPORT_TIMED_OUT;
|
|
2607
|
+
locked.errorMessage = '文件解析超过 20 分钟,请重新上传';
|
|
2608
|
+
locked.completedAt = now;
|
|
2609
|
+
locked.machineElapsedMs = now.getTime() - lockedStartedAt.getTime();
|
|
2610
|
+
await lockedRepo.save(locked);
|
|
2611
|
+
});
|
|
1778
2612
|
}
|
|
1779
2613
|
}
|
|
1780
2614
|
async countUnlinkedPurchases(scope) {
|
|
@@ -1798,11 +2632,75 @@ TradeComplianceWorkbenchService = __decorate([
|
|
|
1798
2632
|
Injectable(),
|
|
1799
2633
|
__param(0, InjectDataSource()),
|
|
1800
2634
|
__param(1, Optional()),
|
|
2635
|
+
__param(2, Optional()),
|
|
1801
2636
|
__metadata("design:paramtypes", [DataSource,
|
|
1802
|
-
TradeComplianceFileStorage
|
|
2637
|
+
TradeComplianceFileStorage,
|
|
2638
|
+
TranslationProvider])
|
|
1803
2639
|
], TradeComplianceWorkbenchService);
|
|
1804
2640
|
export { TradeComplianceWorkbenchService };
|
|
1805
2641
|
function activeScope(scope) { return { tenantId: scope.tenantId, organizationId: scope.organizationId }; }
|
|
2642
|
+
function validateHandoffCallbackInput(input) {
|
|
2643
|
+
if (input.kind !== 'stream' && input.kind !== 'complete' && input.kind !== 'error') {
|
|
2644
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, 'Handoff 回调类型无效');
|
|
2645
|
+
}
|
|
2646
|
+
if (!Number.isInteger(input.sequence) || input.sequence <= 0) {
|
|
2647
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, 'Handoff 回调序号无效');
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
function handoffCallbackIdentityMatches(input, expectedSourceMessageId, persistedTargetAssistantId) {
|
|
2651
|
+
return input.sourceMessageId === expectedSourceMessageId &&
|
|
2652
|
+
Boolean(persistedTargetAssistantId) &&
|
|
2653
|
+
input.targetAssistantId === persistedTargetAssistantId;
|
|
2654
|
+
}
|
|
2655
|
+
async function lockOrganizationTaskClaim(manager, scope) {
|
|
2656
|
+
await manager.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [scope.tenantId, scope.organizationId]);
|
|
2657
|
+
}
|
|
2658
|
+
async function organizationHasActiveParsing(manager, scope) {
|
|
2659
|
+
const activeImport = await manager.getRepository(ImportTask).findOne({
|
|
2660
|
+
where: { ...activeScope(scope), status: In([...RUNNING_IMPORT_TASK_STATUSES]) },
|
|
2661
|
+
order: { startedAt: 'ASC', id: 'ASC' },
|
|
2662
|
+
lock: { mode: 'pessimistic_write' }
|
|
2663
|
+
});
|
|
2664
|
+
if (activeImport)
|
|
2665
|
+
return true;
|
|
2666
|
+
const activeRun = await manager.getRepository(CustomerContractAnalysisRun).findOne({
|
|
2667
|
+
where: { ...activeScope(scope), status: 'RUNNING' },
|
|
2668
|
+
order: { startedAt: 'ASC', id: 'ASC' },
|
|
2669
|
+
lock: { mode: 'pessimistic_write' }
|
|
2670
|
+
});
|
|
2671
|
+
return Boolean(activeRun);
|
|
2672
|
+
}
|
|
2673
|
+
function assertImportTaskResultWritable(task) {
|
|
2674
|
+
if (TERMINAL_IMPORT_TASK_STATUSES.includes(task.status)) {
|
|
2675
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '解析任务状态不允许保存结果');
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
function throwImportTaskTerminalState(task) {
|
|
2679
|
+
const message = task.status === 'FAILED' || task.status === 'TIMED_OUT'
|
|
2680
|
+
? task.errorMessage || '解析任务未成功完成'
|
|
2681
|
+
: '解析任务状态不允许保存结果';
|
|
2682
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, message, { status: task.status, errorCode: task.errorCode });
|
|
2683
|
+
}
|
|
2684
|
+
function cancellableDelay(ms, signal) {
|
|
2685
|
+
if (signal.aborted)
|
|
2686
|
+
return Promise.resolve(false);
|
|
2687
|
+
return new Promise(resolve => {
|
|
2688
|
+
let settled = false;
|
|
2689
|
+
const finish = (elapsed) => {
|
|
2690
|
+
if (settled)
|
|
2691
|
+
return;
|
|
2692
|
+
settled = true;
|
|
2693
|
+
clearTimeout(timeout);
|
|
2694
|
+
signal.removeEventListener('abort', onAbort);
|
|
2695
|
+
resolve(elapsed);
|
|
2696
|
+
};
|
|
2697
|
+
const onAbort = () => finish(false);
|
|
2698
|
+
const timeout = setTimeout(() => finish(true), ms);
|
|
2699
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
2700
|
+
if (signal.aborted)
|
|
2701
|
+
onAbort();
|
|
2702
|
+
});
|
|
2703
|
+
}
|
|
1806
2704
|
function isPostgresUniqueViolation(error) {
|
|
1807
2705
|
if (!error || typeof error !== 'object')
|
|
1808
2706
|
return false;
|
|
@@ -1834,6 +2732,17 @@ async function lockCustomerContractReanalysis(manager, scope, id) {
|
|
|
1834
2732
|
function lockedCurrentReanalysisRunId(locked) {
|
|
1835
2733
|
return locked.contract?.currentReanalysisRunId ?? locked.task?.currentReanalysisRunId ?? null;
|
|
1836
2734
|
}
|
|
2735
|
+
async function clearCurrentReanalysis(manager, locked) {
|
|
2736
|
+
const { run, contract, task } = locked;
|
|
2737
|
+
if (contract?.currentReanalysisRunId === run.id) {
|
|
2738
|
+
contract.currentReanalysisRunId = null;
|
|
2739
|
+
await manager.getRepository(CustomerContract).save(contract);
|
|
2740
|
+
}
|
|
2741
|
+
if (task?.currentReanalysisRunId === run.id) {
|
|
2742
|
+
task.currentReanalysisRunId = null;
|
|
2743
|
+
await manager.getRepository(ImportTask).save(task);
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
1837
2746
|
async function timeoutCustomerContractReanalysis(manager, locked, now = new Date()) {
|
|
1838
2747
|
const { run, contract, task } = locked;
|
|
1839
2748
|
if (run.status !== 'PENDING' && run.status !== 'RUNNING')
|
|
@@ -2086,6 +2995,11 @@ function completeCustomerClauseAnalyses(draft) {
|
|
|
2086
2995
|
return analysis;
|
|
2087
2996
|
});
|
|
2088
2997
|
}
|
|
2998
|
+
function snapshotMissingRecoveryMetadataStillMissing(snapshot, current) {
|
|
2999
|
+
const missingFields = ['claimAttemptToken', 'startedAt', 'commandDispatchedAt']
|
|
3000
|
+
.filter(field => !snapshot[field]);
|
|
3001
|
+
return missingFields.length > 0 && missingFields.every(field => !current[field]);
|
|
3002
|
+
}
|
|
2089
3003
|
function requiredId(value, label) { const id = String(value ?? '').trim(); if (!id)
|
|
2090
3004
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, `缺少${label}`); return id; }
|
|
2091
3005
|
function assertCompleteClauseAnalyses(analyses) {
|