@chenchaolong/plugin-trade-compliance-workbench 1.0.6 → 1.0.8

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.
Files changed (196) hide show
  1. package/dist/index.d.ts +18 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +20 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/adapters/hsbianma.provider.d.ts +6 -8
  6. package/dist/lib/adapters/hsbianma.provider.d.ts.map +1 -1
  7. package/dist/lib/adapters/hsbianma.provider.js +159 -53
  8. package/dist/lib/adapters/hsbianma.provider.js.map +1 -1
  9. package/dist/lib/adapters/translation.provider.d.ts +7 -1
  10. package/dist/lib/adapters/translation.provider.d.ts.map +1 -1
  11. package/dist/lib/adapters/translation.provider.js +15 -5
  12. package/dist/lib/adapters/translation.provider.js.map +1 -1
  13. package/dist/lib/domain/catalog-localization.d.ts +14 -0
  14. package/dist/lib/domain/catalog-localization.d.ts.map +1 -0
  15. package/dist/lib/domain/catalog-localization.js +19 -0
  16. package/dist/lib/domain/catalog-localization.js.map +1 -0
  17. package/dist/lib/domain/file-name.d.ts +8 -0
  18. package/dist/lib/domain/file-name.d.ts.map +1 -0
  19. package/dist/lib/domain/file-name.js +111 -0
  20. package/dist/lib/domain/file-name.js.map +1 -0
  21. package/dist/lib/domain/import-issues.d.ts +4 -0
  22. package/dist/lib/domain/import-issues.d.ts.map +1 -1
  23. package/dist/lib/domain/import-issues.js +14 -3
  24. package/dist/lib/domain/import-issues.js.map +1 -1
  25. package/dist/lib/entities/catalog.entity.d.ts +5 -1
  26. package/dist/lib/entities/catalog.entity.d.ts.map +1 -1
  27. package/dist/lib/entities/catalog.entity.js +16 -0
  28. package/dist/lib/entities/catalog.entity.js.map +1 -1
  29. package/dist/lib/entities/customer.entity.d.ts +3 -0
  30. package/dist/lib/entities/customer.entity.d.ts.map +1 -1
  31. package/dist/lib/entities/customer.entity.js +12 -0
  32. package/dist/lib/entities/customer.entity.js.map +1 -1
  33. package/dist/lib/entities/shared.entity.d.ts +9 -0
  34. package/dist/lib/entities/shared.entity.d.ts.map +1 -1
  35. package/dist/lib/entities/shared.entity.js +33 -0
  36. package/dist/lib/entities/shared.entity.js.map +1 -1
  37. package/dist/lib/import-jobs/agent-handoff.dispatcher.d.ts +15 -0
  38. package/dist/lib/import-jobs/agent-handoff.dispatcher.d.ts.map +1 -0
  39. package/dist/lib/import-jobs/agent-handoff.dispatcher.js +114 -0
  40. package/dist/lib/import-jobs/agent-handoff.dispatcher.js.map +1 -0
  41. package/dist/lib/import-jobs/contracts.d.ts +76 -0
  42. package/dist/lib/import-jobs/contracts.d.ts.map +1 -0
  43. package/dist/lib/import-jobs/contracts.js +31 -0
  44. package/dist/lib/import-jobs/contracts.js.map +1 -0
  45. package/dist/lib/import-jobs/import-command.builder.d.ts +30 -0
  46. package/dist/lib/import-jobs/import-command.builder.d.ts.map +1 -0
  47. package/dist/lib/import-jobs/import-command.builder.js +97 -0
  48. package/dist/lib/import-jobs/import-command.builder.js.map +1 -0
  49. package/dist/lib/import-jobs/import-job-callback.processor.d.ts +15 -0
  50. package/dist/lib/import-jobs/import-job-callback.processor.d.ts.map +1 -0
  51. package/dist/lib/import-jobs/import-job-callback.processor.js +107 -0
  52. package/dist/lib/import-jobs/import-job-callback.processor.js.map +1 -0
  53. package/dist/lib/import-jobs/import-job.coordinator.d.ts +17 -0
  54. package/dist/lib/import-jobs/import-job.coordinator.d.ts.map +1 -0
  55. package/dist/lib/import-jobs/import-job.coordinator.js +256 -0
  56. package/dist/lib/import-jobs/import-job.coordinator.js.map +1 -0
  57. package/dist/lib/import-jobs/import-job.processor.d.ts +24 -0
  58. package/dist/lib/import-jobs/import-job.processor.d.ts.map +1 -0
  59. package/dist/lib/import-jobs/import-job.processor.js +213 -0
  60. package/dist/lib/import-jobs/import-job.processor.js.map +1 -0
  61. package/dist/lib/import-jobs/import-timeout.processor.d.ts +12 -0
  62. package/dist/lib/import-jobs/import-timeout.processor.d.ts.map +1 -0
  63. package/dist/lib/import-jobs/import-timeout.processor.js +37 -0
  64. package/dist/lib/import-jobs/import-timeout.processor.js.map +1 -0
  65. package/dist/lib/import-jobs/organization-lease.d.ts +39 -0
  66. package/dist/lib/import-jobs/organization-lease.d.ts.map +1 -0
  67. package/dist/lib/import-jobs/organization-lease.js +136 -0
  68. package/dist/lib/import-jobs/organization-lease.js.map +1 -0
  69. package/dist/lib/plugin-context.token.d.ts +4 -0
  70. package/dist/lib/plugin-context.token.d.ts.map +1 -0
  71. package/dist/lib/plugin-context.token.js +2 -0
  72. package/dist/lib/plugin-context.token.js.map +1 -0
  73. package/dist/lib/public-dto.d.ts +6 -0
  74. package/dist/lib/public-dto.d.ts.map +1 -0
  75. package/dist/lib/public-dto.js +33 -0
  76. package/dist/lib/public-dto.js.map +1 -0
  77. package/dist/lib/remote-components/trade-compliance-workbench/app.css +1 -1
  78. package/dist/lib/remote-components/trade-compliance-workbench/app.js +5 -5
  79. package/dist/lib/remote-ui/action-execution.d.ts +26 -0
  80. package/dist/lib/remote-ui/action-execution.d.ts.map +1 -0
  81. package/dist/lib/remote-ui/action-execution.js +85 -0
  82. package/dist/lib/remote-ui/action-execution.js.map +1 -0
  83. package/dist/lib/remote-ui/analytics-contracts.d.ts +3 -2
  84. package/dist/lib/remote-ui/analytics-contracts.d.ts.map +1 -1
  85. package/dist/lib/remote-ui/analytics-contracts.js.map +1 -1
  86. package/dist/lib/remote-ui/app-source.d.ts.map +1 -1
  87. package/dist/lib/remote-ui/app-source.js +462 -176
  88. package/dist/lib/remote-ui/app-source.js.map +1 -1
  89. package/dist/lib/remote-ui/company-logo-lifecycle.d.ts +14 -0
  90. package/dist/lib/remote-ui/company-logo-lifecycle.d.ts.map +1 -0
  91. package/dist/lib/remote-ui/company-logo-lifecycle.js +52 -0
  92. package/dist/lib/remote-ui/company-logo-lifecycle.js.map +1 -0
  93. package/dist/lib/remote-ui/components/data-grid.d.ts +9 -4
  94. package/dist/lib/remote-ui/components/data-grid.d.ts.map +1 -1
  95. package/dist/lib/remote-ui/components/data-grid.js +41 -9
  96. package/dist/lib/remote-ui/components/data-grid.js.map +1 -1
  97. package/dist/lib/remote-ui/components/forms.d.ts +6 -1
  98. package/dist/lib/remote-ui/components/forms.d.ts.map +1 -1
  99. package/dist/lib/remote-ui/components/forms.js +6 -1
  100. package/dist/lib/remote-ui/components/forms.js.map +1 -1
  101. package/dist/lib/remote-ui/components/layout.d.ts.map +1 -1
  102. package/dist/lib/remote-ui/components/layout.js +4 -3
  103. package/dist/lib/remote-ui/components/layout.js.map +1 -1
  104. package/dist/lib/remote-ui/contract-import.d.ts +37 -32
  105. package/dist/lib/remote-ui/contract-import.d.ts.map +1 -1
  106. package/dist/lib/remote-ui/contract-import.js +51 -59
  107. package/dist/lib/remote-ui/contract-import.js.map +1 -1
  108. package/dist/lib/remote-ui/contracts.d.ts +20 -27
  109. package/dist/lib/remote-ui/contracts.d.ts.map +1 -1
  110. package/dist/lib/remote-ui/contracts.js +8 -0
  111. package/dist/lib/remote-ui/contracts.js.map +1 -1
  112. package/dist/lib/remote-ui/customer-contract-contracts.d.ts +5 -11
  113. package/dist/lib/remote-ui/customer-contract-contracts.d.ts.map +1 -1
  114. package/dist/lib/remote-ui/customer-contract-contracts.js.map +1 -1
  115. package/dist/lib/remote-ui/date-time.d.ts +3 -0
  116. package/dist/lib/remote-ui/date-time.d.ts.map +1 -0
  117. package/dist/lib/remote-ui/date-time.js +58 -0
  118. package/dist/lib/remote-ui/date-time.js.map +1 -0
  119. package/dist/lib/remote-ui/model.d.ts +26 -1
  120. package/dist/lib/remote-ui/model.d.ts.map +1 -1
  121. package/dist/lib/remote-ui/model.js +151 -15
  122. package/dist/lib/remote-ui/model.js.map +1 -1
  123. package/dist/lib/remote-ui/pages/analytics.d.ts +6 -0
  124. package/dist/lib/remote-ui/pages/analytics.d.ts.map +1 -1
  125. package/dist/lib/remote-ui/pages/analytics.js +56 -6
  126. package/dist/lib/remote-ui/pages/analytics.js.map +1 -1
  127. package/dist/lib/remote-ui/pages/catalogs.d.ts +35 -14
  128. package/dist/lib/remote-ui/pages/catalogs.d.ts.map +1 -1
  129. package/dist/lib/remote-ui/pages/catalogs.js +139 -89
  130. package/dist/lib/remote-ui/pages/catalogs.js.map +1 -1
  131. package/dist/lib/remote-ui/pages/contracts.d.ts +10 -5
  132. package/dist/lib/remote-ui/pages/contracts.d.ts.map +1 -1
  133. package/dist/lib/remote-ui/pages/contracts.js +29 -26
  134. package/dist/lib/remote-ui/pages/contracts.js.map +1 -1
  135. package/dist/lib/remote-ui/pages/files.d.ts +55 -4
  136. package/dist/lib/remote-ui/pages/files.d.ts.map +1 -1
  137. package/dist/lib/remote-ui/pages/files.js +121 -14
  138. package/dist/lib/remote-ui/pages/files.js.map +1 -1
  139. package/dist/lib/remote-ui/pages/home.js +2 -2
  140. package/dist/lib/remote-ui/pages/home.js.map +1 -1
  141. package/dist/lib/remote-ui/pages/procurement.d.ts +6 -4
  142. package/dist/lib/remote-ui/pages/procurement.d.ts.map +1 -1
  143. package/dist/lib/remote-ui/pages/procurement.js +69 -66
  144. package/dist/lib/remote-ui/pages/procurement.js.map +1 -1
  145. package/dist/lib/remote-ui/pages/sales.d.ts +51 -2
  146. package/dist/lib/remote-ui/pages/sales.d.ts.map +1 -1
  147. package/dist/lib/remote-ui/pages/sales.js +106 -19
  148. package/dist/lib/remote-ui/pages/sales.js.map +1 -1
  149. package/dist/lib/remote-ui/pages/settings.d.ts +29 -14
  150. package/dist/lib/remote-ui/pages/settings.d.ts.map +1 -1
  151. package/dist/lib/remote-ui/pages/settings.js +76 -69
  152. package/dist/lib/remote-ui/pages/settings.js.map +1 -1
  153. package/dist/lib/remote-ui/polling.d.ts +41 -0
  154. package/dist/lib/remote-ui/polling.d.ts.map +1 -0
  155. package/dist/lib/remote-ui/polling.js +148 -0
  156. package/dist/lib/remote-ui/polling.js.map +1 -0
  157. package/dist/lib/remote-ui/procurement-contracts.d.ts +4 -9
  158. package/dist/lib/remote-ui/procurement-contracts.d.ts.map +1 -1
  159. package/dist/lib/remote-ui/sales-contracts.d.ts +5 -3
  160. package/dist/lib/remote-ui/sales-contracts.d.ts.map +1 -1
  161. package/dist/lib/remote-ui/settings-contracts.d.ts +9 -1
  162. package/dist/lib/remote-ui/settings-contracts.d.ts.map +1 -1
  163. package/dist/lib/remote-ui/settings-contracts.js.map +1 -1
  164. package/dist/lib/remote-ui/toast-queue.d.ts +29 -0
  165. package/dist/lib/remote-ui/toast-queue.d.ts.map +1 -0
  166. package/dist/lib/remote-ui/toast-queue.js +71 -0
  167. package/dist/lib/remote-ui/toast-queue.js.map +1 -0
  168. package/dist/lib/sales-file.service.d.ts +13 -35
  169. package/dist/lib/sales-file.service.d.ts.map +1 -1
  170. package/dist/lib/sales-file.service.js +39 -21
  171. package/dist/lib/sales-file.service.js.map +1 -1
  172. package/dist/lib/types.d.ts +22 -3
  173. package/dist/lib/types.d.ts.map +1 -1
  174. package/dist/lib/types.js +5 -0
  175. package/dist/lib/types.js.map +1 -1
  176. package/dist/lib/workbench-action-contract.d.ts +4 -0
  177. package/dist/lib/workbench-action-contract.d.ts.map +1 -0
  178. package/dist/lib/workbench-action-contract.js +43 -0
  179. package/dist/lib/workbench-action-contract.js.map +1 -0
  180. package/dist/lib/workbench-view.provider.d.ts +57 -78
  181. package/dist/lib/workbench-view.provider.d.ts.map +1 -1
  182. package/dist/lib/workbench-view.provider.js +104 -94
  183. package/dist/lib/workbench-view.provider.js.map +1 -1
  184. package/dist/lib/workbench.middleware.d.ts +129 -72
  185. package/dist/lib/workbench.middleware.d.ts.map +1 -1
  186. package/dist/lib/workbench.middleware.js +16 -3
  187. package/dist/lib/workbench.middleware.js.map +1 -1
  188. package/dist/lib/workbench.plugin.d.ts +3 -1
  189. package/dist/lib/workbench.plugin.d.ts.map +1 -1
  190. package/dist/lib/workbench.plugin.js +13 -4
  191. package/dist/lib/workbench.plugin.js.map +1 -1
  192. package/dist/lib/workbench.service.d.ts +136 -40
  193. package/dist/lib/workbench.service.d.ts.map +1 -1
  194. package/dist/lib/workbench.service.js +985 -71
  195. package/dist/lib/workbench.service.js.map +1 -1
  196. 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
- return this.pagedCatalogQuery(ControlledGoodsRecord, ControlledCatalogBatch, scope, query, {
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
- return this.pagedQuery(ImportTask, scope, query, ['sourceFileName', 'documentType', 'status', 'errorMessage'], { documentType: query.parameters?.documentType, status: query.parameters?.status });
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: input.sourceFileId, sourceFileName: input.sourceFileName, importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null, totalCount: input.rows.length, successCount: 0, duplicateCount: 0, invalidCount: 0, importTaskId: input.importTaskId }));
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 productName = String(row.productName ?? '').trim();
363
+ const localized = localizedRows[index];
364
+ const productName = localized.productName;
154
365
  const normalizedHsCode = String(row.hsCode ?? '').replace(/\s/g, '');
155
- if (!productName) {
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: row.controlDescription ?? null, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null }));
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
- }).then(async (batch) => { await this.recalculateProductRisks(scope, 'CATALOG_CHANGED'); return batch; });
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
- return this.pagedCatalogQuery(SanctionedCompanyRecord, SanctionCatalogBatch, scope, query, {
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 execution = { commands: task.executionCommand ? [task.executionCommand] : [], polling: { intervalMs: 2000, timeoutMs: 1200000 } };
489
+ const publicTask = publicImportTaskDto(task);
192
490
  if (!task.resultEntityId)
193
- return { task, ...execution };
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, ...execution };
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, ...execution };
498
+ return { task: publicTask, batch: batch ?? undefined };
201
499
  }
202
- return { task, ...execution };
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: input.sourceFileId, sourceFileName: input.sourceFileName, importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null, totalCount: input.rows.length, successCount: 0, duplicateCount: 0, invalidCount: 0, importTaskId: input.importTaskId }));
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
- return repo.save(repo.create({ ...createdScope(scope), ...input, status: input.status ?? 'QUEUED', queuedAt: new Date(), createdById: scope.userId ?? null }));
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 saveImportTaskExecution(scope, id, command) {
305
- const repo = this.dataSource.getRepository(ImportTask);
306
- const task = await required(repo.findOne({ where: { ...activeScope(scope), id } }), '解析任务不存在');
307
- task.executionCommand = command;
308
- task.commandDispatchedAt = null;
309
- return repo.save(task);
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
- const repo = this.dataSource.getRepository(ImportTask);
313
- const task = await required(repo.findOne({ where: { ...activeScope(scope), id } }), '解析任务不存在');
314
- task.commandDispatchedAt ??= new Date();
315
- return repo.save(task);
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
- const repo = this.dataSource.getRepository(ImportTask);
319
- const task = await required(repo.findOne({ where: { ...activeScope(scope), id } }), '解析任务不存在');
320
- task.status = 'FAILED';
321
- task.errorCode = failure.errorCode;
322
- task.errorMessage = failure.errorMessage;
323
- task.completedAt = new Date();
324
- return repo.save(task);
325
- }
326
- async createRetryImportTask(scope, sourceTaskId) {
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
- return repo.save(repo.create({
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: 'EXTRACTING', queuedAt: new Date(), createdById: scope.userId ?? null
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
- const repo = this.dataSource.getRepository(ImportTask);
362
- const task = await required(repo.findOne({ where: { ...activeScope(scope), id } }), '解析任务不存在');
363
- task.status = 'AI_PARSING';
364
- task.startedAt ??= new Date();
365
- task.completedAt = null;
366
- return repo.save(task);
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 settings(scope) {
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.dataSource.getRepository(MonthlySafeExchangeRate).find({ where: { ...activeScope(scope), deletedAt: IsNull() }, order: { year: 'DESC', month: 'DESC' } })
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
- return { scanned: lines.length, matches: await matchRepo.count({ where: { ...activeScope(scope), deletedAt: IsNull() } }) };
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
- task.status = 'TIMED_OUT';
1773
- task.errorCode = DomainErrorCodes.IMPORT_TIMED_OUT;
1774
- task.errorMessage = '文件解析超过 20 分钟,请重新上传';
1775
- task.completedAt = now;
1776
- task.machineElapsedMs = now.getTime() - startedAt.getTime();
1777
- await repo.save(task);
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) {