@chenchaolong/plugin-trade-compliance-workbench 1.0.0 → 1.0.2

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 (194) hide show
  1. package/README.md +46 -1
  2. package/dist/assets/branding/yuneec-logo.png +0 -0
  3. package/dist/assets/templates/CI.xlsx +0 -0
  4. package/dist/assets/templates/CUSTOMS_DECLARATION.xlsx +0 -0
  5. package/dist/assets/templates/PI.xlsx +0 -0
  6. package/dist/assets/templates/PL.xlsx +0 -0
  7. package/dist/index.d.ts +14 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/lib/adapters/file-storage.d.ts +4 -2
  10. package/dist/lib/adapters/file-storage.d.ts.map +1 -1
  11. package/dist/lib/adapters/file-storage.js +25 -10
  12. package/dist/lib/adapters/file-storage.js.map +1 -1
  13. package/dist/lib/adapters/hsbianma.provider.d.ts +1 -0
  14. package/dist/lib/adapters/hsbianma.provider.d.ts.map +1 -1
  15. package/dist/lib/adapters/hsbianma.provider.js +32 -3
  16. package/dist/lib/adapters/hsbianma.provider.js.map +1 -1
  17. package/dist/lib/adapters/translation.provider.d.ts +3 -0
  18. package/dist/lib/adapters/translation.provider.d.ts.map +1 -1
  19. package/dist/lib/adapters/translation.provider.js +15 -6
  20. package/dist/lib/adapters/translation.provider.js.map +1 -1
  21. package/dist/lib/constants.d.ts +3 -2
  22. package/dist/lib/constants.d.ts.map +1 -1
  23. package/dist/lib/constants.js +3 -2
  24. package/dist/lib/constants.js.map +1 -1
  25. package/dist/lib/demo-data.definitions.d.ts +28 -0
  26. package/dist/lib/demo-data.definitions.d.ts.map +1 -0
  27. package/dist/lib/demo-data.definitions.js +236 -0
  28. package/dist/lib/demo-data.definitions.js.map +1 -0
  29. package/dist/lib/demo-data.entity.d.ts +9 -0
  30. package/dist/lib/demo-data.entity.d.ts.map +1 -0
  31. package/dist/lib/demo-data.entity.js +24 -0
  32. package/dist/lib/demo-data.entity.js.map +1 -0
  33. package/dist/lib/demo-data.ids.d.ts +4 -0
  34. package/dist/lib/demo-data.ids.d.ts.map +1 -0
  35. package/dist/lib/demo-data.ids.js +19 -0
  36. package/dist/lib/demo-data.ids.js.map +1 -0
  37. package/dist/lib/demo-data.service.d.ts +26 -0
  38. package/dist/lib/demo-data.service.d.ts.map +1 -0
  39. package/dist/lib/demo-data.service.js +124 -0
  40. package/dist/lib/demo-data.service.js.map +1 -0
  41. package/dist/lib/domain/default-settings.d.ts +1 -0
  42. package/dist/lib/domain/default-settings.d.ts.map +1 -1
  43. package/dist/lib/domain/default-settings.js +1 -0
  44. package/dist/lib/domain/default-settings.js.map +1 -1
  45. package/dist/lib/domain/risk-notice.d.ts +59 -0
  46. package/dist/lib/domain/risk-notice.d.ts.map +1 -0
  47. package/dist/lib/domain/risk-notice.js +30 -0
  48. package/dist/lib/domain/risk-notice.js.map +1 -0
  49. package/dist/lib/entities/catalog.entity.d.ts.map +1 -1
  50. package/dist/lib/entities/catalog.entity.js +4 -2
  51. package/dist/lib/entities/catalog.entity.js.map +1 -1
  52. package/dist/lib/entities/customer.entity.d.ts +9 -2
  53. package/dist/lib/entities/customer.entity.d.ts.map +1 -1
  54. package/dist/lib/entities/customer.entity.js +29 -1
  55. package/dist/lib/entities/customer.entity.js.map +1 -1
  56. package/dist/lib/entities/index.d.ts +2 -2
  57. package/dist/lib/entities/index.d.ts.map +1 -1
  58. package/dist/lib/entities/index.js +2 -2
  59. package/dist/lib/entities/index.js.map +1 -1
  60. package/dist/lib/entities/procurement.entity.d.ts +6 -4
  61. package/dist/lib/entities/procurement.entity.d.ts.map +1 -1
  62. package/dist/lib/entities/procurement.entity.js +9 -1
  63. package/dist/lib/entities/procurement.entity.js.map +1 -1
  64. package/dist/lib/entities/settings.entity.d.ts +12 -2
  65. package/dist/lib/entities/settings.entity.d.ts.map +1 -1
  66. package/dist/lib/entities/settings.entity.js +40 -0
  67. package/dist/lib/entities/settings.entity.js.map +1 -1
  68. package/dist/lib/entities/shared.entity.d.ts +10 -5
  69. package/dist/lib/entities/shared.entity.d.ts.map +1 -1
  70. package/dist/lib/entities/shared.entity.js +22 -1
  71. package/dist/lib/entities/shared.entity.js.map +1 -1
  72. package/dist/lib/remote-components/trade-compliance-workbench/app.css +1 -0
  73. package/dist/lib/remote-components/trade-compliance-workbench/app.js +63 -277
  74. package/dist/lib/remote-ui/analytics-contracts.d.ts +106 -0
  75. package/dist/lib/remote-ui/analytics-contracts.d.ts.map +1 -0
  76. package/dist/lib/remote-ui/analytics-contracts.js +4 -0
  77. package/dist/lib/remote-ui/analytics-contracts.js.map +1 -0
  78. package/dist/lib/remote-ui/app-source.d.ts +2 -0
  79. package/dist/lib/remote-ui/app-source.d.ts.map +1 -0
  80. package/dist/lib/remote-ui/app-source.js +753 -0
  81. package/dist/lib/remote-ui/app-source.js.map +1 -0
  82. package/dist/lib/remote-ui/bridge.d.ts +36 -0
  83. package/dist/lib/remote-ui/bridge.d.ts.map +1 -0
  84. package/dist/lib/remote-ui/bridge.js +159 -0
  85. package/dist/lib/remote-ui/bridge.js.map +1 -0
  86. package/dist/lib/remote-ui/components/data-grid.d.ts +34 -0
  87. package/dist/lib/remote-ui/components/data-grid.d.ts.map +1 -0
  88. package/dist/lib/remote-ui/components/data-grid.js +59 -0
  89. package/dist/lib/remote-ui/components/data-grid.js.map +1 -0
  90. package/dist/lib/remote-ui/components/forms.d.ts +63 -0
  91. package/dist/lib/remote-ui/components/forms.d.ts.map +1 -0
  92. package/dist/lib/remote-ui/components/forms.js +117 -0
  93. package/dist/lib/remote-ui/components/forms.js.map +1 -0
  94. package/dist/lib/remote-ui/components/layout.d.ts +34 -0
  95. package/dist/lib/remote-ui/components/layout.d.ts.map +1 -0
  96. package/dist/lib/remote-ui/components/layout.js +89 -0
  97. package/dist/lib/remote-ui/components/layout.js.map +1 -0
  98. package/dist/lib/remote-ui/contract-import.d.ts +82 -0
  99. package/dist/lib/remote-ui/contract-import.d.ts.map +1 -0
  100. package/dist/lib/remote-ui/contract-import.js +102 -0
  101. package/dist/lib/remote-ui/contract-import.js.map +1 -0
  102. package/dist/lib/remote-ui/contracts.d.ts +257 -0
  103. package/dist/lib/remote-ui/contracts.d.ts.map +1 -0
  104. package/dist/lib/remote-ui/contracts.js +12 -0
  105. package/dist/lib/remote-ui/contracts.js.map +1 -0
  106. package/dist/lib/remote-ui/customer-contract-contracts.d.ts +227 -0
  107. package/dist/lib/remote-ui/customer-contract-contracts.d.ts.map +1 -0
  108. package/dist/lib/remote-ui/customer-contract-contracts.js +10 -0
  109. package/dist/lib/remote-ui/customer-contract-contracts.js.map +1 -0
  110. package/dist/lib/remote-ui/model.d.ts +31 -0
  111. package/dist/lib/remote-ui/model.d.ts.map +1 -0
  112. package/dist/lib/remote-ui/model.js +248 -0
  113. package/dist/lib/remote-ui/model.js.map +1 -0
  114. package/dist/lib/remote-ui/pages/analytics.d.ts +12 -0
  115. package/dist/lib/remote-ui/pages/analytics.d.ts.map +1 -0
  116. package/dist/lib/remote-ui/pages/analytics.js +123 -0
  117. package/dist/lib/remote-ui/pages/analytics.js.map +1 -0
  118. package/dist/lib/remote-ui/pages/catalogs.d.ts +225 -0
  119. package/dist/lib/remote-ui/pages/catalogs.d.ts.map +1 -0
  120. package/dist/lib/remote-ui/pages/catalogs.js +572 -0
  121. package/dist/lib/remote-ui/pages/catalogs.js.map +1 -0
  122. package/dist/lib/remote-ui/pages/contracts.d.ts +80 -0
  123. package/dist/lib/remote-ui/pages/contracts.d.ts.map +1 -0
  124. package/dist/lib/remote-ui/pages/contracts.js +396 -0
  125. package/dist/lib/remote-ui/pages/contracts.js.map +1 -0
  126. package/dist/lib/remote-ui/pages/files.d.ts +31 -0
  127. package/dist/lib/remote-ui/pages/files.d.ts.map +1 -0
  128. package/dist/lib/remote-ui/pages/files.js +64 -0
  129. package/dist/lib/remote-ui/pages/files.js.map +1 -0
  130. package/dist/lib/remote-ui/pages/home.d.ts +71 -0
  131. package/dist/lib/remote-ui/pages/home.d.ts.map +1 -0
  132. package/dist/lib/remote-ui/pages/home.js +136 -0
  133. package/dist/lib/remote-ui/pages/home.js.map +1 -0
  134. package/dist/lib/remote-ui/pages/procurement.d.ts +36 -0
  135. package/dist/lib/remote-ui/pages/procurement.d.ts.map +1 -0
  136. package/dist/lib/remote-ui/pages/procurement.js +370 -0
  137. package/dist/lib/remote-ui/pages/procurement.js.map +1 -0
  138. package/dist/lib/remote-ui/pages/sales.d.ts +12 -0
  139. package/dist/lib/remote-ui/pages/sales.d.ts.map +1 -0
  140. package/dist/lib/remote-ui/pages/sales.js +106 -0
  141. package/dist/lib/remote-ui/pages/sales.js.map +1 -0
  142. package/dist/lib/remote-ui/pages/settings.d.ts +34 -0
  143. package/dist/lib/remote-ui/pages/settings.d.ts.map +1 -0
  144. package/dist/lib/remote-ui/pages/settings.js +259 -0
  145. package/dist/lib/remote-ui/pages/settings.js.map +1 -0
  146. package/dist/lib/remote-ui/procurement-contracts.d.ts +177 -0
  147. package/dist/lib/remote-ui/procurement-contracts.d.ts.map +1 -0
  148. package/dist/lib/remote-ui/procurement-contracts.js +2 -0
  149. package/dist/lib/remote-ui/procurement-contracts.js.map +1 -0
  150. package/dist/lib/remote-ui/sales-contracts.d.ts +133 -0
  151. package/dist/lib/remote-ui/sales-contracts.d.ts.map +1 -0
  152. package/dist/lib/remote-ui/sales-contracts.js +2 -0
  153. package/dist/lib/remote-ui/sales-contracts.js.map +1 -0
  154. package/dist/lib/remote-ui/settings-contracts.d.ts +114 -0
  155. package/dist/lib/remote-ui/settings-contracts.d.ts.map +1 -0
  156. package/dist/lib/remote-ui/settings-contracts.js +6 -0
  157. package/dist/lib/remote-ui/settings-contracts.js.map +1 -0
  158. package/dist/lib/remote-ui/upload-policy.d.ts +18 -0
  159. package/dist/lib/remote-ui/upload-policy.d.ts.map +1 -0
  160. package/dist/lib/remote-ui/upload-policy.js +56 -0
  161. package/dist/lib/remote-ui/upload-policy.js.map +1 -0
  162. package/dist/lib/runtime-validation.d.ts +61 -0
  163. package/dist/lib/runtime-validation.d.ts.map +1 -0
  164. package/dist/lib/runtime-validation.js +243 -0
  165. package/dist/lib/runtime-validation.js.map +1 -0
  166. package/dist/lib/sales-file.service.d.ts +54 -6
  167. package/dist/lib/sales-file.service.d.ts.map +1 -1
  168. package/dist/lib/sales-file.service.js +278 -33
  169. package/dist/lib/sales-file.service.js.map +1 -1
  170. package/dist/lib/trade-compliance.config.d.ts +32 -0
  171. package/dist/lib/trade-compliance.config.d.ts.map +1 -1
  172. package/dist/lib/trade-compliance.config.js +16 -4
  173. package/dist/lib/trade-compliance.config.js.map +1 -1
  174. package/dist/lib/types.d.ts +253 -1
  175. package/dist/lib/types.d.ts.map +1 -1
  176. package/dist/lib/types.js +24 -0
  177. package/dist/lib/types.js.map +1 -1
  178. package/dist/lib/workbench-view.provider.d.ts +675 -38
  179. package/dist/lib/workbench-view.provider.d.ts.map +1 -1
  180. package/dist/lib/workbench-view.provider.js +411 -40
  181. package/dist/lib/workbench-view.provider.js.map +1 -1
  182. package/dist/lib/workbench.middleware.d.ts +70 -19
  183. package/dist/lib/workbench.middleware.d.ts.map +1 -1
  184. package/dist/lib/workbench.middleware.js +5 -1
  185. package/dist/lib/workbench.middleware.js.map +1 -1
  186. package/dist/lib/workbench.plugin.d.ts.map +1 -1
  187. package/dist/lib/workbench.plugin.js +6 -4
  188. package/dist/lib/workbench.plugin.js.map +1 -1
  189. package/dist/lib/workbench.service.d.ts +629 -48
  190. package/dist/lib/workbench.service.d.ts.map +1 -1
  191. package/dist/lib/workbench.service.js +1818 -300
  192. package/dist/lib/workbench.service.js.map +1 -1
  193. package/package.json +15 -4
  194. package/scripts/seed-demo-data.mjs +138 -0
@@ -1,44 +1,92 @@
1
1
  import { __decorate, __metadata, __param } from "tslib";
2
- import { Injectable } from '@nestjs/common';
2
+ import { Injectable, Optional } from '@nestjs/common';
3
3
  import { InjectDataSource } from '@nestjs/typeorm';
4
4
  import { Brackets, DataSource, In, IsNull } from 'typeorm';
5
5
  import { Decimal } from 'decimal.js';
6
- import { BankAccountSettings, CompanySanctionMatch, CompanySettings, ControlledCatalogBatch, ControlledCatalogImportIssue, ControlledGoodsRecord, Customer, CustomerContract, CustomerContractAnalysisRun, CustomerContractClause, CustomerContractClauseAnalysis, DerivedSalesOrderManualData, GeneratedSalesFile, ImportTask, MonthlySafeExchangeRate, ProductComplianceMatch, ProfitRuleSettings, PurchaseOrder, PurchaseOrderLine, RiskNoticeSettings, SanctionCatalogBatch, SanctionCatalogImportIssue, SanctionedCompanyRecord, SettingsHistory, Supplier } from './entities/index.js';
7
- import { ClauseTypes, DomainError, DomainErrorCodes } from './types.js';
6
+ import ExcelJS from 'exceljs';
7
+ import { createWriteStream } from 'node:fs';
8
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { finished } from 'node:stream/promises';
12
+ import { BankAccountSettings, CompanySanctionMatch, CompanySettings, ControlledCatalogBatch, ControlledCatalogImportIssue, ControlledGoodsRecord, Customer, CustomerContract, CustomerContractAnalysisRun, CustomerContractClause, CustomerContractClauseAnalysis, DerivedSalesOrderManualData, GeneratedSalesFile, ImportTask, ManagedAsset, MonthlySafeExchangeRate, ProductComplianceMatch, ProfitRuleSettings, PurchaseOrder, PurchaseOrderLine, RiskNoticeSettings, SanctionCatalogBatch, SanctionCatalogImportIssue, SanctionedCompanyRecord, SettingsHistory, Supplier } from './entities/index.js';
13
+ import { CatalogTypes, ClauseTypes, CustomerProfitRateStatuses, DomainError, DomainErrorCodes, RefundValidationMethods, SalesPaymentStatuses, SanctionHitStatuses } from './types.js';
8
14
  import { deriveSalesLine, assertReceivedAmount } from './domain/finance.js';
9
15
  import { parseCustomerContractNo, parsePurchaseContractNo } from './domain/contract-number.js';
10
16
  import { isProductNameMatch, normalizeExactName, normalizeForProductMatch, normalizeHsCode, normalizeSanctionSource } from './domain/normalization.js';
11
17
  import { importTaskTimedOut, nextSettingsRevision, normalizeOptionalDecimalInput } from './domain/workflow.js';
12
18
  import { renderImportIssuesWorkbook } from './domain/import-issues.js';
13
- import { DEFAULT_BANKS, DEFAULT_COMPANY, DEFAULT_PROFIT_RULE } from './domain/default-settings.js';
14
- const DEFAULT_RISK_NOTICE = '<h3>操作前请确认</h3><ul><li><strong>删除、目录覆盖和合同审核</strong>会影响当前业务数据,请核对对象后再操作。</li><li>采购合同和客户合同重复时不会覆盖,必须先删除旧记录再重新上传。</li><li>销售文件生成前请确认利润率、换汇成本和银行信息。</li></ul><h3>推荐业务顺序</h3><p>维护风险目录,上传采购合同和客户合同,完成审核后处理销售合规与销售文件。</p>';
19
+ import { BUILTIN_COMPANY_LOGO_STORAGE_ID, DEFAULT_BANKS, DEFAULT_COMPANY, DEFAULT_PROFIT_RULE } from './domain/default-settings.js';
20
+ import { DEFAULT_RISK_NOTICE } from './domain/risk-notice.js';
21
+ import { TradeComplianceFileStorage } from './adapters/file-storage.js';
22
+ import { parseBankAccountType, parseExchangeRateInput, parseExpectedRevision, sanitizeRiskNoticeContent, validateBankSettingsInput, validateProfitSettingsInput, validatePurchaseLineDiscriminators, validateTaxRates } from './runtime-validation.js';
23
+ const DEFAULT_IMPORT_ISSUE_EXPORT_MAX_ROWS = 50_000;
24
+ const HARD_IMPORT_ISSUE_EXPORT_MAX_ROWS = 100_000;
25
+ const CUSTOMER_REANALYSIS_TIMEOUT_MS = 20 * 60 * 1000;
26
+ const ANALYTICS_EXPORT_MAX_ROWS = 100_000;
27
+ const CATALOG_RISK_RECOMMENDATIONS = {
28
+ DUAL_USE: '建议核验军民两用物项许可要求,并在出口前完成合规审批',
29
+ OVERSEAS_SANCTION: '建议核验海外制裁限制及交易对手风险,并在交易前完成合规审批'
30
+ };
31
+ export { DEFAULT_RISK_NOTICE };
32
+ export async function writeWorkbookFile(filePath, populate, openOutput = createWriteStream) {
33
+ const output = openOutput(filePath);
34
+ const closed = finished(output, { cleanup: true });
35
+ void closed.catch(() => undefined);
36
+ try {
37
+ const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({ stream: output, useStyles: true });
38
+ await populate(workbook);
39
+ await workbook.commit();
40
+ await closed;
41
+ }
42
+ catch (error) {
43
+ if (!output.destroyed)
44
+ output.destroy();
45
+ await closed.catch(() => undefined);
46
+ throw error;
47
+ }
48
+ }
15
49
  let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
16
- constructor(dataSource) {
50
+ constructor(dataSource, storage) {
17
51
  this.dataSource = dataSource;
52
+ this.storage = storage;
18
53
  }
19
54
  async bootstrapSettings(scope) {
20
55
  const riskRepo = this.dataSource.getRepository(RiskNoticeSettings);
21
- let risk = await riskRepo.findOne({ where: activeScope(scope) });
22
- if (!risk)
23
- risk = await riskRepo.save(riskRepo.create({ ...auditScope(scope), content: DEFAULT_RISK_NOTICE, revision: 1 }));
24
56
  const profitRepo = this.dataSource.getRepository(ProfitRuleSettings);
25
- let profit = await profitRepo.findOne({ where: activeScope(scope) });
26
- if (!profit)
27
- profit = await profitRepo.save(profitRepo.create({ ...auditScope(scope), ...DEFAULT_PROFIT_RULE, revision: 1 }));
28
57
  const companyRepo = this.dataSource.getRepository(CompanySettings);
29
- let company = await companyRepo.findOne({ where: activeScope(scope) });
30
- if (!company)
31
- company = await companyRepo.save(companyRepo.create({ ...auditScope(scope), ...DEFAULT_COMPANY, revision: 1 }));
32
58
  const bankRepo = this.dataSource.getRepository(BankAccountSettings);
33
- const banks = [];
34
- for (const defaults of DEFAULT_BANKS) {
35
- let bank = await bankRepo.findOne({ where: { ...activeScope(scope), bankType: defaults.bankType } });
36
- if (!bank)
37
- bank = await bankRepo.save(bankRepo.create({ ...auditScope(scope), ...defaults, revision: 1 }));
38
- banks.push(bank);
39
- }
59
+ const builtinLogo = await this.ensureBuiltinCompanyLogo(scope);
60
+ const [risk, profit, company, ...banks] = await Promise.all([
61
+ this.ensureSettingsDefault(riskRepo, activeScope(scope), { ...auditScope(scope), content: DEFAULT_RISK_NOTICE, revision: 1 }),
62
+ this.ensureSettingsDefault(profitRepo, activeScope(scope), { ...auditScope(scope), ...DEFAULT_PROFIT_RULE, revision: 1 }),
63
+ this.ensureSettingsDefault(companyRepo, activeScope(scope), { ...auditScope(scope), ...DEFAULT_COMPANY, logoFileId: builtinLogo.id, revision: 1 }),
64
+ ...DEFAULT_BANKS.map(defaults => this.ensureSettingsDefault(bankRepo, { ...activeScope(scope), bankType: defaults.bankType }, { ...auditScope(scope), ...defaults, revision: 1 }))
65
+ ]);
40
66
  return { risk, profit, company, banks };
41
67
  }
68
+ async ensureBuiltinCompanyLogo(scope) {
69
+ const repo = this.dataSource.getRepository(ManagedAsset);
70
+ const where = { ...activeScope(scope), assetType: 'COMPANY_LOGO', storageType: 'BUILTIN', storageFileId: BUILTIN_COMPANY_LOGO_STORAGE_ID, deletedAt: IsNull() };
71
+ const existing = await repo.findOne({ where });
72
+ if (existing)
73
+ return existing;
74
+ await repo.createQueryBuilder().insert().values(repo.create({ ...auditScope(scope), assetType: 'COMPANY_LOGO', status: 'ACTIVE', storageType: 'BUILTIN', storageFileId: BUILTIN_COMPANY_LOGO_STORAGE_ID, ownerId: null, originalFileName: 'yuneec-logo.png', deletedAt: null, deletedById: null, deleteOperation: null })).orIgnore().execute();
75
+ const created = await repo.findOne({ where });
76
+ if (!created)
77
+ throw new DomainError(DomainErrorCodes.NOT_FOUND, '系统默认 Logo 资产初始化失败');
78
+ return created;
79
+ }
80
+ async ensureSettingsDefault(repo, where, values) {
81
+ const existing = await repo.findOne({ where });
82
+ if (existing)
83
+ return existing;
84
+ await repo.createQueryBuilder().insert().values(repo.create(values)).orIgnore().execute();
85
+ const row = await repo.findOne({ where });
86
+ if (!row)
87
+ throw new DomainError(DomainErrorCodes.NOT_FOUND, '系统默认设置初始化失败');
88
+ return row;
89
+ }
42
90
  async dashboard(scope) {
43
91
  const [{ risk }, controlledRiskCount, sanctionRiskCount, unlinkedPurchaseCount, unlinkedCustomerCount] = await Promise.all([
44
92
  this.bootstrapSettings(scope),
@@ -50,9 +98,10 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
50
98
  return { riskNotice: risk, controlledRiskCount, sanctionRiskCount, unlinkedPurchaseCount, unlinkedCustomerCount };
51
99
  }
52
100
  async listControlledGoods(scope, query = {}) {
53
- return this.pagedQuery(ControlledGoodsRecord, scope, query, ['productName', 'hsCode', 'controlDescription'], {
54
- catalogType: query.parameters?.catalogType,
55
- sourceFileName: query.parameters?.sourceFileName
101
+ return this.pagedCatalogQuery(ControlledGoodsRecord, ControlledCatalogBatch, scope, query, {
102
+ exact: { catalogType: query.parameters?.catalogType },
103
+ fuzzy: { productName: query.parameters?.productName, hsCode: query.parameters?.hsCode },
104
+ batchSourceFileName: query.parameters?.sourceFileName
56
105
  });
57
106
  }
58
107
  async listImportTasks(scope, query = {}) {
@@ -88,7 +137,9 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
88
137
  const recordRepo = manager.getRepository(ControlledGoodsRecord);
89
138
  const issueRepo = manager.getRepository(ControlledCatalogImportIssue);
90
139
  const taskRepo = manager.getRepository(ImportTask);
91
- const task = await required(taskRepo.findOne({ where: { ...activeScope(scope), id: input.importTaskId } }), '解析任务不存在');
140
+ const task = await required(taskRepo.findOne({ where: { ...activeScope(scope), id: input.importTaskId }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
141
+ if (task.status === 'SUCCEEDED' && task.resultEntityId)
142
+ return required(batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId } }), '已完成导入批次不存在');
92
143
  task.status = 'PERSISTING';
93
144
  await taskRepo.save(task);
94
145
  const now = new Date();
@@ -129,13 +180,52 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
129
180
  }).then(async (batch) => { await this.recalculateProductRisks(scope, 'CATALOG_CHANGED'); return batch; });
130
181
  }
131
182
  async listSanctionedCompanies(scope, query = {}) {
132
- return this.pagedQuery(SanctionedCompanyRecord, scope, query, ['companyName', 'countryOrRegion', 'sanctionListSource', 'sanctionReason'], { sanctionListSource: query.parameters?.sanctionListSource });
183
+ return this.pagedCatalogQuery(SanctionedCompanyRecord, SanctionCatalogBatch, scope, query, {
184
+ fuzzy: {
185
+ companyName: query.parameters?.companyName,
186
+ countryOrRegion: query.parameters?.countryOrRegion,
187
+ sanctionListSource: query.parameters?.sanctionListSource
188
+ },
189
+ range: { effectiveDate: query.parameters?.effectiveDate },
190
+ batchSourceFileName: query.parameters?.sourceFileName
191
+ });
192
+ }
193
+ async getImportTaskResult(scope, taskId) {
194
+ await this.expireStaleImportTasks(scope);
195
+ const task = await required(this.dataSource.getRepository(ImportTask).findOne({ where: { ...activeScope(scope), id: taskId } }), '解析任务不存在');
196
+ const execution = { commands: task.executionCommand ? [task.executionCommand] : [], polling: { intervalMs: 2000, timeoutMs: 1200000 } };
197
+ if (!task.resultEntityId)
198
+ return { task, ...execution };
199
+ if (task.documentType === 'CONTROLLED_CATALOG') {
200
+ const batch = await this.dataSource.getRepository(ControlledCatalogBatch).findOne({ where: { ...activeScope(scope), id: task.resultEntityId } });
201
+ return { task, batch: batch ?? undefined, ...execution };
202
+ }
203
+ if (task.documentType === 'SANCTION_CATALOG') {
204
+ const batch = await this.dataSource.getRepository(SanctionCatalogBatch).findOne({ where: { ...activeScope(scope), id: task.resultEntityId } });
205
+ return { task, batch: batch ?? undefined, ...execution };
206
+ }
207
+ return { task, ...execution };
208
+ }
209
+ async listImportIssues(scope, query) {
210
+ const page = Math.max(1, Number(query.page ?? 1));
211
+ const pageSize = Math.min(200, Math.max(1, Number(query.pageSize ?? 20)));
212
+ const entity = query.documentType === 'CONTROLLED_CATALOG' ? ControlledCatalogImportIssue : SanctionCatalogImportIssue;
213
+ const [items, total] = await this.dataSource.getRepository(entity).findAndCount({
214
+ where: { ...activeScope(scope), batchId: query.batchId, issueType: query.issueType },
215
+ order: { sourceSequence: 'ASC' }, skip: (page - 1) * pageSize, take: pageSize
216
+ });
217
+ return { items, total, page, pageSize };
133
218
  }
134
219
  async downloadImportIssues(scope, documentType, batchId) {
135
220
  const batchEntity = documentType === 'CONTROLLED_CATALOG' ? ControlledCatalogBatch : SanctionCatalogBatch;
136
221
  const issueEntity = documentType === 'CONTROLLED_CATALOG' ? ControlledCatalogImportIssue : SanctionCatalogImportIssue;
137
222
  const batch = await required(this.dataSource.getRepository(batchEntity).findOne({ where: { ...activeScope(scope), id: batchId } }), '目录导入批次不存在');
138
- const issues = await this.dataSource.getRepository(issueEntity).find({ where: { ...activeScope(scope), batchId }, order: { sourceSequence: 'ASC' } });
223
+ const issueRepo = this.dataSource.getRepository(issueEntity);
224
+ const exportLimit = importIssueExportLimit();
225
+ const total = await issueRepo.count({ where: { ...activeScope(scope), batchId } });
226
+ if (total > exportLimit)
227
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, `导入问题共 ${total} 条,超过单次导出上限 ${exportLimit} 条,请使用在线分页筛选或分批处理。`);
228
+ const issues = await issueRepo.find({ where: { ...activeScope(scope), batchId }, order: { sourceSequence: 'ASC' }, take: exportLimit });
139
229
  return { fileName: `${batch.sourceFileName}-导入问题明细.xlsx`, buffer: await renderImportIssuesWorkbook(issues) };
140
230
  }
141
231
  async saveSanctionedCompany(scope, input) {
@@ -167,7 +257,9 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
167
257
  const recordRepo = manager.getRepository(SanctionedCompanyRecord);
168
258
  const issueRepo = manager.getRepository(SanctionCatalogImportIssue);
169
259
  const taskRepo = manager.getRepository(ImportTask);
170
- const task = await required(taskRepo.findOne({ where: { ...activeScope(scope), id: input.importTaskId } }), '解析任务不存在');
260
+ const task = await required(taskRepo.findOne({ where: { ...activeScope(scope), id: input.importTaskId }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
261
+ if (task.status === 'SUCCEEDED' && task.resultEntityId)
262
+ return required(batchRepo.findOne({ where: { ...activeScope(scope), id: task.resultEntityId } }), '已完成导入批次不存在');
171
263
  task.status = 'PERSISTING';
172
264
  await taskRepo.save(task);
173
265
  const now = new Date();
@@ -214,15 +306,61 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
214
306
  const repo = this.dataSource.getRepository(ImportTask);
215
307
  return repo.save(repo.create({ ...createdScope(scope), ...input, status: input.status ?? 'QUEUED', queuedAt: new Date(), createdById: scope.userId ?? null }));
216
308
  }
217
- async updateImportTaskDraft(scope, id, payload, version = 'v1') {
309
+ async saveImportTaskExecution(scope, id, command) {
310
+ const repo = this.dataSource.getRepository(ImportTask);
311
+ const task = await required(repo.findOne({ where: { ...activeScope(scope), id } }), '解析任务不存在');
312
+ task.executionCommand = command;
313
+ task.commandDispatchedAt = null;
314
+ return repo.save(task);
315
+ }
316
+ async markImportCommandDispatched(scope, id) {
317
+ const repo = this.dataSource.getRepository(ImportTask);
318
+ const task = await required(repo.findOne({ where: { ...activeScope(scope), id } }), '解析任务不存在');
319
+ task.commandDispatchedAt ??= new Date();
320
+ return repo.save(task);
321
+ }
322
+ async markImportTaskFailed(scope, id, failure) {
218
323
  const repo = this.dataSource.getRepository(ImportTask);
219
324
  const task = await required(repo.findOne({ where: { ...activeScope(scope), id } }), '解析任务不存在');
220
- task.resultPayload = payload;
221
- task.resultPayloadVersion = version;
222
- task.status = 'AWAITING_REVIEW';
325
+ task.status = 'FAILED';
326
+ task.errorCode = failure.errorCode;
327
+ task.errorMessage = failure.errorMessage;
223
328
  task.completedAt = new Date();
224
329
  return repo.save(task);
225
330
  }
331
+ async createRetryImportTask(scope, sourceTaskId) {
332
+ return this.dataSource.transaction(async (manager) => {
333
+ const repo = manager.getRepository(ImportTask);
334
+ const source = await required(repo.findOne({ where: { ...activeScope(scope), id: sourceTaskId }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
335
+ if (source.status !== 'FAILED' && source.status !== 'TIMED_OUT')
336
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '仅失败或超时任务可以重新解析');
337
+ if (source.documentType !== 'CONTROLLED_CATALOG' && source.documentType !== 'SANCTION_CATALOG')
338
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '仅目录任务支持重新解析');
339
+ if (!source.executionInput)
340
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '原任务缺少可恢复执行参数');
341
+ const existing = await repo.findOne({ where: { ...activeScope(scope), retryOfTaskId: source.id } });
342
+ if (existing)
343
+ return existing;
344
+ return repo.save(repo.create({
345
+ ...createdScope(scope), documentType: source.documentType, sourceFileId: source.sourceFileId,
346
+ sourceFileName: source.sourceFileName, sourceFileFormat: source.sourceFileFormat,
347
+ sourcePlatformFileId: source.sourcePlatformFileId, executionInput: source.executionInput,
348
+ retryOfTaskId: source.id, status: 'EXTRACTING', queuedAt: new Date(), createdById: scope.userId ?? null
349
+ }));
350
+ });
351
+ }
352
+ async updateImportTaskDraft(scope, id, payload, version = 'v1') {
353
+ return this.dataSource.transaction(async (manager) => {
354
+ const repo = manager.getRepository(ImportTask);
355
+ const task = await required(repo.findOne({ where: { ...activeScope(scope), id }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
356
+ await invalidateCustomerContractReanalysis(manager, scope, { reviewSource: 'IMPORT_DRAFT', task });
357
+ task.resultPayload = payload;
358
+ task.resultPayloadVersion = version;
359
+ task.status = 'AWAITING_REVIEW';
360
+ task.completedAt = new Date();
361
+ return repo.save(task);
362
+ });
363
+ }
226
364
  async markImportTaskParsing(scope, id) {
227
365
  const repo = this.dataSource.getRepository(ImportTask);
228
366
  const task = await required(repo.findOne({ where: { ...activeScope(scope), id } }), '解析任务不存在');
@@ -232,45 +370,132 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
232
370
  return repo.save(task);
233
371
  }
234
372
  async savePurchaseDraft(scope, importTaskId, draft) {
373
+ assertPurchaseDraft(draft);
374
+ const confirmedById = requiredId(scope.userId, '当前用户标识');
375
+ const confirmedAt = new Date();
235
376
  const order = await this.dataSource.transaction(async (manager) => {
377
+ const taskRepo = manager.getRepository(ImportTask);
378
+ const task = await required(taskRepo.findOne({ where: { ...activeScope(scope), id: importTaskId }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
379
+ if (task.documentType !== 'PURCHASE_CONTRACT' || task.status !== 'AWAITING_REVIEW' || task.resultEntityId != null)
380
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '仅待审核且未入库的采购合同任务可以保存审核');
236
381
  const orderRepo = manager.getRepository(PurchaseOrder);
237
382
  const normalizedContractNo = parsePurchaseContractNo(draft.order.contractNo).normalized;
238
383
  const duplicate = await orderRepo.findOne({ where: { ...activeScope(scope), normalizedContractNo, deletedAt: IsNull() } });
239
384
  if (duplicate)
240
385
  throw new DomainError(DomainErrorCodes.DUPLICATE_CONTRACT, '采购合同已存在,如需重新上传请先删除旧记录', { id: duplicate.id, contractNo: draft.order.contractNo });
241
386
  const supplier = await this.upsertSupplier(manager, scope, draft.supplier);
242
- const task = await required(manager.getRepository(ImportTask).findOne({ where: { ...activeScope(scope), id: importTaskId } }), '解析任务不存在');
243
387
  task.status = 'PERSISTING';
244
- await manager.getRepository(ImportTask).save(task);
245
- const entity = await orderRepo.save(orderRepo.create({ ...auditScope(scope), supplierId: supplier.id, contractNo: draft.order.contractNo.trim(), normalizedContractNo, orderDate: draft.order.orderDate ?? null, contractAmountCny: draft.order.contractAmountCny, paidAmountCny: draft.order.paidAmountCny ?? '0', refundedAmountCny: draft.order.refundedAmountCny ?? '0', sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName, importTaskId: task.id, amountMismatchConfirmedAt: draft.order.amountMismatchConfirmedAt ?? null, amountMismatchConfirmedById: draft.order.amountMismatchConfirmedById ?? null }));
388
+ await taskRepo.save(task);
389
+ const entity = await orderRepo.save(orderRepo.create({ ...auditScope(scope), supplierId: supplier.id, contractNo: draft.order.contractNo.trim(), normalizedContractNo, orderDate: draft.order.orderDate ?? null, contractAmountCny: draft.order.contractAmountCny, paidAmountCny: draft.order.paidAmountCny ?? '0', refundedAmountCny: draft.order.refundedAmountCny ?? '0', sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName, importTaskId: task.id, amountMismatchConfirmedAt: draft.order.amountMismatch === true ? confirmedAt : null, amountMismatchConfirmedById: draft.order.amountMismatch === true ? confirmedById : null }));
246
390
  const lineRepo = manager.getRepository(PurchaseOrderLine);
247
391
  for (let index = 0; index < draft.lines.length; index += 1) {
248
392
  const line = draft.lines[index];
249
393
  if (new Decimal(line.quantity).lte(0))
250
394
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '采购商品数量必须大于 0', { lineNumber: index + 1 });
251
- await lineRepo.save(lineRepo.create({ ...auditScope(scope), purchaseOrderId: entity.id, lineNumber: line.lineNumber ?? index + 1, sourceType: line.sourceType ?? 'CONTRACT_EXTRACTED', productName: line.productName.trim(), normalizedProductName: normalizeForProductMatch(line.productName), englishName: line.englishName ?? null, englishNameSource: line.englishNameSource ?? null, productDescription: line.productDescription ?? null, modelSpecification: line.modelSpecification ?? null, unit: line.unit ?? null, quantity: line.quantity, unitPriceTaxIncluded: line.unitPriceTaxIncluded, lineAmountTaxIncluded: line.lineAmountTaxIncluded, vatRate: line.vatRate ?? null, vatRateSource: line.vatRateSource ?? null, hsCode: line.hsCode ?? null, hsCodeStatus: line.hsCodeStatus ?? 'UNCONFIRMED', hsCodeSource: line.hsCodeSource ?? null, hsCodeReferenceSnapshotId: line.hsCodeReferenceSnapshotId ?? null, refundRate: line.refundRate ?? null, refundRateSource: line.refundRateSource ?? null, refundPolicyReference: line.refundPolicyReference ?? null, refundPolicyDate: line.refundPolicyDate ?? null, refundValidationMethod: line.refundValidationMethod ?? null, refundConfirmedAt: line.refundConfirmedAt ?? null, refundConfirmedById: line.refundConfirmedById ?? null, amountMismatchConfirmedAt: line.amountMismatchConfirmedAt ?? null, amountMismatchConfirmedById: line.amountMismatchConfirmedById ?? null, sourceLocation: line.sourceLocation ?? null, sourceContent: line.sourceContent ?? null }));
395
+ await lineRepo.save(lineRepo.create({ ...auditScope(scope), purchaseOrderId: entity.id, lineNumber: line.lineNumber ?? index + 1, sourceType: line.sourceType ?? 'CONTRACT_EXTRACTED', productName: line.productName.trim(), normalizedProductName: normalizeForProductMatch(line.productName), englishName: line.englishName ?? null, englishNameSource: line.englishNameSource ?? null, productDescription: line.productDescription ?? null, modelSpecification: line.modelSpecification ?? null, unit: line.unit ?? null, quantity: line.quantity, unitPriceTaxIncluded: line.unitPriceTaxIncluded, lineAmountTaxIncluded: line.lineAmountTaxIncluded, vatRate: line.vatRate ?? null, vatRateSource: line.vatRateSource ?? null, hsCode: line.hsCode ?? null, hsCodeStatus: line.hsCodeStatus ?? 'UNCONFIRMED', hsCodeSource: line.hsCodeSource ?? null, hsCodeReferenceSnapshotId: line.hsCodeReferenceSnapshotId ?? null, originCountry: line.originCountry ?? null, domesticSourceLocation: line.domesticSourceLocation ?? null, refundRate: line.refundRate ?? null, refundRateSource: line.refundRateSource ?? null, refundPolicyReference: line.refundPolicyReference ?? null, refundPolicyDate: line.refundPolicyDate ?? null, refundValidationMethod: line.refundValidationMethod, refundConfirmedAt: confirmedAt, refundConfirmedById: confirmedById, amountMismatchConfirmedAt: line.amountMismatch === true ? confirmedAt : null, amountMismatchConfirmedById: line.amountMismatch === true ? confirmedById : null, sourceLocation: line.sourceLocation ?? null, sourceContent: line.sourceContent ?? null }));
252
396
  }
253
397
  task.status = 'SUCCEEDED';
254
398
  task.resultEntityId = entity.id;
255
399
  task.reviewSavedAt = new Date();
256
400
  task.completedAt = new Date();
257
- await manager.getRepository(ImportTask).save(task);
401
+ await taskRepo.save(task);
258
402
  return entity;
259
403
  });
260
404
  await this.recalculateProductRisks(scope, 'SOURCE_CHANGED');
261
405
  await this.recalculateCompanyRisks(scope, 'SOURCE_CHANGED');
262
406
  return order;
263
407
  }
264
- async listSuppliers(scope, query = {}) { return this.pagedQuery(Supplier, scope, query, ['code', 'name', 'shortName', 'contactName', 'businessProducts']); }
265
- async listPurchaseOrders(scope, supplierId, query = {}) { return this.pagedQuery(PurchaseOrder, scope, query, ['contractNo', 'sourceFileName'], { supplierId }); }
266
- async listPurchaseLines(scope, purchaseOrderId, query = {}) { return this.pagedQuery(PurchaseOrderLine, scope, query, ['productName', 'productDescription', 'modelSpecification', 'hsCode'], { purchaseOrderId }); }
408
+ async listSuppliers(scope, query = {}) {
409
+ const page = await this.pagedQuery(Supplier, scope, query, ['code', 'name', 'shortName', 'contactName', 'businessProducts'], {}, {
410
+ fuzzy: pick(query.parameters ?? {}, ['code', 'name', 'shortName', 'contactName', 'phone'])
411
+ });
412
+ return { ...page, items: await this.supplierDtos(scope, page.items) };
413
+ }
414
+ async getSupplier(scope, id) {
415
+ const supplier = await required(this.dataSource.getRepository(Supplier).findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() } }), '供应商不存在');
416
+ return (await this.supplierDtos(scope, [supplier]))[0];
417
+ }
418
+ async listPurchaseOrders(scope, supplierId, query = {}) {
419
+ const parameters = query.parameters ?? {};
420
+ const page = await this.pagedQuery(PurchaseOrder, scope, query, ['contractNo', 'sourceFileName'], { supplierId }, {
421
+ fuzzy: pick(parameters, ['contractNo']),
422
+ range: pick(parameters, ['orderDate', 'contractAmountCny']),
423
+ apply: qb => {
424
+ const unpaid = booleanFilter(parameters.hasUnpaid);
425
+ if (unpaid != null)
426
+ qb.andWhere(unpaid ? 'row.contractAmountCny > row.paidAmountCny' : 'row.contractAmountCny <= row.paidAmountCny');
427
+ const refundExpression = purchaseEstimatedRefundSql('row');
428
+ const unrefunded = booleanFilter(parameters.hasUnrefunded);
429
+ if (unrefunded != null)
430
+ qb.andWhere(unrefunded ? `${refundExpression} > row.refundedAmountCny` : `${refundExpression} <= row.refundedAmountCny`);
431
+ const linked = parameters.linkStatus === 'LINKED' ? true : parameters.linkStatus === 'UNLINKED' ? false : null;
432
+ if (linked != null)
433
+ qb.andWhere(`${linked ? '' : 'NOT '}EXISTS (${linkedCustomerContractSql('row')})`, { ...activeScope(scope) });
434
+ }
435
+ });
436
+ return { ...page, items: await this.purchaseOrderDtos(scope, page.items) };
437
+ }
438
+ async getPurchaseOrder(scope, id) {
439
+ const order = await required(this.dataSource.getRepository(PurchaseOrder).findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() } }), '采购订单不存在');
440
+ return (await this.purchaseOrderDtos(scope, [order]))[0];
441
+ }
442
+ async listPurchaseLines(scope, purchaseOrderId, query = {}) {
443
+ const parameters = query.parameters ?? {};
444
+ const page = await this.pagedQuery(PurchaseOrderLine, scope, query, ['productName', 'productDescription', 'modelSpecification', 'hsCode'], { purchaseOrderId, hsCodeStatus: parameters.hsCodeStatus }, {
445
+ fuzzy: pick(parameters, ['productName', 'productDescription', 'hsCode']),
446
+ apply: qb => {
447
+ if (hasQueryValue(parameters.complianceCategory))
448
+ qb.andWhere(`EXISTS (SELECT 1 FROM plugin_trade_compliance_product_match compliance_match WHERE compliance_match."purchaseOrderLineId" = row.id AND compliance_match."tenantId" = :tenantId AND compliance_match."organizationId" = :organizationId AND compliance_match."deletedAt" IS NULL AND compliance_match."catalogType" = :complianceCategory)`, { ...activeScope(scope), complianceCategory: parameters.complianceCategory });
449
+ }
450
+ });
451
+ const ids = page.items.map(item => item.id);
452
+ const matches = ids.length ? await this.dataSource.getRepository(ProductComplianceMatch).find({ where: { ...activeScope(scope), purchaseOrderLineId: In(ids), deletedAt: IsNull() } }) : [];
453
+ return {
454
+ ...page,
455
+ items: page.items.map(item => ({
456
+ ...item,
457
+ sourceType: item.sourceType,
458
+ hsCodeStatus: item.hsCodeStatus,
459
+ complianceCategories: [...new Set(matches.filter(match => match.purchaseOrderLineId === item.id).map(match => match.catalogType))]
460
+ }))
461
+ };
462
+ }
463
+ async getSupplierProcurementMetrics(scope, supplierId, currentYear = new Date().getFullYear()) {
464
+ const orders = await this.dataSource.getRepository(PurchaseOrder).find({ where: { ...activeScope(scope), supplierId, deletedAt: IsNull() } });
465
+ const orderIds = orders.map(order => order.id);
466
+ const lines = orderIds.length ? await this.dataSource.getRepository(PurchaseOrderLine).find({ where: { ...activeScope(scope), purchaseOrderId: In(orderIds), deletedAt: IsNull() } }) : [];
467
+ const currentOrders = orders.filter(order => String(order.orderDate ?? '').startsWith(`${currentYear}-`));
468
+ const top = new Map();
469
+ for (const line of lines)
470
+ top.set(line.productName, (top.get(line.productName) ?? new Decimal(0)).plus(line.lineAmountTaxIncluded));
471
+ const orderBalances = {};
472
+ for (const order of orders) {
473
+ const orderLines = lines.filter(line => line.purchaseOrderId === order.id);
474
+ orderBalances[order.id] = {
475
+ unpaidAmountCny: Decimal.max(new Decimal(order.contractAmountCny).minus(order.paidAmountCny), 0).toNumber(),
476
+ unrefundedAmountCny: Number(this.unrefundedAmount(order, orderLines))
477
+ };
478
+ }
479
+ return {
480
+ totalOrderCount: orders.length,
481
+ totalPurchaseAmountCny: decimalSum(orders, order => order.contractAmountCny),
482
+ currentYearOrderCount: currentOrders.length,
483
+ currentYearPurchaseAmountCny: decimalSum(currentOrders, order => order.contractAmountCny),
484
+ topProducts: [...top].sort((left, right) => right[1].cmp(left[1]) || left[0].localeCompare(right[0])).slice(0, 5).map(([productName, amountCny]) => ({ productName, amountCny: amountCny.toNumber() })),
485
+ orderBalances
486
+ };
487
+ }
267
488
  async updateSupplier(scope, id, input) {
268
- const repo = this.dataSource.getRepository(Supplier);
269
- const item = await required(repo.findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() } }), '供应商不存在');
270
- Object.assign(item, pick(input, ['code', 'name', 'shortName', 'address', 'contactName', 'phone', 'wechat', 'email', 'taxNumber', 'paymentAccount', 'bankName', 'taxpayerRating', 'website', 'businessProducts']));
271
- item.normalizedName = normalizeExactName(item.name);
272
- item.updatedById = scope.userId ?? null;
273
- const saved = await repo.save(item);
489
+ const saved = await this.dataSource.transaction(async (manager) => {
490
+ const repo = manager.getRepository(Supplier);
491
+ const item = await required(repo.findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } }), '供应商不存在');
492
+ const code = await this.lockAndAssertPartyCodeAvailable(manager, scope, Supplier, 'code' in input ? input.code : item.code, item.id);
493
+ Object.assign(item, pick(input, ['name', 'shortName', 'address', 'contactName', 'phone', 'wechat', 'email', 'taxNumber', 'paymentAccount', 'bankName', 'taxpayerRating', 'website', 'businessProducts']));
494
+ item.code = code;
495
+ item.normalizedName = normalizeExactName(item.name);
496
+ item.updatedById = scope.userId ?? null;
497
+ return repo.save(item);
498
+ });
274
499
  await this.recalculateCompanyRisks(scope, 'SOURCE_CHANGED');
275
500
  return saved;
276
501
  }
@@ -284,44 +509,67 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
284
509
  return repo.save(item);
285
510
  }
286
511
  async updatePurchaseLine(scope, id, input) {
287
- const repo = this.dataSource.getRepository(PurchaseOrderLine);
288
- const item = await required(repo.findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() } }), '采购商品不存在');
289
- Object.assign(item, pick(input, ['productName', 'englishName', 'englishNameSource', 'productDescription', 'modelSpecification', 'unit', 'quantity', 'unitPriceTaxIncluded', 'lineAmountTaxIncluded', 'vatRate', 'vatRateSource', 'hsCode', 'hsCodeStatus', 'hsCodeSource', 'refundRate', 'refundRateSource', 'refundPolicyReference', 'refundPolicyDate', 'refundValidationMethod']));
290
- if (new Decimal(item.quantity).lte(0))
512
+ validatePurchaseLineDiscriminators(input);
513
+ const existing = await required(this.dataSource.getRepository(PurchaseOrderLine).findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() } }), '采购商品不存在');
514
+ const candidate = { ...existing, ...pick(input, ['productName', 'englishName', 'englishNameSource', 'productDescription', 'modelSpecification', 'unit', 'quantity', 'unitPriceTaxIncluded', 'lineAmountTaxIncluded', 'vatRate', 'vatRateSource', 'hsCode', 'hsCodeStatus', 'hsCodeSource', 'originCountry', 'domesticSourceLocation', 'refundRate', 'refundRateSource', 'refundPolicyReference', 'refundPolicyDate', 'refundValidationMethod']) };
515
+ validateTaxRates(candidate, { purchaseOrderLineId: existing.id, lineNumber: existing.lineNumber, productName: candidate.productName });
516
+ if (new Decimal(candidate.quantity).lte(0))
291
517
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '采购商品数量必须大于 0');
292
- await this.assertPurchaseOrderReceiptsRemainValid(scope, item.purchaseOrderId, item);
293
- item.normalizedProductName = normalizeForProductMatch(item.productName);
294
- item.updatedById = scope.userId ?? null;
295
- const saved = await repo.save(item);
518
+ const saved = await this.withLockedPurchaseLines(scope, existing.purchaseOrderId, async ({ lineRepo, lines, contracts, customers, manuals }) => {
519
+ const item = await required(Promise.resolve(lines.find(line => line.id === id) ?? null), '采购商品不存在');
520
+ Object.assign(item, pick(input, ['productName', 'englishName', 'englishNameSource', 'productDescription', 'modelSpecification', 'unit', 'quantity', 'unitPriceTaxIncluded', 'lineAmountTaxIncluded', 'vatRate', 'vatRateSource', 'hsCode', 'hsCodeStatus', 'hsCodeSource', 'originCountry', 'domesticSourceLocation', 'refundRate', 'refundRateSource', 'refundPolicyReference', 'refundPolicyDate', 'refundValidationMethod']));
521
+ validatePurchaseLineDiscriminators(item);
522
+ const rates = validateTaxRates(item, { purchaseOrderLineId: item.id, lineNumber: item.lineNumber, productName: item.productName });
523
+ item.vatRate = rates.vatRate;
524
+ item.refundRate = rates.refundRate;
525
+ if (new Decimal(item.quantity).lte(0))
526
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '采购商品数量必须大于 0');
527
+ this.assertLockedPurchaseReceipts(lines, contracts, customers, manuals);
528
+ item.normalizedProductName = normalizeForProductMatch(item.productName);
529
+ item.updatedById = scope.userId ?? null;
530
+ return lineRepo.save(item);
531
+ });
296
532
  await this.recalculateProductRisks(scope, 'SOURCE_CHANGED');
297
533
  return saved;
298
534
  }
299
535
  async addPurchaseLine(scope, purchaseOrderId, input) {
300
- const repo = this.dataSource.getRepository(PurchaseOrderLine);
301
- const count = await repo.count({ where: { ...activeScope(scope), purchaseOrderId, deletedAt: IsNull() } });
302
- const saved = await repo.save(repo.create({ ...auditScope(scope), purchaseOrderId, lineNumber: count + 1, sourceType: 'MANUAL_ENTRY', productName: input.productName.trim(), normalizedProductName: normalizeForProductMatch(input.productName), englishName: input.englishName ?? null, englishNameSource: input.englishNameSource ?? null, productDescription: input.productDescription ?? null, modelSpecification: input.modelSpecification ?? null, unit: input.unit ?? null, quantity: input.quantity, unitPriceTaxIncluded: input.unitPriceTaxIncluded, lineAmountTaxIncluded: input.lineAmountTaxIncluded, vatRate: input.vatRate ?? null, vatRateSource: input.vatRateSource ?? 'MANUAL', hsCode: input.hsCode ?? null, hsCodeStatus: input.hsCodeStatus ?? 'UNCONFIRMED', hsCodeSource: input.hsCodeSource ?? null, hsCodeReferenceSnapshotId: null, refundRate: input.refundRate ?? null, refundRateSource: input.refundRateSource ?? null, refundPolicyReference: input.refundPolicyReference ?? null, refundPolicyDate: input.refundPolicyDate ?? null, refundValidationMethod: input.refundValidationMethod ?? null, refundConfirmedAt: null, refundConfirmedById: null, amountMismatchConfirmedAt: null, amountMismatchConfirmedById: null, sourceLocation: null, sourceContent: null }));
536
+ validatePurchaseLineDiscriminators(input);
537
+ const discriminators = validatePurchaseLineDiscriminators({ ...input, sourceType: 'MANUAL_ENTRY', vatRateSource: input.vatRateSource ?? 'MANUAL', hsCodeStatus: input.hsCodeStatus ?? 'UNCONFIRMED' });
538
+ const rates = validateTaxRates(input, { productName: input.productName });
539
+ if (new Decimal(input.quantity).lte(0))
540
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '采购商品数量必须大于 0');
541
+ const saved = await this.withLockedPurchaseLines(scope, purchaseOrderId, async ({ lineRepo, lines, contracts, customers, manuals }) => {
542
+ const lineNumber = lines.reduce((maximum, line) => Math.max(maximum, line.lineNumber), 0) + 1;
543
+ const item = lineRepo.create({ ...auditScope(scope), purchaseOrderId, lineNumber, sourceType: discriminators.sourceType ?? 'MANUAL_ENTRY', productName: input.productName.trim(), normalizedProductName: normalizeForProductMatch(input.productName), englishName: input.englishName ?? null, englishNameSource: discriminators.englishNameSource ?? null, productDescription: input.productDescription ?? null, modelSpecification: input.modelSpecification ?? null, unit: input.unit ?? null, quantity: input.quantity, unitPriceTaxIncluded: input.unitPriceTaxIncluded, lineAmountTaxIncluded: input.lineAmountTaxIncluded, vatRate: rates.vatRate, vatRateSource: discriminators.vatRateSource ?? 'MANUAL', hsCode: input.hsCode ?? null, hsCodeStatus: discriminators.hsCodeStatus ?? 'UNCONFIRMED', hsCodeSource: discriminators.hsCodeSource ?? null, hsCodeReferenceSnapshotId: null, originCountry: input.originCountry ?? null, domesticSourceLocation: input.domesticSourceLocation ?? null, refundRate: rates.refundRate, refundRateSource: discriminators.refundRateSource ?? null, refundPolicyReference: input.refundPolicyReference ?? null, refundPolicyDate: input.refundPolicyDate ?? null, refundValidationMethod: discriminators.refundValidationMethod ?? null, refundConfirmedAt: null, refundConfirmedById: null, amountMismatchConfirmedAt: null, amountMismatchConfirmedById: null, sourceLocation: null, sourceContent: null });
544
+ this.assertLockedPurchaseReceipts([...lines, item], contracts, customers, manuals);
545
+ return lineRepo.save(item);
546
+ });
303
547
  await this.recalculateProductRisks(scope, 'SOURCE_CHANGED');
304
548
  return saved;
305
549
  }
306
550
  async saveCustomerContractDraft(scope, importTaskId, draft) {
551
+ const normalizedDraft = { ...draft, clauses: draft.clauses.map(clause => ({ ...clause, ...normalizeCustomerContractAnalysisInput(clause) })) };
552
+ const effectiveAnalyses = completeCustomerClauseAnalyses(normalizedDraft);
307
553
  const contract = await this.dataSource.transaction(async (manager) => {
554
+ const taskRepo = manager.getRepository(ImportTask);
555
+ const task = await required(taskRepo.findOne({ where: { ...activeScope(scope), id: importTaskId }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
556
+ await invalidateCustomerContractReanalysis(manager, scope, { reviewSource: 'IMPORT_DRAFT', task });
308
557
  const parsed = parseCustomerContractNo(draft.contract.contractNo);
309
558
  const contractRepo = manager.getRepository(CustomerContract);
310
559
  const duplicate = await contractRepo.findOne({ where: { ...activeScope(scope), normalizedContractNo: parsed.normalized, deletedAt: IsNull() } });
311
560
  if (duplicate)
312
561
  throw new DomainError(DomainErrorCodes.DUPLICATE_CONTRACT, '客户合同已存在,如需重新上传请先删除旧记录', { id: duplicate.id, contractNo: draft.contract.contractNo });
313
562
  const customer = await this.upsertCustomer(manager, scope, draft.customer);
314
- const task = await required(manager.getRepository(ImportTask).findOne({ where: { ...activeScope(scope), id: importTaskId } }), '解析任务不存在');
315
563
  task.status = 'PERSISTING';
316
- await manager.getRepository(ImportTask).save(task);
317
- const overallRiskLevel = maxRisk(draft.analyses?.map(item => item.riskLevel) ?? []);
564
+ await taskRepo.save(task);
565
+ const overallRiskLevel = maxRisk(effectiveAnalyses.map(item => item.riskLevel));
318
566
  const entity = await contractRepo.save(contractRepo.create({ ...auditScope(scope), customerId: customer.id, contractNo: draft.contract.contractNo.trim(), normalizedContractNo: parsed.normalized, contractAmountReviewCny: draft.contract.contractAmountReviewCny ?? null, signedAt: draft.contract.signedAt ?? null, overallRiskLevel, latestAnalysisRunId: null, sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName, importTaskId: task.id }));
319
567
  const clauseRepo = manager.getRepository(CustomerContractClause);
320
568
  const analysisRun = await manager.getRepository(CustomerContractAnalysisRun).save(manager.getRepository(CustomerContractAnalysisRun).create({ ...createdScope(scope), customerContractId: entity.id, importTaskId: task.id, analysisType: 'INITIAL', status: 'SUCCEEDED', promptVersion: 'CUSTOMER_CLAUSE_V1', modelName: null, startedAt: new Date(), completedAt: new Date(), createdById: scope.userId ?? null }));
321
569
  for (const type of ClauseTypes) {
322
- const item = draft.clauses.find(clause => clause.clauseType === type);
323
- const clause = await clauseRepo.save(clauseRepo.create({ ...auditScope(scope), customerContractId: entity.id, clauseType: type, extractionStatus: item?.extractionStatus ?? (item?.sourceText ? 'FOUND' : 'NOT_FOUND'), sourceText: item?.sourceText ?? null, reviewedText: item?.reviewedText ?? item?.sourceText ?? null, sourceLocation: item?.sourceLocation ?? null, sourceContent: item?.sourceContent ?? null }));
324
- const analysis = draft.analyses?.find(value => value.clauseType === type);
570
+ const item = normalizedDraft.clauses.find(clause => clause.clauseType === type);
571
+ const clause = await clauseRepo.save(clauseRepo.create({ ...auditScope(scope), customerContractId: entity.id, clauseType: type, extractionStatus: item?.extractionStatus ?? 'NOT_FOUND', sourceText: item?.sourceText ?? null, reviewedText: item?.reviewedText ?? null, sourceLocation: item?.sourceLocation ?? null, sourceContent: item?.sourceContent ?? null }));
572
+ const analysis = effectiveAnalyses.find(value => value.clauseType === type);
325
573
  if (analysis)
326
574
  await manager.getRepository(CustomerContractClauseAnalysis).save(manager.getRepository(CustomerContractClauseAnalysis).create({ ...createdScope(scope), analysisRunId: analysisRun.id, customerContractClauseId: clause.id, clauseType: type, analyzedTextSnapshot: clause.reviewedText, riskLevel: analysis.riskLevel, riskMessageZh: analysis.riskMessageZh, suggestionZh: analysis.suggestionZh }));
327
575
  }
@@ -331,14 +579,96 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
331
579
  task.resultEntityId = entity.id;
332
580
  task.reviewSavedAt = new Date();
333
581
  task.completedAt = new Date();
334
- await manager.getRepository(ImportTask).save(task);
582
+ await taskRepo.save(task);
335
583
  return entity;
336
584
  });
337
585
  await this.recalculateCompanyRisks(scope, 'SOURCE_CHANGED');
338
586
  return contract;
339
587
  }
340
588
  async listCustomers(scope, query = {}) { return this.pagedQuery(Customer, scope, query, ['code', 'name', 'shortName', 'country', 'contactName']); }
341
- async listCustomerContracts(scope, query = {}) { return this.pagedQuery(CustomerContract, scope, query, ['contractNo', 'sourceFileName'], { customerId: query.parameters?.customerId }); }
589
+ async listSalesCustomers(scope, query = {}) {
590
+ const parameters = query.parameters ?? {};
591
+ assertOptionalEnum(parameters.profitRateStatus, CustomerProfitRateStatuses, '默认定价利润率状态');
592
+ assertOptionalEnum(parameters.sanctionStatus, SanctionHitStatuses, '制裁风险状态');
593
+ const page = await this.pagedQuery(Customer, scope, query, ['code', 'name', 'country'], {}, {
594
+ fuzzy: pick(parameters, ['code', 'name', 'country']),
595
+ range: pick(parameters, ['complianceCheckedAt']),
596
+ apply: qb => {
597
+ if (parameters.profitRateStatus === 'MISSING' || parameters.profitRateStatus === 'CONFIGURED')
598
+ qb.andWhere('row.pricingProfitRateStatus = :pricingProfitRateStatus', { pricingProfitRateStatus: parameters.profitRateStatus });
599
+ if (parameters.sanctionStatus === 'HIT')
600
+ qb.andWhere(`EXISTS (SELECT 1 FROM plugin_trade_compliance_company_sanction_match match WHERE match."tenantId" = :tenantId AND match."organizationId" = :organizationId AND match."deletedAt" IS NULL AND match."subjectType" = 'CUSTOMER' AND match."customerId" = row.id)`, activeScope(scope));
601
+ if (parameters.sanctionStatus === 'CLEAR')
602
+ qb.andWhere("row.complianceCheckStatus = 'CHECKED'").andWhere(`NOT EXISTS (SELECT 1 FROM plugin_trade_compliance_company_sanction_match match WHERE match."tenantId" = :tenantId AND match."organizationId" = :organizationId AND match."deletedAt" IS NULL AND match."subjectType" = 'CUSTOMER' AND match."customerId" = row.id)`, activeScope(scope));
603
+ if (parameters.sanctionStatus === 'UNCHECKED')
604
+ qb.andWhere("row.complianceCheckStatus = 'UNCHECKED'");
605
+ }
606
+ });
607
+ return { ...page, items: await this.salesCustomerDtos(scope, page.items) };
608
+ }
609
+ async listSalesOrders(scope, customerId, query = {}) {
610
+ assertOptionalEnum(query.parameters?.paymentStatus, SalesPaymentStatuses, '收款状态');
611
+ const customer = (await this.salesCustomerDtos(scope, [await required(this.dataSource.getRepository(Customer).findOne({ where: { ...activeScope(scope), id: customerId, deletedAt: IsNull() } }), '客户不存在')]))[0];
612
+ const page = await this.selectDerivedSalesOrderIdentities(scope, customerId, query, false);
613
+ const metricIdentities = await this.selectAllDerivedSalesOrderIdentities(scope, customerId, query.parameters ?? {}, false);
614
+ const [items, metricRows] = await Promise.all([
615
+ this.salesOrderDtos(scope, await this.listDerivedSalesOrders(scope, customerId, page.items)),
616
+ this.salesOrderDtos(scope, await this.listDerivedSalesOrders(scope, customerId, metricIdentities))
617
+ ]);
618
+ return { ...page, items, customer, metrics: salesMetrics(metricRows) };
619
+ }
620
+ async listSalesLines(scope, query) {
621
+ assertOptionalEnum(query.parameters?.complianceCategory, CatalogTypes, '合规分类');
622
+ const rows = await this.salesOrderDtos(scope, await this.listDerivedSalesOrders(scope, query.customerId));
623
+ const order = rows.find(item => item.customerContractId === query.customerContractId && item.purchaseOrderId === query.purchaseOrderId);
624
+ if (!order)
625
+ throw new DomainError(DomainErrorCodes.NOT_FOUND, '销售订单不存在');
626
+ const parameters = query.parameters ?? {};
627
+ const filtered = order.lines.filter(line => fuzzyValue(line.productName, parameters.productName) && fuzzyValue(line.productDescription, parameters.productDescription) && fuzzyValue(line.hsCode, parameters.hsCode) && (!hasQueryValue(parameters.complianceCategory) || line.complianceCategories.includes(parameters.complianceCategory)));
628
+ return { ...pageRows(filtered, query), order };
629
+ }
630
+ async listSalesFileOrders(scope, query = {}) {
631
+ const page = await this.selectDerivedSalesOrderIdentities(scope, undefined, query, true);
632
+ return { ...page, items: await this.salesOrderDtos(scope, await this.listDerivedSalesOrders(scope, undefined, page.items)) };
633
+ }
634
+ async listCustomerContracts(scope, query = {}) {
635
+ const parameters = query.parameters ?? {};
636
+ const page = await this.pagedQuery(CustomerContract, scope, query, ['contractNo', 'sourceFileName'], { customerId: parameters.customerId, overallRiskLevel: parameters.overallRiskLevel }, {
637
+ fuzzy: pick(parameters, ['contractNo']),
638
+ range: pick(parameters, ['signedAt', 'contractAmountReviewCny']),
639
+ apply: qb => {
640
+ if (hasQueryValue(parameters.customerName))
641
+ qb.andWhere(`EXISTS (SELECT 1 FROM plugin_trade_compliance_customer customer_filter WHERE customer_filter.id = row."customerId" AND customer_filter."tenantId" = :tenantId AND customer_filter."organizationId" = :organizationId AND customer_filter."deletedAt" IS NULL AND customer_filter.name ILIKE :customerName)`, { ...activeScope(scope), customerName: `%${String(parameters.customerName).trim()}%` });
642
+ if (parameters.sanctionStatus === 'HIT')
643
+ qb.andWhere(`EXISTS (${customerSanctionMatchSql('row')})`, activeScope(scope));
644
+ if (parameters.sanctionStatus === 'CLEAR')
645
+ qb.andWhere(`NOT EXISTS (${customerSanctionMatchSql('row')})`, activeScope(scope)).andWhere(`EXISTS (SELECT 1 FROM plugin_trade_compliance_customer customer_checked WHERE customer_checked.id = row."customerId" AND customer_checked."complianceCheckStatus" = 'CHECKED')`);
646
+ if (parameters.sanctionStatus === 'UNCHECKED')
647
+ qb.andWhere(`EXISTS (SELECT 1 FROM plugin_trade_compliance_customer customer_unchecked WHERE customer_unchecked.id = row."customerId" AND customer_unchecked."complianceCheckStatus" = 'UNCHECKED')`);
648
+ }
649
+ });
650
+ const customerIds = [...new Set(page.items.map(item => item.customerId))];
651
+ const customers = customerIds.length ? await this.dataSource.getRepository(Customer).find({ where: { ...activeScope(scope), id: In(customerIds), deletedAt: IsNull() } }) : [];
652
+ const matches = customerIds.length ? await this.dataSource.getRepository(CompanySanctionMatch).find({ where: { ...activeScope(scope), subjectType: 'CUSTOMER', customerId: In(customerIds), deletedAt: IsNull() } }) : [];
653
+ const contractIds = page.items.map(item => item.id);
654
+ const runs = contractIds.length ? await this.dataSource.getRepository(CustomerContractAnalysisRun).find({ where: { ...activeScope(scope), customerContractId: In(contractIds) }, order: { createdAt: 'DESC' } }) : [];
655
+ return {
656
+ ...page,
657
+ items: page.items.map(item => {
658
+ const customer = customers.find(value => value.id === item.customerId);
659
+ const latestRun = runs.find(run => run.customerContractId === item.id);
660
+ const sanctionMatchCount = matches.filter(match => match.customerId === item.customerId).length;
661
+ return {
662
+ ...item,
663
+ contractAmountReviewCny: item.contractAmountReviewCny,
664
+ customerName: customer?.name ?? '',
665
+ customerCode: customer?.code ?? null,
666
+ sanctionStatus: customer?.complianceCheckStatus === 'CHECKED' ? sanctionMatchCount ? 'HIT' : 'CLEAR' : 'UNCHECKED',
667
+ latestAnalysisAt: latestRun?.completedAt ?? latestRun?.createdAt ?? null
668
+ };
669
+ })
670
+ };
671
+ }
342
672
  async getCustomerContractReview(scope, id) {
343
673
  const contract = await required(this.dataSource.getRepository(CustomerContract).findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() } }), '客户合同不存在');
344
674
  const [customer, clauses, runs] = await Promise.all([
@@ -346,31 +676,299 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
346
676
  this.dataSource.getRepository(CustomerContractClause).find({ where: { ...activeScope(scope), customerContractId: id, deletedAt: IsNull() } }),
347
677
  this.dataSource.getRepository(CustomerContractAnalysisRun).find({ where: { ...activeScope(scope), customerContractId: id }, order: { createdAt: 'DESC' } })
348
678
  ]);
349
- const analyses = runs.length ? await this.dataSource.getRepository(CustomerContractClauseAnalysis).find({ where: { ...activeScope(scope), analysisRunId: runs[0].id } }) : [];
350
- return { contract, customer, clauses, analyses, latestRun: runs[0] ?? null };
679
+ const latestRun = contract.latestAnalysisRunId ? runs.find(run => run.id === contract.latestAnalysisRunId && run.status === 'SUCCEEDED') ?? null : null;
680
+ const analyses = latestRun ? await this.dataSource.getRepository(CustomerContractClauseAnalysis).find({ where: { ...activeScope(scope), analysisRunId: latestRun.id } }) : [];
681
+ const sanctionMatchCount = customer ? await this.dataSource.getRepository(CompanySanctionMatch).count({ where: { ...activeScope(scope), subjectType: 'CUSTOMER', customerId: customer.id, deletedAt: IsNull() } }) : 0;
682
+ return { contract, customer, clauses, analyses, latestRun, analysisRuns: runs, sanctionStatus: customer?.complianceCheckStatus === 'CHECKED' ? sanctionMatchCount ? 'HIT' : 'CLEAR' : 'UNCHECKED' };
683
+ }
684
+ async startCustomerContractReanalysis(scope, input) {
685
+ return this.dataSource.transaction(async (manager) => {
686
+ const now = new Date();
687
+ let importTaskId;
688
+ let customerContractId = null;
689
+ let clauses;
690
+ let contractTarget = null;
691
+ let taskTarget = null;
692
+ let activeRun = null;
693
+ const runRepo = manager.getRepository(CustomerContractAnalysisRun);
694
+ if (input.reviewSource === 'EXISTING_CONTRACT') {
695
+ const contractRepo = manager.getRepository(CustomerContract);
696
+ const contract = await required(contractRepo.findOne({ where: { ...activeScope(scope), id: requiredId(input.id, '客户合同标识'), deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } }), '客户合同不存在');
697
+ contractTarget = contract;
698
+ customerContractId = contract.id;
699
+ importTaskId = contract.importTaskId;
700
+ activeRun = await runRepo.findOne({ where: { ...activeScope(scope), customerContractId: contract.id, analysisType: 'REANALYSIS', status: In(['PENDING', 'RUNNING']) }, lock: { mode: 'pessimistic_write' } });
701
+ const storedClauses = await manager.getRepository(CustomerContractClause).find({ where: { ...activeScope(scope), customerContractId: contract.id, deletedAt: IsNull() } });
702
+ clauses = storedClauses.map(clause => ({ clauseType: clause.clauseType, extractionStatus: clause.extractionStatus, sourceText: clause.sourceText, reviewedText: clause.reviewedText, sourceLocation: clause.sourceLocation, sourceContent: clause.sourceContent }));
703
+ }
704
+ else if (input.reviewSource === 'IMPORT_DRAFT') {
705
+ importTaskId = requiredId(input.importTaskId, '导入任务标识');
706
+ const taskRepo = manager.getRepository(ImportTask);
707
+ const task = await required(taskRepo.findOne({ where: { ...activeScope(scope), id: importTaskId }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
708
+ taskTarget = task;
709
+ if (task.documentType !== 'CUSTOMER_CONTRACT' || task.status !== 'AWAITING_REVIEW')
710
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '仅待审核的客户合同草稿可以重新分析');
711
+ activeRun = await runRepo.findOne({ where: { ...activeScope(scope), customerContractId: IsNull(), importTaskId, analysisType: 'REANALYSIS', status: In(['PENDING', 'RUNNING']) }, lock: { mode: 'pessimistic_write' } });
712
+ clauses = Array.isArray(task.resultPayload?.clauses) ? task.resultPayload.clauses.map(reanalysisClauseInput) : [];
713
+ }
714
+ else {
715
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '不支持的客户合同审核来源');
716
+ }
717
+ const analysisInput = { reviewSource: input.reviewSource, clauses };
718
+ const targetCurrentRunId = contractTarget?.currentReanalysisRunId ?? taskTarget?.currentReanalysisRunId ?? null;
719
+ if (activeRun) {
720
+ if (targetCurrentRunId && targetCurrentRunId !== activeRun.id)
721
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析任务已不是当前运行');
722
+ const expiresAt = activeRun.expiresAt ?? new Date((activeRun.createdAt ?? now).getTime() + CUSTOMER_REANALYSIS_TIMEOUT_MS);
723
+ activeRun.expiresAt = expiresAt;
724
+ if (!await timeoutCustomerContractReanalysis(manager, { run: activeRun, contract: contractTarget, task: taskTarget }, now)) {
725
+ if (activeRun.analysisInput) {
726
+ await runRepo.save(activeRun);
727
+ if (!targetCurrentRunId) {
728
+ if (contractTarget) {
729
+ contractTarget.currentReanalysisRunId = activeRun.id;
730
+ await manager.getRepository(CustomerContract).save(contractTarget);
731
+ }
732
+ if (taskTarget) {
733
+ taskTarget.currentReanalysisRunId = activeRun.id;
734
+ await manager.getRepository(ImportTask).save(taskTarget);
735
+ }
736
+ }
737
+ return { run: activeRun, resumed: true, reviewSource: activeRun.analysisInput.reviewSource, clauses: activeRun.analysisInput.clauses };
738
+ }
739
+ activeRun.status = 'FAILED';
740
+ activeRun.errorCode = 'AI_REANALYSIS_INPUT_MISSING';
741
+ activeRun.errorMessage = '重新分析运行缺少可验证的输入快照';
742
+ activeRun.completedAt = now;
743
+ await runRepo.save(activeRun);
744
+ }
745
+ }
746
+ 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 }));
747
+ if (contractTarget) {
748
+ contractTarget.currentReanalysisRunId = run.id;
749
+ await manager.getRepository(CustomerContract).save(contractTarget);
750
+ }
751
+ if (taskTarget) {
752
+ taskTarget.currentReanalysisRunId = run.id;
753
+ await manager.getRepository(ImportTask).save(taskTarget);
754
+ }
755
+ return { run, resumed: false, reviewSource: input.reviewSource, clauses };
756
+ });
757
+ }
758
+ async saveCustomerContractReanalysisExecution(scope, id, command) {
759
+ const outcome = await this.dataSource.transaction(async (manager) => {
760
+ const locked = await lockCustomerContractReanalysis(manager, scope, id);
761
+ const run = locked.run;
762
+ if (await timeoutCustomerContractReanalysis(manager, locked))
763
+ return { run, expired: true };
764
+ if (run.status !== 'PENDING' && run.status !== 'RUNNING')
765
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析任务状态不允许保存命令');
766
+ if (run.executionCommand)
767
+ return { run, expired: false };
768
+ if (lockedCurrentReanalysisRunId(locked) !== run.id)
769
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析任务已不是当前运行');
770
+ run.executionCommand = command;
771
+ return { run: await manager.getRepository(CustomerContractAnalysisRun).save(run), expired: false };
772
+ });
773
+ if (outcome.expired)
774
+ throw reanalysisTimeoutError();
775
+ return outcome.run;
776
+ }
777
+ async markCustomerContractReanalysisDispatched(scope, id) {
778
+ const outcome = await this.dataSource.transaction(async (manager) => {
779
+ const locked = await lockCustomerContractReanalysis(manager, scope, id);
780
+ const run = locked.run;
781
+ if (await timeoutCustomerContractReanalysis(manager, locked))
782
+ return { run, expired: true };
783
+ if (run.status !== 'PENDING')
784
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '只有待派发的重新分析任务可以派发');
785
+ if (!run.executionCommand)
786
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析任务尚未持久化执行命令');
787
+ if (lockedCurrentReanalysisRunId(locked) !== run.id)
788
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析任务已不是当前运行');
789
+ const dispatchedAt = new Date();
790
+ run.commandDispatchedAt = dispatchedAt;
791
+ run.startedAt = dispatchedAt;
792
+ run.status = 'RUNNING';
793
+ return { run: await manager.getRepository(CustomerContractAnalysisRun).save(run), expired: false };
794
+ });
795
+ if (outcome.expired)
796
+ throw reanalysisTimeoutError();
797
+ return outcome.run;
798
+ }
799
+ async markCustomerContractReanalysisFailed(scope, id, input) {
800
+ const errorCode = requiredId(input.errorCode, '错误代码');
801
+ const errorMessage = requiredId(input.errorMessage, '错误信息');
802
+ return this.dataSource.transaction(async (manager) => {
803
+ const repo = manager.getRepository(CustomerContractAnalysisRun);
804
+ const locked = await lockCustomerContractReanalysis(manager, scope, id);
805
+ const run = locked.run;
806
+ if (await timeoutCustomerContractReanalysis(manager, locked))
807
+ return run;
808
+ if (run.status !== 'PENDING' && run.status !== 'RUNNING')
809
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析任务状态不允许标记失败');
810
+ run.status = 'FAILED';
811
+ run.errorCode = errorCode;
812
+ run.errorMessage = errorMessage;
813
+ run.completedAt = new Date();
814
+ return repo.save(run);
815
+ });
816
+ }
817
+ async getCustomerContractReanalysisResult(scope, id) {
818
+ const run = await this.dataSource.transaction(async (manager) => {
819
+ const locked = await lockCustomerContractReanalysis(manager, scope, id);
820
+ await timeoutCustomerContractReanalysis(manager, locked);
821
+ return locked.run;
822
+ });
823
+ if (run.status !== 'SUCCEEDED')
824
+ return { run };
825
+ if (run.customerContractId)
826
+ return { run, review: await this.getCustomerContractReview(scope, run.customerContractId) };
827
+ const task = await required(this.dataSource.getRepository(ImportTask).findOne({ where: { ...activeScope(scope), id: run.importTaskId } }), '解析任务不存在');
828
+ return { run, task };
829
+ }
830
+ async saveCustomerContractReanalysis(scope, analysisRunId, analyses) {
831
+ assertCompleteClauseAnalyses(analyses);
832
+ const outcome = await this.dataSource.transaction(async (manager) => {
833
+ const runRepo = manager.getRepository(CustomerContractAnalysisRun);
834
+ const locked = await lockCustomerContractReanalysis(manager, scope, analysisRunId);
835
+ const { run, contract, task } = locked;
836
+ if (run.status === 'SUCCEEDED')
837
+ return { run, expired: false };
838
+ if (await timeoutCustomerContractReanalysis(manager, locked))
839
+ return { run, expired: true };
840
+ if (lockedCurrentReanalysisRunId(locked) !== analysisRunId)
841
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析任务已不是当前运行');
842
+ if (run.status !== 'PENDING' && run.status !== 'RUNNING')
843
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析任务状态不允许保存');
844
+ const analysisInput = requiredCustomerContractReanalysisInput(run);
845
+ if (run.customerContractId) {
846
+ const clauses = await manager.getRepository(CustomerContractClause).find({ where: { ...activeScope(scope), customerContractId: run.customerContractId, deletedAt: IsNull() } });
847
+ const analysisRepo = manager.getRepository(CustomerContractClauseAnalysis);
848
+ for (const analysis of analyses) {
849
+ const clause = clauses.find(item => item.clauseType === analysis.clauseType);
850
+ const inputClause = analysisInput.clauses.find(item => item.clauseType === analysis.clauseType);
851
+ if (!clause)
852
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析结果包含未知条款类型', { clauseType: analysis.clauseType });
853
+ if (!inputClause)
854
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析运行缺少条款输入快照', { clauseType: analysis.clauseType });
855
+ await analysisRepo.save(analysisRepo.create({ ...createdScope(scope), analysisRunId: run.id, customerContractClauseId: clause.id, clauseType: analysis.clauseType, analyzedTextSnapshot: inputClause.reviewedText, riskLevel: analysis.riskLevel, riskMessageZh: analysis.riskMessageZh, suggestionZh: analysis.suggestionZh }));
856
+ }
857
+ const contractRepo = manager.getRepository(CustomerContract);
858
+ if (!contract)
859
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '客户合同不存在');
860
+ contract.latestAnalysisRunId = run.id;
861
+ contract.overallRiskLevel = maxRisk(analyses.map(item => item.riskLevel));
862
+ await contractRepo.save(contract);
863
+ }
864
+ else {
865
+ const taskRepo = manager.getRepository(ImportTask);
866
+ if (!task)
867
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '解析任务不存在');
868
+ task.resultPayload = { ...(task.resultPayload ?? {}), analyses };
869
+ await taskRepo.save(task);
870
+ }
871
+ run.status = 'SUCCEEDED';
872
+ run.completedAt = new Date();
873
+ return { run: await runRepo.save(run), expired: false };
874
+ });
875
+ if (outcome.expired)
876
+ throw reanalysisTimeoutError();
877
+ return outcome.run;
878
+ }
879
+ async getSourceFileDescriptor(scope, sourceFileId) {
880
+ const task = await required(this.dataSource.getRepository(ImportTask).findOne({ where: { ...activeScope(scope), sourceFileId } }), '源文件不存在');
881
+ return { sourceFileId: task.sourceFileId, sourceFileName: task.sourceFileName };
351
882
  }
352
883
  async updateCustomerContractReview(scope, id, input) {
353
884
  const saved = await this.dataSource.transaction(async (manager) => {
354
885
  const contractRepo = manager.getRepository(CustomerContract);
355
- const contract = await required(contractRepo.findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() } }), '客户合同不存在');
886
+ const contract = await required(contractRepo.findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } }), '客户合同不存在');
356
887
  const customerRepo = manager.getRepository(Customer);
357
- const customer = await required(customerRepo.findOne({ where: { ...activeScope(scope), id: contract.customerId, deletedAt: IsNull() } }), '客户不存在');
888
+ const originalCustomer = await required(customerRepo.findOne({ where: { ...activeScope(scope), id: contract.customerId, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } }), '客户不存在');
889
+ let customer = originalCustomer;
358
890
  if (input.customer) {
359
- Object.assign(customer, pick(input.customer, ['code', 'name', 'shortName', 'country', 'address', 'contactName', 'phone', 'wechat', 'email', 'taxNumber', 'paymentAccount', 'bankName', 'taxpayerRating', 'website', 'businessProducts']));
360
- customer.normalizedName = normalizeExactName(customer.name);
361
- customer.updatedById = scope.userId ?? null;
362
- await customerRepo.save(customer);
891
+ const requestedName = 'name' in input.customer ? String(input.customer.name ?? '').trim() : originalCustomer.name;
892
+ if (!requestedName)
893
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '客户名称为必填项');
894
+ const targetNormalizedName = normalizeExactName(requestedName);
895
+ if (targetNormalizedName !== originalCustomer.normalizedName) {
896
+ const confirmation = input.customerTransferConfirmation;
897
+ if (confirmation?.type !== 'CONFIRMED_CUSTOMER_TRANSFER')
898
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '更改客户名称需要确认转移合同');
899
+ if (confirmation.targetNormalizedName !== targetNormalizedName)
900
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '客户转移确认已失效');
901
+ await this.lockCustomerName(manager, scope, targetNormalizedName);
902
+ customer = await customerRepo.findOne({ where: { ...activeScope(scope), normalizedName: targetNormalizedName, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } })
903
+ ?? customerRepo.create({ ...auditScope(scope), name: requestedName, normalizedName: targetNormalizedName });
904
+ const existingCode = customer.code;
905
+ Object.assign(customer, pick(input.customer, ['name', 'shortName', 'country', 'address', 'contactName', 'phone', 'wechat', 'email', 'taxNumber', 'paymentAccount', 'bankName', 'taxpayerRating', 'website', 'businessProducts']));
906
+ customer.name = requestedName;
907
+ customer.normalizedName = targetNormalizedName;
908
+ customer.code = await this.lockAndAssertPartyCodeAvailable(manager, scope, Customer, existingCode ?? input.customer.code, customer.id);
909
+ customer.updatedById = scope.userId ?? null;
910
+ customer = await customerRepo.save(customer);
911
+ contract.customerId = customer.id;
912
+ contract.updatedById = scope.userId ?? null;
913
+ await contractRepo.save(contract);
914
+ }
915
+ else {
916
+ const code = await this.lockAndAssertPartyCodeAvailable(manager, scope, Customer, 'code' in input.customer ? input.customer.code : customer.code, customer.id);
917
+ Object.assign(customer, pick(input.customer, ['code', 'name', 'shortName', 'country', 'address', 'contactName', 'phone', 'wechat', 'email', 'taxNumber', 'paymentAccount', 'bankName', 'taxpayerRating', 'website', 'businessProducts']));
918
+ customer.name = requestedName;
919
+ customer.normalizedName = targetNormalizedName;
920
+ customer.code = code;
921
+ customer.updatedById = scope.userId ?? null;
922
+ customer = await customerRepo.save(customer);
923
+ }
924
+ }
925
+ const clauseRepo = manager.getRepository(CustomerContractClause);
926
+ const clauseUpdates = input.clauses ?? [];
927
+ const currentClauses = clauseUpdates.length ? await clauseRepo.find({ where: { ...activeScope(scope), customerContractId: id, deletedAt: IsNull() } }) : [];
928
+ const currentClausesByType = new Map(currentClauses.map(clause => [clause.clauseType, clause]));
929
+ if (customerContractAnalysisInputChanged(currentClausesByType, clauseUpdates)) {
930
+ await invalidateCustomerContractReanalysis(manager, scope, { reviewSource: 'EXISTING_CONTRACT', contract });
931
+ contract.latestAnalysisRunId = null;
932
+ contract.overallRiskLevel = null;
933
+ contract.updatedById = scope.userId ?? null;
934
+ await contractRepo.save(contract);
363
935
  }
364
936
  if (input.contract) {
937
+ if ('contractNo' in input.contract) {
938
+ const parsed = parseCustomerContractNo(String(input.contract.contractNo ?? ''));
939
+ const duplicate = await contractRepo.findOne({
940
+ where: { ...activeScope(scope), normalizedContractNo: parsed.normalized, deletedAt: IsNull() }
941
+ });
942
+ if (duplicate && duplicate.id !== contract.id) {
943
+ throw new DomainError(DomainErrorCodes.DUPLICATE_CONTRACT, '客户合同已存在,如需重新上传请先删除旧记录', {
944
+ id: duplicate.id,
945
+ contractNo: parsed.normalized
946
+ });
947
+ }
948
+ contract.contractNo = parsed.normalized;
949
+ contract.normalizedContractNo = parsed.normalized;
950
+ }
365
951
  Object.assign(contract, pick(input.contract, ['contractAmountReviewCny', 'signedAt']));
366
952
  contract.updatedById = scope.userId ?? null;
367
- await contractRepo.save(contract);
953
+ try {
954
+ await contractRepo.save(contract);
955
+ }
956
+ catch (error) {
957
+ if ('contractNo' in input.contract && isPostgresUniqueViolation(error)) {
958
+ throw new DomainError(DomainErrorCodes.DUPLICATE_CONTRACT, '客户合同已存在,如需重新上传请先删除旧记录', {
959
+ contractNo: contract.normalizedContractNo
960
+ });
961
+ }
962
+ throw error;
963
+ }
368
964
  }
369
- const clauseRepo = manager.getRepository(CustomerContractClause);
370
- for (const update of input.clauses ?? []) {
371
- const clause = await required(clauseRepo.findOne({ where: { ...activeScope(scope), id: update.id, customerContractId: id, deletedAt: IsNull() } }), '客户合同条款不存在');
372
- clause.reviewedText = String(update.reviewedText ?? '').trim() || null;
373
- clause.extractionStatus = clause.reviewedText ? 'FOUND' : 'NOT_FOUND';
965
+ for (const update of clauseUpdates) {
966
+ const clause = currentClausesByType.get(update.clauseType);
967
+ if (!clause || clause.id !== update.id)
968
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '客户合同条款不存在');
969
+ const normalized = normalizeCustomerContractAnalysisInput(update);
970
+ clause.reviewedText = normalized.reviewedText;
971
+ clause.extractionStatus = normalized.extractionStatus;
374
972
  clause.updatedById = scope.userId ?? null;
375
973
  await clauseRepo.save(clause);
376
974
  }
@@ -379,108 +977,229 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
379
977
  await this.recalculateCompanyRisks(scope, 'SOURCE_CHANGED');
380
978
  return saved;
381
979
  }
382
- async listDerivedSalesOrders(scope, customerId) {
383
- const customerRepo = this.dataSource.getRepository(Customer);
384
- const contractRepo = this.dataSource.getRepository(CustomerContract);
385
- const purchaseRepo = this.dataSource.getRepository(PurchaseOrder);
386
- const lineRepo = this.dataSource.getRepository(PurchaseOrderLine);
387
- const manualRepo = this.dataSource.getRepository(DerivedSalesOrderManualData);
388
- const customers = await customerRepo.find({ where: { ...activeScope(scope), ...(customerId ? { id: customerId } : {}), deletedAt: IsNull() } });
980
+ async listDerivedSalesOrders(scope, customerId, selectedIdentities) {
981
+ const identities = selectedIdentities ?? await this.selectAllDerivedSalesOrderIdentities(scope, customerId, {}, false);
982
+ if (!identities.length)
983
+ return [];
984
+ const contractIds = [...new Set(identities.map(identity => identity.customerContractId))];
985
+ const purchaseIds = [...new Set(identities.map(identity => identity.purchaseOrderId))];
986
+ const selectedKeys = new Set(identities.map(identity => derivedOrderKey(identity.customerContractId, identity.purchaseOrderId)));
987
+ const [customers, contracts, purchases, manuals, lines] = await Promise.all([
988
+ this.dataSource.getRepository(Customer).find({ where: { ...activeScope(scope), ...(customerId ? { id: customerId } : {}), deletedAt: IsNull() } }),
989
+ this.dataSource.getRepository(CustomerContract).find({ where: { ...activeScope(scope), id: In(contractIds), ...(customerId ? { customerId } : {}), deletedAt: IsNull() } }),
990
+ this.dataSource.getRepository(PurchaseOrder).find({ where: { ...activeScope(scope), id: In(purchaseIds), deletedAt: IsNull() } }),
991
+ this.dataSource.getRepository(DerivedSalesOrderManualData).find({ where: { ...activeScope(scope), customerContractId: In(contractIds), purchaseOrderId: In(purchaseIds), deletedAt: IsNull() } }),
992
+ this.dataSource.getRepository(PurchaseOrderLine).find({ where: { ...activeScope(scope), purchaseOrderId: In(purchaseIds), deletedAt: IsNull() }, order: { lineNumber: 'ASC' } })
993
+ ]);
389
994
  const customerMap = new Map(customers.map(customer => [customer.id, customer]));
390
- const contracts = await contractRepo.find({ where: { ...activeScope(scope), ...(customerId ? { customerId } : {}), deletedAt: IsNull() } });
391
- const purchases = await purchaseRepo.find({ where: { ...activeScope(scope), deletedAt: IsNull() } });
995
+ const manualMap = new Map(manuals.map(manual => [derivedOrderKey(manual.customerContractId, manual.purchaseOrderId), manual]));
996
+ const linesByPurchase = groupBy(lines, line => line.purchaseOrderId);
997
+ const riskRows = lines.length ? await this.dataSource.getRepository(ProductComplianceMatch).find({ where: { ...activeScope(scope), purchaseOrderLineId: In(lines.map(line => line.id)), deletedAt: IsNull() } }) : [];
998
+ const controlledGoodsIds = [...new Set(riskRows.map(match => match.controlledGoodsId))];
999
+ const controlledGoods = controlledGoodsIds.length ? await this.dataSource.getRepository(ControlledGoodsRecord).find({ where: { ...activeScope(scope), id: In(controlledGoodsIds), deletedAt: IsNull() } }) : [];
1000
+ const controlledGoodsById = new Map(controlledGoods.map(item => [item.id, item]));
1001
+ const risksByLine = groupBy(riskRows, match => match.purchaseOrderLineId);
1002
+ const purchasesByBaseCode = groupBy(purchases, purchase => parsePurchaseContractNo(purchase.contractNo).baseCode);
392
1003
  const results = [];
393
1004
  for (const contract of contracts) {
394
- const customer = customerMap.get(contract.customerId) ?? await customerRepo.findOne({ where: { ...activeScope(scope), id: contract.customerId, deletedAt: IsNull() } });
1005
+ const customer = customerMap.get(contract.customerId);
395
1006
  if (!customer)
396
1007
  continue;
397
1008
  const baseCode = parseCustomerContractNo(contract.contractNo).baseCode;
398
- for (const purchase of purchases.filter(item => parsePurchaseContractNo(item.contractNo).baseCode === baseCode)) {
399
- const manual = await manualRepo.findOne({ where: { ...activeScope(scope), customerContractId: contract.id, purchaseOrderId: purchase.id, deletedAt: IsNull() } });
400
- const effectiveRate = manual?.pricingProfitRateOverride ?? customer.defaultPricingProfitRate;
1009
+ for (const purchase of purchasesByBaseCode.get(baseCode) ?? []) {
1010
+ if (!selectedKeys.has(derivedOrderKey(contract.id, purchase.id)))
1011
+ continue;
1012
+ const manual = manualMap.get(derivedOrderKey(contract.id, purchase.id));
1013
+ const effectiveRate = manual?.pricingProfitRateOverride ?? (customer.pricingProfitRateStatus === 'CONFIGURED' ? customer.defaultPricingProfitRate : null);
401
1014
  if (effectiveRate == null)
402
1015
  continue;
403
1016
  const source = manual?.pricingProfitRateOverride != null ? 'ORDER_OVERRIDE' : 'CUSTOMER_DEFAULT';
404
- const lines = await lineRepo.find({ where: { ...activeScope(scope), purchaseOrderId: purchase.id, deletedAt: IsNull() }, order: { lineNumber: 'ASC' } });
405
- const riskRows = lines.length ? await this.dataSource.getRepository(ProductComplianceMatch).find({ where: { ...activeScope(scope), purchaseOrderLineId: In(lines.map(line => line.id)), deletedAt: IsNull() } }) : [];
406
- const derivedLines = lines.map(line => {
1017
+ const derivedLines = (linesByPurchase.get(purchase.id) ?? []).map(line => {
407
1018
  const pricing = deriveSalesLine({ purchaseUnitPriceCny: line.unitPriceTaxIncluded, quantity: line.quantity }, effectiveRate);
408
- return { purchaseOrderLineId: line.id, productName: line.productName, englishName: line.englishName, productDescription: line.productDescription, modelSpecification: line.modelSpecification, unit: line.unit, quantity: line.quantity, hsCode: line.hsCode, ...pricing, complianceMatches: riskRows.filter(match => match.purchaseOrderLineId === line.id).map(match => ({ controlledGoodsId: match.controlledGoodsId, catalogType: match.catalogType, matchMethod: match.matchMethod, similarityScore: match.similarityScore })) };
1019
+ return {
1020
+ purchaseOrderLineId: line.id, productName: line.productName, englishName: line.englishName, productDescription: line.productDescription,
1021
+ modelSpecification: line.modelSpecification, unit: line.unit, quantity: line.quantity, hsCode: line.hsCode, hsCodeStatus: line.hsCodeStatus,
1022
+ vatRate: line.vatRate, refundRate: line.refundRate, originCountry: line.originCountry, domesticSourceLocation: line.domesticSourceLocation,
1023
+ ...pricing,
1024
+ complianceMatches: (risksByLine.get(line.id) ?? []).map(match => {
1025
+ const controlledItem = controlledGoodsById.get(match.controlledGoodsId);
1026
+ return {
1027
+ controlledGoodsId: match.controlledGoodsId,
1028
+ catalogType: match.catalogType,
1029
+ matchMethod: match.matchMethod,
1030
+ similarityScore: match.similarityScore,
1031
+ matchedItemName: controlledItem?.productName ?? null,
1032
+ riskDescription: controlledItem?.controlDescription ?? null,
1033
+ recommendation: CATALOG_RISK_RECOMMENDATIONS[match.catalogType]
1034
+ };
1035
+ })
1036
+ };
409
1037
  });
410
1038
  const amount = derivedLines.reduce((sum, line) => sum.plus(line.salesLineAmountCny), new Decimal(0));
411
1039
  const received = new Decimal(manual?.receivedAmountCny ?? 0);
412
1040
  results.push({ customerContractId: contract.id, purchaseOrderId: purchase.id, customerId: customer.id, salesOrderNo: parsePurchaseContractNo(purchase.contractNo).salesOrderNo, customerContractNo: contract.contractNo, purchaseContractNo: purchase.contractNo, orderDate: purchase.orderDate, currencyCode: 'CNY', pricingProfitRateSource: source, effectivePricingProfitRate: effectiveRate, orderAmountCny: amount.toFixed(0), receivedAmountCny: received.toFixed(2), unreceivedAmountCny: Decimal.max(amount.minus(received), 0).toFixed(2), lines: derivedLines });
413
1041
  }
414
1042
  }
415
- return results;
1043
+ return results.sort(compareDerivedSalesOrders);
1044
+ }
1045
+ derivedSalesIdentityQuery(scope, customerId, parameters, includeParties) {
1046
+ const qb = this.dataSource.getRepository(CustomerContract).createQueryBuilder('contract')
1047
+ .innerJoin(Customer, 'customer', 'customer.id = contract."customerId" AND customer."tenantId" = contract."tenantId" AND customer."organizationId" = contract."organizationId" AND customer."deletedAt" IS NULL')
1048
+ .innerJoin(PurchaseOrder, 'purchase', `purchase."tenantId" = contract."tenantId" AND purchase."organizationId" = contract."organizationId" AND purchase."deletedAt" IS NULL AND contract."normalizedContractNo" = regexp_replace(purchase."normalizedContractNo", '(-[0-9]+)?C$', '') || 'X'`)
1049
+ .leftJoin(DerivedSalesOrderManualData, 'manual', 'manual."customerContractId" = contract.id AND manual."purchaseOrderId" = purchase.id AND manual."tenantId" = contract."tenantId" AND manual."organizationId" = contract."organizationId" AND manual."deletedAt" IS NULL')
1050
+ .leftJoin(Supplier, 'supplier', 'supplier.id = purchase."supplierId" AND supplier."tenantId" = purchase."tenantId" AND supplier."organizationId" = purchase."organizationId" AND supplier."deletedAt" IS NULL')
1051
+ .where('contract."tenantId" = :tenantId AND contract."organizationId" = :organizationId AND contract."deletedAt" IS NULL', activeScope(scope))
1052
+ .andWhere("(manual.\"pricingProfitRateOverride\" IS NOT NULL OR (customer.\"pricingProfitRateStatus\" = 'CONFIGURED' AND customer.\"defaultPricingProfitRate\" IS NOT NULL))")
1053
+ .select('contract.id', 'customerContractId').addSelect('purchase.id', 'purchaseOrderId');
1054
+ if (customerId)
1055
+ qb.andWhere('customer.id = :customerId', { customerId });
1056
+ const fuzzy = (expression, key) => { if (hasQueryValue(parameters[key]))
1057
+ qb.andWhere(`${expression} ILIKE :${key}`, { [key]: `%${String(parameters[key]).trim()}%` }); };
1058
+ fuzzy(`regexp_replace(purchase."contractNo", 'C$', 'X')`, 'salesOrderNo');
1059
+ fuzzy('contract."contractNo"', 'customerContractNo');
1060
+ fuzzy('purchase."contractNo"', 'purchaseContractNo');
1061
+ if (includeParties) {
1062
+ fuzzy('customer.name', 'customerName');
1063
+ fuzzy('supplier.name', 'supplierName');
1064
+ }
1065
+ const date = parameters.orderDate && typeof parameters.orderDate === 'object' && !Array.isArray(parameters.orderDate) ? parameters.orderDate : {};
1066
+ if (hasQueryValue(date.from))
1067
+ qb.andWhere('purchase."orderDate" >= :orderDateFrom', { orderDateFrom: date.from });
1068
+ if (hasQueryValue(date.to))
1069
+ qb.andWhere('purchase."orderDate" <= :orderDateTo', { orderDateTo: date.to });
1070
+ const effectiveRate = 'COALESCE(manual."pricingProfitRateOverride", customer."defaultPricingProfitRate")';
1071
+ const amount = `(SELECT COALESCE(SUM(ROUND(ROUND(sales_line."unitPriceTaxIncluded" * (1 + ${effectiveRate}), 0) * sales_line.quantity, 0)), 0) FROM plugin_trade_compliance_purchase_order_line sales_line WHERE sales_line."purchaseOrderId" = purchase.id AND sales_line."tenantId" = purchase."tenantId" AND sales_line."organizationId" = purchase."organizationId" AND sales_line."deletedAt" IS NULL)`;
1072
+ const received = 'COALESCE(manual."receivedAmountCny", 0)';
1073
+ if (parameters.paymentStatus === 'UNPAID')
1074
+ qb.andWhere(`${received} <= 0`);
1075
+ if (parameters.paymentStatus === 'PARTIAL')
1076
+ qb.andWhere(`${received} > 0 AND ${received} < ${amount}`);
1077
+ if (parameters.paymentStatus === 'PAID')
1078
+ qb.andWhere(`${received} >= ${amount}`);
1079
+ return qb;
1080
+ }
1081
+ async selectAllDerivedSalesOrderIdentities(scope, customerId, parameters, includeParties) {
1082
+ return this.derivedSalesIdentityQuery(scope, customerId, parameters, includeParties).orderBy('contract.id', 'ASC').addOrderBy('purchase.id', 'ASC').getRawMany();
1083
+ }
1084
+ async selectDerivedSalesOrderIdentities(scope, customerId, query, includeParties) {
1085
+ const pageSize = Math.min(200, Math.max(1, Number(query.pageSize ?? 20)));
1086
+ const identityQuery = this.derivedSalesIdentityQuery(scope, customerId, query.parameters ?? {}, includeParties);
1087
+ const identitySql = identityQuery.getQuery();
1088
+ const identityParameters = identityQuery.getParameters();
1089
+ const countRow = await this.dataSource.createQueryBuilder().select('COUNT(*)', 'total').from(`(${identitySql})`, 'identity').setParameters(identityParameters).getRawOne();
1090
+ const total = Number(countRow?.total ?? 0);
1091
+ const pageCount = Math.max(1, Math.ceil(total / pageSize));
1092
+ const page = Math.min(pageCount, Math.max(1, Number(query.page ?? 1)));
1093
+ const items = await this.dataSource.createQueryBuilder()
1094
+ .select('identity."customerContractId"', 'customerContractId').addSelect('identity."purchaseOrderId"', 'purchaseOrderId')
1095
+ .from(`(${identitySql})`, 'identity').setParameters(identityParameters)
1096
+ .orderBy('identity."customerContractId"', 'ASC').addOrderBy('identity."purchaseOrderId"', 'ASC')
1097
+ .offset((page - 1) * pageSize).limit(pageSize).getRawMany();
1098
+ return { items: items, total, page, pageSize };
416
1099
  }
417
1100
  async saveCustomerProfitRate(scope, customerId, rate) {
418
- const repo = this.dataSource.getRepository(Customer);
419
- const customer = await required(repo.findOne({ where: { ...activeScope(scope), id: customerId, deletedAt: IsNull() } }), '客户不存在');
420
1101
  if (new Decimal(rate).isNegative())
421
1102
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '客户默认利润率不能小于 0');
422
- await this.assertExistingReceiptsRemainValid(scope, customerId, rate);
423
- customer.defaultPricingProfitRate = rate;
424
- customer.updatedById = scope.userId ?? null;
425
- return repo.save(customer);
1103
+ return this.dataSource.transaction(async (manager) => {
1104
+ const customerRepo = manager.getRepository(Customer);
1105
+ const customer = await required(customerRepo.findOne({ where: { ...activeScope(scope), id: customerId, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } }), '客户不存在');
1106
+ const contracts = await manager.getRepository(CustomerContract).find({ where: { ...activeScope(scope), customerId, deletedAt: IsNull() }, order: { id: 'ASC' } });
1107
+ const baseCodes = new Set(contracts.map(contract => parseCustomerContractNo(contract.contractNo).baseCode));
1108
+ const purchaseRepo = manager.getRepository(PurchaseOrder);
1109
+ const relatedIds = (await purchaseRepo.find({ where: { ...activeScope(scope), deletedAt: IsNull() } })).filter(purchase => baseCodes.has(parsePurchaseContractNo(purchase.contractNo).baseCode)).map(purchase => purchase.id).sort();
1110
+ const purchases = relatedIds.length ? await purchaseRepo.find({ where: { ...activeScope(scope), id: In(relatedIds), deletedAt: IsNull() }, order: { id: 'ASC' }, lock: { mode: 'pessimistic_write' } }) : [];
1111
+ const purchaseIds = purchases.map(purchase => purchase.id);
1112
+ const lines = purchaseIds.length ? await manager.getRepository(PurchaseOrderLine).find({ where: { ...activeScope(scope), purchaseOrderId: In(purchaseIds), deletedAt: IsNull() }, order: { purchaseOrderId: 'ASC', id: 'ASC' }, lock: { mode: 'pessimistic_write' } }) : [];
1113
+ const contractIds = contracts.map(contract => contract.id);
1114
+ const manuals = contractIds.length && purchaseIds.length ? await manager.getRepository(DerivedSalesOrderManualData).find({ where: { ...activeScope(scope), customerContractId: In(contractIds), purchaseOrderId: In(purchaseIds), deletedAt: IsNull() }, order: { customerContractId: 'ASC', purchaseOrderId: 'ASC' }, lock: { mode: 'pessimistic_write' } }) : [];
1115
+ const linesByPurchase = groupBy(lines, line => line.purchaseOrderId);
1116
+ for (const manual of manuals.filter(item => item.pricingProfitRateOverride == null)) {
1117
+ const orderAmount = derivedOrderAmount(linesByPurchase.get(manual.purchaseOrderId) ?? [], rate);
1118
+ assertReceivedAmount(orderAmount, manual.receivedAmountCny);
1119
+ }
1120
+ customer.defaultPricingProfitRate = rate;
1121
+ customer.updatedById = scope.userId ?? null;
1122
+ customer.pricingProfitRateStatus = 'CONFIGURED';
1123
+ return customerRepo.save(customer);
1124
+ });
426
1125
  }
427
1126
  async updateCustomer(scope, id, input) {
428
- const repo = this.dataSource.getRepository(Customer);
429
- const item = await required(repo.findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() } }), '客户不存在');
430
- Object.assign(item, pick(input, ['code', 'name', 'shortName', 'country', 'address', 'contactName', 'phone', 'wechat', 'email', 'taxNumber', 'paymentAccount', 'bankName', 'taxpayerRating', 'website', 'businessProducts']));
431
- item.normalizedName = normalizeExactName(item.name);
432
- item.updatedById = scope.userId ?? null;
433
- const saved = await repo.save(item);
1127
+ const saved = await this.dataSource.transaction(async (manager) => {
1128
+ const repo = manager.getRepository(Customer);
1129
+ const item = await required(repo.findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } }), '客户不存在');
1130
+ const code = await this.lockAndAssertPartyCodeAvailable(manager, scope, Customer, 'code' in input ? input.code : item.code, item.id);
1131
+ Object.assign(item, pick(input, ['name', 'shortName', 'country', 'address', 'contactName', 'phone', 'wechat', 'email', 'taxNumber', 'paymentAccount', 'bankName', 'taxpayerRating', 'website', 'businessProducts']));
1132
+ item.code = code;
1133
+ item.normalizedName = normalizeExactName(item.name);
1134
+ item.updatedById = scope.userId ?? null;
1135
+ return repo.save(item);
1136
+ });
434
1137
  await this.recalculateCompanyRisks(scope, 'SOURCE_CHANGED');
435
1138
  return saved;
436
1139
  }
437
1140
  async saveDerivedManualData(scope, customerContractId, purchaseOrderId, input) {
438
- const repo = this.dataSource.getRepository(DerivedSalesOrderManualData);
439
- let row = await repo.findOne({ where: { ...activeScope(scope), customerContractId, purchaseOrderId, deletedAt: IsNull() } });
440
- if (!row)
441
- row = repo.create({ ...auditScope(scope), customerContractId, purchaseOrderId, pricingProfitRateOverride: null, receivedAmountCny: '0' });
442
- const override = input.pricingProfitRateOverride === undefined ? row.pricingProfitRateOverride : normalizeOptionalDecimalInput(input.pricingProfitRateOverride);
443
- if (override != null && new Decimal(override).isNegative())
444
- throw new DomainError(DomainErrorCodes.INVALID_INPUT, '订单利润率不能小于 0');
445
- row.pricingProfitRateOverride = override;
446
- const derived = await this.deriveSingleOrder(scope, customerContractId, purchaseOrderId, row.pricingProfitRateOverride);
447
- const received = input.receivedAmountCny ?? row.receivedAmountCny;
448
- assertReceivedAmount(derived.orderAmountCny, received);
449
- row.receivedAmountCny = received;
450
- row.updatedById = scope.userId ?? null;
451
- return repo.save(row);
1141
+ return this.dataSource.transaction(async (manager) => {
1142
+ const contract = await required(manager.getRepository(CustomerContract).findOne({ where: { ...activeScope(scope), id: customerContractId, deletedAt: IsNull() } }), '客户合同不存在');
1143
+ const customer = await required(manager.getRepository(Customer).findOne({ where: { ...activeScope(scope), id: contract.customerId, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } }), '客户不存在');
1144
+ const purchase = await required(manager.getRepository(PurchaseOrder).findOne({ where: { ...activeScope(scope), id: purchaseOrderId, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } }), '采购订单不存在');
1145
+ if (parseCustomerContractNo(contract.contractNo).baseCode !== parsePurchaseContractNo(purchase.contractNo).baseCode)
1146
+ throw new DomainError(DomainErrorCodes.DERIVED_LINK_CONFLICT, '客户合同与采购订单未形成派生销售订单');
1147
+ const lines = await manager.getRepository(PurchaseOrderLine).find({ where: { ...activeScope(scope), purchaseOrderId, deletedAt: IsNull() }, order: { id: 'ASC' }, lock: { mode: 'pessimistic_write' } });
1148
+ const repo = manager.getRepository(DerivedSalesOrderManualData);
1149
+ const where = { ...activeScope(scope), customerContractId, purchaseOrderId, deletedAt: IsNull() };
1150
+ let row = await repo.findOne({ where, lock: { mode: 'pessimistic_write' } });
1151
+ if (!row) {
1152
+ await repo.createQueryBuilder().insert().into(DerivedSalesOrderManualData).values({ ...auditScope(scope), customerContractId, purchaseOrderId, pricingProfitRateOverride: null, receivedAmountCny: '0' }).orIgnore().execute();
1153
+ row = await required(repo.findOne({ where, lock: { mode: 'pessimistic_write' } }), '销售订单人工维护数据创建失败');
1154
+ }
1155
+ const override = input.pricingProfitRateOverride === undefined ? row.pricingProfitRateOverride : normalizeOptionalDecimalInput(input.pricingProfitRateOverride);
1156
+ if (override != null && new Decimal(override).isNegative())
1157
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '订单利润率不能小于 0');
1158
+ const effectiveRate = override ?? (customer.pricingProfitRateStatus === 'CONFIGURED' ? customer.defaultPricingProfitRate : null);
1159
+ if (effectiveRate == null)
1160
+ throw new DomainError(DomainErrorCodes.CUSTOMER_PROFIT_RATE_REQUIRED, '客户默认利润率未维护');
1161
+ const received = input.receivedAmountCny ?? row.receivedAmountCny;
1162
+ assertReceivedAmount(derivedOrderAmount(lines, effectiveRate), received);
1163
+ if (input.pricingProfitRateOverride !== undefined)
1164
+ row.pricingProfitRateOverride = override;
1165
+ if (input.receivedAmountCny !== undefined)
1166
+ row.receivedAmountCny = input.receivedAmountCny;
1167
+ row.updatedById = scope.userId ?? null;
1168
+ return repo.save(row);
1169
+ });
452
1170
  }
453
1171
  async softDelete(scope, entityType, id) {
454
- const map = { controlledGoods: ControlledGoodsRecord, sanctionedCompany: SanctionedCompanyRecord, supplier: Supplier, purchaseOrder: PurchaseOrder, purchaseLine: PurchaseOrderLine, customer: Customer, customerContract: CustomerContract, generatedFile: GeneratedSalesFile, exchangeRate: MonthlySafeExchangeRate };
1172
+ const map = { controlledGoods: ControlledGoodsRecord, sanctionedCompany: SanctionedCompanyRecord, supplier: Supplier, purchaseOrder: PurchaseOrder, purchaseLine: PurchaseOrderLine, customer: Customer, customerContract: CustomerContract, generatedFile: GeneratedSalesFile };
455
1173
  const entity = map[entityType];
456
1174
  if (!entity)
457
1175
  throw new DomainError(DomainErrorCodes.INVALID_INPUT, '不支持的删除类型', { entityType });
458
- if (entity === Customer) {
459
- const count = await this.dataSource.getRepository(CustomerContract).count({ where: { ...activeScope(scope), customerId: id, deletedAt: IsNull() } });
460
- if (count)
461
- throw new DomainError(DomainErrorCodes.INVALID_INPUT, '客户存在有效合同,不能删除', { contractCount: count });
462
- }
463
- if (entity === Supplier) {
464
- const count = await this.dataSource.getRepository(PurchaseOrder).count({ where: { ...activeScope(scope), supplierId: id, deletedAt: IsNull() } });
465
- if (count)
466
- throw new DomainError(DomainErrorCodes.INVALID_INPUT, '供应商存在有效采购订单,不能删除', { orderCount: count });
467
- }
468
- const repo = this.dataSource.getRepository(entity);
469
- const row = await required(repo.findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() } }), '记录不存在');
470
1176
  if (entity === PurchaseOrderLine)
471
- await this.assertPurchaseOrderReceiptsRemainValid(scope, row.purchaseOrderId, undefined, row.id);
472
- row.deletedAt = new Date();
473
- row.deletedById = scope.userId ?? null;
474
- row.deleteOperation = 'USER_DELETE';
475
- row.updatedById = scope.userId ?? null;
476
- await repo.save(row);
477
- if (entity === PurchaseOrder)
478
- await this.cascadePurchaseOrder(scope, id);
479
- if (entity === CustomerContract)
480
- await this.cascadeCustomerContract(scope, id);
481
- if (entity === PurchaseOrderLine) {
482
- await this.dataSource.getRepository(ProductComplianceMatch).update({ ...activeScope(scope), purchaseOrderLineId: id, deletedAt: IsNull() }, { deletedAt: new Date(), invalidationReason: 'SOURCE_CHANGED' });
483
- }
1177
+ return this.deletePurchaseLine(scope, id);
1178
+ const row = await this.dataSource.transaction(async (manager) => {
1179
+ if (entity === Customer) {
1180
+ const count = await manager.getRepository(CustomerContract).count({ where: { ...activeScope(scope), customerId: id, deletedAt: IsNull() } });
1181
+ if (count)
1182
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '客户存在有效合同,不能删除', { contractCount: count });
1183
+ }
1184
+ if (entity === Supplier) {
1185
+ const count = await manager.getRepository(PurchaseOrder).count({ where: { ...activeScope(scope), supplierId: id, deletedAt: IsNull() } });
1186
+ if (count)
1187
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '供应商存在有效采购订单,不能删除', { orderCount: count });
1188
+ }
1189
+ const repo = manager.getRepository(entity);
1190
+ const target = await required(repo.findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } }), '记录不存在');
1191
+ const now = new Date();
1192
+ target.deletedAt = now;
1193
+ target.deletedById = scope.userId ?? null;
1194
+ target.deleteOperation = 'USER_DELETE';
1195
+ target.updatedById = scope.userId ?? null;
1196
+ await repo.save(target);
1197
+ if (entity === PurchaseOrder)
1198
+ await this.cascadePurchaseOrder(manager, scope, id, now);
1199
+ if (entity === CustomerContract)
1200
+ await this.cascadeCustomerContract(manager, scope, id, now);
1201
+ return target;
1202
+ });
484
1203
  if (entity === ControlledGoodsRecord)
485
1204
  await this.recalculateProductRisks(scope, 'CATALOG_CHANGED');
486
1205
  if (entity === SanctionedCompanyRecord)
@@ -489,118 +1208,354 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
489
1208
  }
490
1209
  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 }); }
491
1210
  async settings(scope) {
492
- const [{ risk, profit }, company, banks, rates] = await Promise.all([
493
- this.bootstrapSettings(scope),
1211
+ await this.bootstrapSettings(scope);
1212
+ const [risk, profit, company, banks, rates] = await Promise.all([
1213
+ this.dataSource.getRepository(RiskNoticeSettings).findOne({ where: activeScope(scope) }),
1214
+ this.dataSource.getRepository(ProfitRuleSettings).findOne({ where: activeScope(scope) }),
494
1215
  this.dataSource.getRepository(CompanySettings).findOne({ where: activeScope(scope) }),
495
1216
  this.dataSource.getRepository(BankAccountSettings).find({ where: activeScope(scope), order: { bankType: 'ASC' } }),
496
1217
  this.dataSource.getRepository(MonthlySafeExchangeRate).find({ where: { ...activeScope(scope), deletedAt: IsNull() }, order: { year: 'DESC', month: 'DESC' } })
497
1218
  ]);
498
1219
  return { risk, company, banks, profit, rates };
499
1220
  }
500
- async saveSettings(scope, type, input) {
501
- const entityMap = { risk: RiskNoticeSettings, company: CompanySettings, bank: BankAccountSettings, profit: ProfitRuleSettings, rate: MonthlySafeExchangeRate };
502
- const entity = entityMap[type];
503
- const repo = this.dataSource.getRepository(entity);
504
- const where = { ...activeScope(scope) };
505
- if (type === 'bank')
506
- where.bankType = input.bankType;
507
- if (type === 'rate') {
508
- where.year = input.year;
509
- where.month = input.month;
510
- where.currencyCode = 'USD';
511
- where.deletedAt = IsNull();
1221
+ async saveSettings(scope, type, input, expectedRevision) {
1222
+ if (type === 'risk') {
1223
+ const changes = {};
1224
+ if ('content' in input)
1225
+ changes.content = sanitizeRiskNoticeContent(input.content);
1226
+ return this.persistSettings(scope, 'RISK', RiskNoticeSettings, activeScope(scope), settingsCreateScope(scope), changes, expectedRevision);
512
1227
  }
513
- let row = await repo.findOne({ where });
514
- const before = row ? { ...row } : null;
515
- if (!row)
516
- row = repo.create({ ...auditScope(scope), ...(type === 'rate' ? { currencyCode: 'USD' } : {}) });
517
- const allowed = { risk: ['content'], company: ['logoFileId', 'companyNameZh', 'companyNameEn', 'unifiedSocialCreditCode', 'addressZh', 'addressEn', 'phone', 'email'], bank: ['bankType', 'beneficiary', 'bankName', 'bankAddress', 'cnapsCode', 'accountNo', 'swiftCode'], profit: ['status', 'ruleVersion', 'minimumOrderProfitRate', 'exchangeCostMinimum', 'exchangeCostMaximum'], rate: ['year', 'month', 'usdToCnyRate', 'sourceReference'] };
518
- Object.assign(row, pick(input, allowed[type]));
519
- row.updatedById = scope.userId ?? null;
520
- row.revision = nextSettingsRevision(before?.revision);
521
- const saved = await repo.save(row);
522
- await this.dataSource.getRepository(SettingsHistory).save(this.dataSource.getRepository(SettingsHistory).create({ ...activeScope(scope), settingsType: type.toUpperCase(), settingsId: saved.id, operationType: 'EDIT', snapshotVersion: 'v1', beforeRevision: before?.revision ?? 0, afterRevision: saved.revision ?? 1, beforeSnapshot: before, afterSnapshot: saved, operatedById: scope.userId ?? null }));
523
- return saved;
1228
+ if (type === 'company') {
1229
+ const changes = {};
1230
+ for (const field of ['logoFileId', 'companyNameZh', 'companyNameEn', 'unifiedSocialCreditCode', 'addressZh', 'addressEn', 'phone', 'email'])
1231
+ if (field in input)
1232
+ changes[field] = settingNullableString(input[field], field);
1233
+ return this.persistCompanySettings(scope, changes, expectedRevision);
1234
+ }
1235
+ if (type === 'bank') {
1236
+ const values = validateBankSettingsInput(input);
1237
+ return this.persistSettings(scope, 'BANK', BankAccountSettings, { ...activeScope(scope), bankType: values.bankType }, { ...settingsCreateScope(scope), bankType: values.bankType }, values, expectedRevision);
1238
+ }
1239
+ if (type === 'profit') {
1240
+ const values = validateProfitSettingsInput(input);
1241
+ return this.persistSettings(scope, 'PROFIT', ProfitRuleSettings, activeScope(scope), { ...settingsCreateScope(scope), ...DEFAULT_PROFIT_RULE }, values, expectedRevision);
1242
+ }
1243
+ const parsed = parseExchangeRateInput(input);
1244
+ const sourceReference = 'sourceReference' in input ? settingString(input.sourceReference, '来源说明').trim() : '人工汇率';
1245
+ if (!sourceReference)
1246
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '来源说明为必填项');
1247
+ return this.persistMonthlyRate(scope, input, { ...parsed, sourceReference }, expectedRevision);
524
1248
  }
525
- async analytics(scope, filters = {}) {
526
- const purchases = await this.dataSource.getRepository(PurchaseOrder).find({ where: { ...activeScope(scope), deletedAt: IsNull() } });
527
- const suppliers = await this.dataSource.getRepository(Supplier).find({ where: { ...activeScope(scope), deletedAt: IsNull() } });
528
- const lines = await this.dataSource.getRepository(PurchaseOrderLine).find({ where: { ...activeScope(scope), deletedAt: IsNull() } });
529
- const derived = await this.listDerivedSalesOrders(scope);
530
- const supplierMap = new Map(suppliers.map(item => [item.id, item.name]));
531
- const search = String(filters.search ?? '').trim().toLocaleLowerCase();
532
- const dateFrom = String(filters.orderDateFrom ?? '').trim();
533
- const dateTo = String(filters.orderDateTo ?? '').trim();
534
- const unpaidOnly = filters.unpaidOnly === true || filters.unpaidOnly === 'true';
535
- const unreceivedOnly = filters.unreceivedOnly === true || filters.unreceivedOnly === 'true';
536
- const purchaseRows = purchases.map(order => ({ ...order, supplierName: supplierMap.get(order.supplierId) ?? '', unpaidAmountCny: Decimal.max(new Decimal(order.contractAmountCny).minus(order.paidAmountCny), 0).toFixed(2), unrefundedAmountCny: this.unrefundedAmount(order, lines.filter(line => line.purchaseOrderId === order.id)) })).filter(row => {
537
- const orderLines = lines.filter(line => line.purchaseOrderId === row.id);
538
- const text = [row.contractNo, row.supplierName, ...orderLines.flatMap(line => [line.productName, line.hsCode])].filter(Boolean).join(' ').toLocaleLowerCase();
539
- return (!search || text.includes(search)) && (!dateFrom || String(row.orderDate ?? '') >= dateFrom) && (!dateTo || String(row.orderDate ?? '') <= dateTo) && (!unpaidOnly || new Decimal(row.unpaidAmountCny).gt(0));
540
- });
541
- const customerNames = new Map((await this.dataSource.getRepository(Customer).find({ where: { ...activeScope(scope), deletedAt: IsNull() } })).map(item => [item.id, item.name]));
542
- const salesRows = derived.filter(row => {
543
- const text = [row.salesOrderNo, row.purchaseContractNo, row.customerContractNo, customerNames.get(row.customerId), ...row.lines.flatMap(line => [line.productName, line.hsCode])].filter(Boolean).join(' ').toLocaleLowerCase();
544
- return (!search || text.includes(search)) && (!dateFrom || String(row.orderDate ?? '') >= dateFrom) && (!dateTo || String(row.orderDate ?? '') <= dateTo) && (!unreceivedOnly || new Decimal(row.unreceivedAmountCny).gt(0));
545
- });
546
- const purchaseIds = new Set(purchaseRows.map(row => row.id));
547
- const filteredLines = lines.filter(line => purchaseIds.has(line.purchaseOrderId));
548
- const currentYear = new Date().getFullYear();
549
- return {
550
- purchase: summarizeRows(purchaseRows, 'contractAmountCny', 'paidAmountCny', 'unpaidAmountCny'),
551
- sales: summarizeRows(salesRows, 'orderAmountCny', 'receivedAmountCny', 'unreceivedAmountCny'),
552
- annualPurchaseCount: purchaseRows.filter(row => row.orderDate?.startsWith(String(currentYear))).length,
553
- annualSalesCount: salesRows.filter(row => row.orderDate?.startsWith(String(currentYear))).length,
554
- purchaseTop5: topProducts(filteredLines, 'lineAmountTaxIncluded'),
555
- salesTop5: topDerivedProducts(salesRows),
556
- filters
557
- };
1249
+ async restoreSettingsDefault(scope, input) {
1250
+ if (input.settingsType === 'risk')
1251
+ return this.persistSettings(scope, 'RISK', RiskNoticeSettings, activeScope(scope), settingsCreateScope(scope), { content: DEFAULT_RISK_NOTICE }, input.expectedRevision, 'RESTORE_DEFAULT');
1252
+ if (input.settingsType === 'company') {
1253
+ const builtinLogo = await this.ensureBuiltinCompanyLogo(scope);
1254
+ return this.persistCompanySettings(scope, { ...DEFAULT_COMPANY, logoFileId: builtinLogo.id }, input.expectedRevision, 'RESTORE_DEFAULT');
1255
+ }
1256
+ if (input.settingsType === 'profit')
1257
+ return this.persistSettings(scope, 'PROFIT', ProfitRuleSettings, activeScope(scope), { ...settingsCreateScope(scope), ...DEFAULT_PROFIT_RULE }, { ...DEFAULT_PROFIT_RULE }, input.expectedRevision, 'RESTORE_DEFAULT');
1258
+ const bankType = parseBankAccountType(input.bankType);
1259
+ const defaults = DEFAULT_BANKS.find(item => item.bankType === bankType);
1260
+ if (!defaults)
1261
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '银行默认配置不存在', { bankType });
1262
+ return this.persistSettings(scope, 'BANK', BankAccountSettings, { ...activeScope(scope), bankType }, { ...settingsCreateScope(scope), bankType }, { ...defaults }, input.expectedRevision, 'RESTORE_DEFAULT');
558
1263
  }
559
- async recalculateProductRisks(scope, reason) {
560
- const matchRepo = this.dataSource.getRepository(ProductComplianceMatch);
561
- const now = new Date();
562
- await matchRepo.update({ ...activeScope(scope), deletedAt: IsNull() }, { deletedAt: now, invalidationReason: reason });
563
- const [lines, goods] = await Promise.all([
564
- this.dataSource.getRepository(PurchaseOrderLine).find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
565
- this.dataSource.getRepository(ControlledGoodsRecord).find({ where: { ...activeScope(scope), deletedAt: IsNull() } })
566
- ]);
567
- for (const line of lines) {
568
- const lineHs = normalizeHsCode(line.hsCode);
569
- const hsMatches = lineHs ? goods.filter(item => item.normalizedHsCode && item.normalizedHsCode === lineHs) : [];
570
- const candidates = hsMatches.length ? hsMatches : goods.filter(item => isProductNameMatch(line.productName, item.productName));
571
- for (const item of candidates) {
572
- const contains = normalizeForProductMatch(line.productName).includes(normalizeForProductMatch(item.productName)) || normalizeForProductMatch(item.productName).includes(normalizeForProductMatch(line.productName));
573
- await matchRepo.save(matchRepo.create({ ...createdScope(scope), purchaseOrderLineId: line.id, controlledGoodsId: item.id, catalogType: item.catalogType, matchMethod: hsMatches.length ? 'HS_CODE_EXACT' : contains ? 'NAME_CONTAINS' : 'NAME_DICE', similarityScore: null, matchingRuleVersion: 'PRODUCT_MATCH_V1', matchingThreshold: '0.65', matchedAt: now, deletedAt: null, invalidationReason: null }));
574
- }
575
- line.complianceCheckedAt = now;
576
- await this.dataSource.getRepository(PurchaseOrderLine).save(line);
1264
+ async deleteMonthlyRate(scope, input) {
1265
+ return this.dataSource.transaction(async (manager) => {
1266
+ const repo = manager.getRepository(MonthlySafeExchangeRate);
1267
+ const row = await required(repo.findOne({ where: { ...activeScope(scope), id: input.id, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } }), '月度美元汇率不存在');
1268
+ assertExpectedRevision(row.revision, input.expectedRevision);
1269
+ const beforeRevision = row.revision;
1270
+ const beforeSnapshot = settingsSnapshot('RATE', row);
1271
+ row.deletedAt = new Date();
1272
+ row.deletedById = scope.userId ?? null;
1273
+ row.deleteOperation = 'USER_DELETE';
1274
+ row.updatedById = scope.userId ?? null;
1275
+ row.revision = nextSettingsRevision(beforeRevision);
1276
+ const saved = await repo.save(row);
1277
+ await saveSettingsHistory(manager, scope, 'RATE', saved.id, 'DELETE', beforeRevision, beforeSnapshot, saved);
1278
+ return saved;
1279
+ });
1280
+ }
1281
+ async persistSettings(scope, settingsType, entity, where, create, changes, expectedRevision, operationType = 'EDIT') {
1282
+ return this.dataSource.transaction(async (manager) => {
1283
+ const repo = manager.getRepository(entity);
1284
+ let row = await repo.findOne({ where, lock: { mode: 'pessimistic_write' } });
1285
+ const beforeRevision = row?.revision ?? 0;
1286
+ assertExpectedRevision(beforeRevision, parseExpectedRevision(expectedRevision, true));
1287
+ const beforeSnapshot = row ? settingsSnapshot(settingsType, row) : null;
1288
+ if (!row)
1289
+ row = repo.create(create);
1290
+ repo.merge(row, changes);
1291
+ row.updatedById = scope.userId ?? null;
1292
+ row.revision = nextSettingsRevision(beforeRevision);
1293
+ const saved = await repo.save(row);
1294
+ await saveSettingsHistory(manager, scope, settingsType, saved.id, operationType, beforeRevision, beforeSnapshot, saved);
1295
+ return saved;
1296
+ });
1297
+ }
1298
+ async persistCompanySettings(scope, requestedChanges, expectedRevision, operationType = 'EDIT') {
1299
+ const changes = { ...requestedChanges };
1300
+ const requestedLogoAssetId = typeof changes.logoFileId === 'string' ? changes.logoFileId : null;
1301
+ let replacedStorageFileId = null;
1302
+ try {
1303
+ const saved = await this.dataSource.transaction(async (manager) => {
1304
+ const repo = manager.getRepository(CompanySettings);
1305
+ let row = await repo.findOne({ where: activeScope(scope), lock: { mode: 'pessimistic_write' } });
1306
+ const beforeRevision = row?.revision ?? 0;
1307
+ assertExpectedRevision(beforeRevision, parseExpectedRevision(expectedRevision, true));
1308
+ const beforeSnapshot = row ? settingsSnapshot('COMPANY', row) : null;
1309
+ const previousLogoAssetId = row?.logoFileId ?? null;
1310
+ if (requestedLogoAssetId) {
1311
+ const assetRepo = manager.getRepository(ManagedAsset);
1312
+ const selected = await required(assetRepo.findOne({ where: { ...activeScope(scope), id: requestedLogoAssetId, assetType: 'COMPANY_LOGO', deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } }), '公司 Logo 资产不存在');
1313
+ if (selected.status === 'DRAFT') {
1314
+ if (selected.storageType !== 'MANAGED' || selected.ownerId !== (scope.userId ?? null))
1315
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '公司 Logo 草稿不属于当前操作人');
1316
+ selected.status = 'ACTIVE';
1317
+ selected.updatedById = scope.userId ?? null;
1318
+ await assetRepo.save(selected);
1319
+ }
1320
+ else if (selected.status !== 'ACTIVE')
1321
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '公司 Logo 资产状态无效');
1322
+ if (previousLogoAssetId && previousLogoAssetId !== selected.id) {
1323
+ const previous = await assetRepo.findOne({ where: { ...activeScope(scope), id: previousLogoAssetId, assetType: 'COMPANY_LOGO', status: 'ACTIVE', deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } });
1324
+ if (previous?.storageType === 'MANAGED') {
1325
+ previous.deletedAt = new Date();
1326
+ previous.deletedById = scope.userId ?? null;
1327
+ previous.deleteOperation = 'USER_DELETE';
1328
+ previous.updatedById = scope.userId ?? null;
1329
+ replacedStorageFileId = previous.storageFileId;
1330
+ await assetRepo.save(previous);
1331
+ }
1332
+ }
1333
+ }
1334
+ if (!row)
1335
+ row = repo.create(settingsCreateScope(scope));
1336
+ repo.merge(row, changes);
1337
+ row.updatedById = scope.userId ?? null;
1338
+ row.revision = nextSettingsRevision(beforeRevision);
1339
+ const committed = await repo.save(row);
1340
+ await saveSettingsHistory(manager, scope, 'COMPANY', committed.id, operationType, beforeRevision, beforeSnapshot, committed);
1341
+ return committed;
1342
+ });
1343
+ if (this.storage && replacedStorageFileId)
1344
+ await this.storage.remove(replacedStorageFileId).catch(() => undefined);
1345
+ return saved;
1346
+ }
1347
+ catch (error) {
1348
+ if (requestedLogoAssetId)
1349
+ await this.discardCompanyLogoDraft(scope, requestedLogoAssetId).catch(() => undefined);
1350
+ throw error;
577
1351
  }
578
- return { scanned: lines.length, matches: await matchRepo.count({ where: { ...activeScope(scope), deletedAt: IsNull() } }) };
579
1352
  }
580
- async recalculateCompanyRisks(scope, reason) {
581
- const repo = this.dataSource.getRepository(CompanySanctionMatch);
582
- const now = new Date();
583
- await repo.update({ ...activeScope(scope), deletedAt: IsNull() }, { deletedAt: now, invalidationReason: reason });
584
- const [suppliers, customers, sanctions] = await Promise.all([
585
- this.dataSource.getRepository(Supplier).find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
586
- this.dataSource.getRepository(Customer).find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
587
- this.dataSource.getRepository(SanctionedCompanyRecord).find({ where: { ...activeScope(scope), deletedAt: IsNull() } })
588
- ]);
589
- for (const supplier of suppliers) {
590
- for (const item of sanctions.filter(row => row.normalizedCompanyName === supplier.normalizedName))
591
- await repo.save(repo.create({ ...createdScope(scope), subjectType: 'SUPPLIER', supplierId: supplier.id, customerId: null, sanctionedCompanyId: item.id, matchedNormalizedName: supplier.normalizedName, checkedAt: now, deletedAt: null, invalidationReason: null }));
592
- supplier.complianceCheckedAt = now;
593
- await this.dataSource.getRepository(Supplier).save(supplier);
1353
+ async createCompanyLogoDraft(scope, buffer, fileName) {
1354
+ if (!this.storage)
1355
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '公司 Logo 存储服务不可用');
1356
+ const storageFileId = await this.storage.save(buffer, fileName, 'company-logo-drafts');
1357
+ try {
1358
+ const repo = this.dataSource.getRepository(ManagedAsset);
1359
+ return await repo.save(repo.create({ ...auditScope(scope), assetType: 'COMPANY_LOGO', status: 'DRAFT', storageType: 'MANAGED', storageFileId, ownerId: scope.userId ?? null, originalFileName: fileName, deletedAt: null, deletedById: null, deleteOperation: null }));
1360
+ }
1361
+ catch (error) {
1362
+ await this.storage.remove(storageFileId).catch(() => undefined);
1363
+ throw error;
1364
+ }
1365
+ }
1366
+ async discardCompanyLogoDraft(scope, assetId) {
1367
+ let storageFileId = null;
1368
+ const discarded = await this.dataSource.transaction(async (manager) => {
1369
+ const repo = manager.getRepository(ManagedAsset);
1370
+ const asset = await required(repo.findOne({ where: { ...activeScope(scope), id: assetId, assetType: 'COMPANY_LOGO', status: 'DRAFT', storageType: 'MANAGED', deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } }), '公司 Logo 草稿不存在');
1371
+ if (asset.ownerId !== (scope.userId ?? null))
1372
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '公司 Logo 草稿不属于当前操作人');
1373
+ asset.deletedAt = new Date();
1374
+ asset.deletedById = scope.userId ?? null;
1375
+ asset.deleteOperation = 'USER_DELETE';
1376
+ asset.updatedById = scope.userId ?? null;
1377
+ storageFileId = asset.storageFileId;
1378
+ return repo.save(asset);
1379
+ });
1380
+ if (this.storage && storageFileId)
1381
+ await this.storage.remove(storageFileId);
1382
+ return discarded;
1383
+ }
1384
+ async readCompanyLogo(scope, assetId) {
1385
+ if (!this.storage)
1386
+ throw new DomainError(DomainErrorCodes.NOT_FOUND, '公司 Logo 存储服务不可用');
1387
+ const asset = await required(this.dataSource.getRepository(ManagedAsset).findOne({ where: { ...activeScope(scope), id: assetId, assetType: 'COMPANY_LOGO', status: 'ACTIVE', deletedAt: IsNull() } }), '公司 Logo 资产不存在');
1388
+ try {
1389
+ return await this.storage.read(asset.storageFileId);
1390
+ }
1391
+ catch (error) {
1392
+ if (asset.storageType === 'BUILTIN')
1393
+ return undefined;
1394
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '公司 Logo 文件不可读取,请重新上传或恢复默认', { assetId, cause: error instanceof Error ? error.message : String(error) });
1395
+ }
1396
+ }
1397
+ async persistMonthlyRate(scope, input, changes, expectedRevision) {
1398
+ return this.dataSource.transaction(async (manager) => {
1399
+ const repo = manager.getRepository(MonthlySafeExchangeRate);
1400
+ const id = typeof input.id === 'string' && input.id.trim() ? input.id.trim() : null;
1401
+ let row = id ? await repo.findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } }) : null;
1402
+ if (id && !row)
1403
+ throw new DomainError(DomainErrorCodes.NOT_FOUND, '月度美元汇率不存在');
1404
+ const beforeRevision = row?.revision ?? 0;
1405
+ assertExpectedRevision(beforeRevision, parseExpectedRevision(expectedRevision, true));
1406
+ const occupied = await repo.findOne({ where: { ...activeScope(scope), year: changes.year, month: changes.month, currencyCode: 'USD', deletedAt: IsNull() } });
1407
+ if (occupied && occupied.id !== row?.id)
1408
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '目标年月已存在美元汇率');
1409
+ const beforeSnapshot = row ? settingsSnapshot('RATE', row) : null;
1410
+ if (!row)
1411
+ row = repo.create({ ...auditScope(scope), ...changes, currencyCode: 'USD', revision: 1 });
1412
+ else {
1413
+ repo.merge(row, changes);
1414
+ row.updatedById = scope.userId ?? null;
1415
+ row.revision = nextSettingsRevision(beforeRevision);
1416
+ }
1417
+ const saved = await repo.save(row);
1418
+ await saveSettingsHistory(manager, scope, 'RATE', saved.id, 'EDIT', beforeRevision, beforeSnapshot, saved);
1419
+ return saved;
1420
+ });
1421
+ }
1422
+ async analytics(scope, query, source = this.dataSource) {
1423
+ const currentYear = shanghaiCurrentYear();
1424
+ const statement = analyticsAggregationStatement(scope, query, currentYear, 'PAGE');
1425
+ const rawRows = await source.query(statement.sql, statement.parameters);
1426
+ const total = Number(rawRows[0]?.total ?? rawRows.length);
1427
+ if (total > ANALYTICS_EXPORT_MAX_ROWS)
1428
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, `分析分组最多支持 ${ANALYTICS_EXPORT_MAX_ROWS} 条`, { total });
1429
+ const pageCount = Math.max(1, Math.ceil(total / query.pageSize));
1430
+ const page = Math.min(query.page, pageCount);
1431
+ const pageStart = (page - 1) * query.pageSize + 1;
1432
+ const pageEnd = pageStart + query.pageSize - 1;
1433
+ const rankedRows = rawRows.map((row, index) => ({ row, rank: Number(row.rank ?? index + 1) }));
1434
+ const details = rankedRows.filter(item => item.rank >= pageStart && item.rank <= pageEnd).map(item => analyticsDetailFromRaw(item.row, query));
1435
+ const top5 = rankedRows.filter(item => item.rank <= 5).map(item => ({ key: String(item.row.key), label: String(item.row.label), amountCny: moneyString(item.row.amountCny) }));
1436
+ const metrics = analyticsMetricsFromRaw(rawRows[0], query);
1437
+ return { mode: query.mode, currentYear, dimension: query.dimension, metrics, top5, details: { items: details, page, pageSize: query.pageSize, total, pageCount } };
1438
+ }
1439
+ async exportAnalytics(scope, query) {
1440
+ const title = query.mode === 'PURCHASE' ? '采购分析' : '销售分析';
1441
+ const subject = query.dimension === 'SUPPLIER' ? '供应商' : query.dimension === 'CUSTOMER' ? '客户' : '商品名称';
1442
+ const headers = query.dimension === 'PRODUCT'
1443
+ ? [subject, '订单数', '商品数量', '商品明细金额 CNY']
1444
+ : query.mode === 'PURCHASE'
1445
+ ? [subject, '订单数', '订单总额 CNY', '已付款 CNY', '未付款 CNY', '已退税 CNY', '未退税 CNY']
1446
+ : [subject, '订单数', '订单总额 CNY', '已收款 CNY', '未收款 CNY'];
1447
+ const directory = await mkdtemp(join(tmpdir(), 'tcw-analytics-'));
1448
+ const filePath = join(directory, `${title}.xlsx`);
1449
+ try {
1450
+ await writeWorkbookFile(filePath, async (workbook) => {
1451
+ const sheet = workbook.addWorksheet(title);
1452
+ sheet.columns = headers.map(header => ({ header, width: 20 }));
1453
+ sheet.getRow(1).font = { bold: true };
1454
+ sheet.getRow(1).commit();
1455
+ const statement = analyticsAggregationStatement(scope, query, shanghaiCurrentYear(), 'EXPORT');
1456
+ let count = 0;
1457
+ await this.withAnalyticsSnapshot(statement, async (raw) => {
1458
+ count += 1;
1459
+ if (count > ANALYTICS_EXPORT_MAX_ROWS)
1460
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, `分析导出最多支持 ${ANALYTICS_EXPORT_MAX_ROWS} 条明细`, { total: count });
1461
+ const row = analyticsDetailFromRaw(raw, query);
1462
+ sheet.addRow(query.dimension === 'PRODUCT'
1463
+ ? [row.label, row.orderCount, row.productQuantity, row.amountCny]
1464
+ : query.mode === 'PURCHASE'
1465
+ ? [row.label, row.orderCount, row.amountCny, row.paidCny, row.unpaidCny, row.refundedCny, row.unrefundedCny]
1466
+ : [row.label, row.orderCount, row.amountCny, row.receivedCny, row.unreceivedCny]).commit();
1467
+ });
1468
+ });
1469
+ return { fileName: `${title}.xlsx`, buffer: await readFile(filePath) };
594
1470
  }
595
- for (const customer of customers) {
596
- for (const item of sanctions.filter(row => row.normalizedCompanyName === customer.normalizedName))
597
- await repo.save(repo.create({ ...createdScope(scope), subjectType: 'CUSTOMER', supplierId: null, customerId: customer.id, sanctionedCompanyId: item.id, matchedNormalizedName: customer.normalizedName, checkedAt: now, deletedAt: null, invalidationReason: null }));
598
- customer.complianceCheckedAt = now;
599
- await this.dataSource.getRepository(Customer).save(customer);
1471
+ finally {
1472
+ await rm(directory, { recursive: true, force: true });
1473
+ }
1474
+ }
1475
+ async withAnalyticsSnapshot(statement, consume) {
1476
+ if (typeof this.dataSource.createQueryRunner === 'function') {
1477
+ const runner = this.dataSource.createQueryRunner();
1478
+ await runner.connect();
1479
+ await runner.startTransaction('REPEATABLE READ');
1480
+ try {
1481
+ const stream = await runner.stream(statement.sql, statement.parameters);
1482
+ for await (const row of stream)
1483
+ await consume(row);
1484
+ await runner.commitTransaction();
1485
+ }
1486
+ catch (error) {
1487
+ await runner.rollbackTransaction();
1488
+ throw error;
1489
+ }
1490
+ finally {
1491
+ await runner.release();
1492
+ }
1493
+ return;
600
1494
  }
601
- return { scanned: suppliers.length + customers.length, matches: await repo.count({ where: { ...activeScope(scope), deletedAt: IsNull() } }) };
1495
+ await this.dataSource.transaction('REPEATABLE READ', async (manager) => {
1496
+ const rows = await manager.query(statement.sql, statement.parameters);
1497
+ for (const row of rows)
1498
+ await consume(row);
1499
+ });
602
1500
  }
603
- async pagedQuery(entity, scope, query, searchFields, exactFilters = {}) {
1501
+ async recalculateProductRisks(scope, reason) {
1502
+ return this.dataSource.transaction(async (manager) => {
1503
+ const matchRepo = manager.getRepository(ProductComplianceMatch);
1504
+ const lineRepo = manager.getRepository(PurchaseOrderLine);
1505
+ const now = new Date();
1506
+ if (this.dataSource.options?.type === 'postgres') {
1507
+ await manager.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [`trade-compliance:product-risk:${scope.tenantId}:${scope.organizationId}`, 'recalculate']);
1508
+ }
1509
+ const lines = await lineRepo.find({
1510
+ where: { ...activeScope(scope), deletedAt: IsNull() },
1511
+ order: { id: 'ASC' },
1512
+ lock: { mode: 'pessimistic_write' }
1513
+ });
1514
+ await matchRepo.update({ ...activeScope(scope), deletedAt: IsNull() }, { deletedAt: now, invalidationReason: reason });
1515
+ const goods = await manager.getRepository(ControlledGoodsRecord).find({
1516
+ where: { ...activeScope(scope), deletedAt: IsNull() },
1517
+ order: { id: 'ASC' },
1518
+ lock: { mode: 'pessimistic_read' }
1519
+ });
1520
+ for (const line of lines) {
1521
+ const lineHs = normalizeHsCode(line.hsCode);
1522
+ const hsMatches = lineHs ? goods.filter(item => item.normalizedHsCode && item.normalizedHsCode === lineHs) : [];
1523
+ const candidates = hsMatches.length ? hsMatches : goods.filter(item => isProductNameMatch(line.productName, item.productName));
1524
+ for (const item of candidates) {
1525
+ const contains = normalizeForProductMatch(line.productName).includes(normalizeForProductMatch(item.productName)) || normalizeForProductMatch(item.productName).includes(normalizeForProductMatch(line.productName));
1526
+ await matchRepo.save(matchRepo.create({ ...createdScope(scope), purchaseOrderLineId: line.id, controlledGoodsId: item.id, catalogType: item.catalogType, matchMethod: hsMatches.length ? 'HS_CODE_EXACT' : contains ? 'NAME_CONTAINS' : 'NAME_DICE', similarityScore: null, matchingRuleVersion: 'PRODUCT_MATCH_V1', matchingThreshold: '0.65', matchedAt: now, deletedAt: null, invalidationReason: null }));
1527
+ }
1528
+ await lineRepo.update({ ...activeScope(scope), id: line.id, deletedAt: IsNull() }, { complianceCheckedAt: now, updatedById: scope.userId ?? null, updatedAt: now });
1529
+ }
1530
+ return { scanned: lines.length, matches: await matchRepo.count({ where: { ...activeScope(scope), deletedAt: IsNull() } }) };
1531
+ });
1532
+ }
1533
+ async recalculateCompanyRisks(scope, reason) {
1534
+ return this.dataSource.transaction(async (manager) => {
1535
+ const repo = manager.getRepository(CompanySanctionMatch);
1536
+ const supplierRepo = manager.getRepository(Supplier);
1537
+ const customerRepo = manager.getRepository(Customer);
1538
+ const now = new Date();
1539
+ await repo.update({ ...activeScope(scope), deletedAt: IsNull() }, { deletedAt: now, invalidationReason: reason });
1540
+ const [suppliers, customers, sanctions] = await Promise.all([
1541
+ supplierRepo.find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
1542
+ customerRepo.find({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
1543
+ manager.getRepository(SanctionedCompanyRecord).find({ where: { ...activeScope(scope), deletedAt: IsNull() } })
1544
+ ]);
1545
+ for (const supplier of suppliers) {
1546
+ for (const item of sanctions.filter(row => row.normalizedCompanyName === supplier.normalizedName))
1547
+ await repo.save(repo.create({ ...createdScope(scope), subjectType: 'SUPPLIER', supplierId: supplier.id, customerId: null, sanctionedCompanyId: item.id, matchedNormalizedName: supplier.normalizedName, checkedAt: now, deletedAt: null, invalidationReason: null }));
1548
+ await supplierRepo.update({ ...activeScope(scope), id: supplier.id, deletedAt: IsNull() }, { complianceCheckedAt: now, updatedById: scope.userId ?? null, updatedAt: now });
1549
+ }
1550
+ for (const customer of customers) {
1551
+ for (const item of sanctions.filter(row => row.normalizedCompanyName === customer.normalizedName))
1552
+ await repo.save(repo.create({ ...createdScope(scope), subjectType: 'CUSTOMER', supplierId: null, customerId: customer.id, sanctionedCompanyId: item.id, matchedNormalizedName: customer.normalizedName, checkedAt: now, deletedAt: null, invalidationReason: null }));
1553
+ await customerRepo.update({ ...activeScope(scope), id: customer.id, deletedAt: IsNull() }, { complianceCheckStatus: 'CHECKED', complianceCheckedAt: now, updatedById: scope.userId ?? null, updatedAt: now });
1554
+ }
1555
+ return { scanned: suppliers.length + customers.length, matches: await repo.count({ where: { ...activeScope(scope), deletedAt: IsNull() } }) };
1556
+ });
1557
+ }
1558
+ async pagedQuery(entity, scope, query, searchFields, exactFilters = {}, filters = {}) {
604
1559
  const page = Math.max(1, Number(query.page ?? 1));
605
1560
  const pageSize = Math.min(200, Math.max(1, Number(query.pageSize ?? 20)));
606
1561
  const qb = this.dataSource.getRepository(entity).createQueryBuilder('row').where('row.tenantId = :tenantId AND row.organizationId = :organizationId', activeScope(scope));
@@ -612,9 +1567,48 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
612
1567
  for (const [key, value] of Object.entries(exactFilters))
613
1568
  if (value !== undefined && value !== null && value !== '')
614
1569
  qb.andWhere(`row.${key} = :filter_${key}`, { [`filter_${key}`]: value });
1570
+ for (const [key, value] of Object.entries(filters.fuzzy ?? {}))
1571
+ if (hasQueryValue(value))
1572
+ qb.andWhere(`CAST(row.${key} AS TEXT) ILIKE :fuzzy_${key}`, { [`fuzzy_${key}`]: `%${String(value).trim()}%` });
1573
+ for (const [key, value] of Object.entries(filters.range ?? {})) {
1574
+ const range = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
1575
+ if (hasQueryValue(range.from))
1576
+ qb.andWhere(`row.${key} >= :range_${key}_from`, { [`range_${key}_from`]: range.from });
1577
+ if (hasQueryValue(range.to))
1578
+ qb.andWhere(`row.${key} <= :range_${key}_to`, { [`range_${key}_to`]: range.to });
1579
+ }
1580
+ filters.apply?.(qb);
615
1581
  const [items, total] = await qb.orderBy('row.createdAt', 'DESC').skip((page - 1) * pageSize).take(pageSize).getManyAndCount();
616
1582
  return { items, total, page, pageSize };
617
1583
  }
1584
+ async pagedCatalogQuery(entity, batchEntity, scope, query, filters) {
1585
+ const page = Math.max(1, Number(query.page ?? 1));
1586
+ const pageSize = Math.min(200, Math.max(1, Number(query.pageSize ?? 20)));
1587
+ const qb = this.dataSource.getRepository(entity).createQueryBuilder('row')
1588
+ .leftJoin(batchEntity, 'batch', 'batch.id = row.batchId AND batch.tenantId = :tenantId AND batch.organizationId = :organizationId', activeScope(scope))
1589
+ .where('row.tenantId = :tenantId AND row.organizationId = :organizationId', activeScope(scope))
1590
+ .andWhere('row.deletedAt IS NULL');
1591
+ for (const [key, value] of Object.entries(filters.exact ?? {}))
1592
+ if (hasQueryValue(value))
1593
+ qb.andWhere(`row.${key} = :exact_${key}`, { [`exact_${key}`]: value });
1594
+ for (const [key, value] of Object.entries(filters.fuzzy ?? {}))
1595
+ if (hasQueryValue(value))
1596
+ qb.andWhere(`CAST(row.${key} AS TEXT) ILIKE :fuzzy_${key}`, { [`fuzzy_${key}`]: `%${String(value).trim()}%` });
1597
+ if (hasQueryValue(filters.batchSourceFileName))
1598
+ qb.andWhere('CAST(batch.sourceFileName AS TEXT) ILIKE :batchSourceFileName', { batchSourceFileName: `%${String(filters.batchSourceFileName).trim()}%` });
1599
+ for (const [key, value] of Object.entries(filters.range ?? {})) {
1600
+ const range = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
1601
+ if (hasQueryValue(range.from))
1602
+ qb.andWhere(`row.${key} >= :range_${key}_from`, { [`range_${key}_from`]: range.from });
1603
+ if (hasQueryValue(range.to))
1604
+ qb.andWhere(`row.${key} <= :range_${key}_to`, { [`range_${key}_to`]: range.to });
1605
+ }
1606
+ const [items, total] = await qb.orderBy('row.createdAt', 'DESC').skip((page - 1) * pageSize).take(pageSize).getManyAndCount();
1607
+ const batchIds = [...new Set(items.map(item => item.batchId).filter((id) => Boolean(id)))];
1608
+ const batches = batchIds.length ? await this.dataSource.getRepository(batchEntity).find({ where: { ...activeScope(scope), id: In(batchIds) } }) : [];
1609
+ const fileNames = new Map(batches.map(batch => [batch.id, batch.sourceFileName]));
1610
+ return { items: items.map(item => ({ ...item, sourceFileName: item.batchId ? fileNames.get(item.batchId) : undefined })), total, page, pageSize };
1611
+ }
618
1612
  async upsertSupplier(manager, scope, input) {
619
1613
  const repo = manager.getRepository(Supplier);
620
1614
  const normalizedName = normalizeExactName(input.name);
@@ -624,10 +1618,58 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
624
1618
  const manualCode = item.code;
625
1619
  Object.assign(item, pick(input, ['name', 'shortName', 'address', 'contactName', 'phone', 'wechat', 'email', 'taxNumber', 'paymentAccount', 'bankName', 'taxpayerRating', 'website', 'businessProducts']));
626
1620
  item.normalizedName = normalizedName;
627
- item.code = manualCode ?? input.code ?? null;
1621
+ item.code = await this.lockAndAssertPartyCodeAvailable(manager, scope, Supplier, manualCode ?? input.code, item.id);
628
1622
  item.updatedById = scope.userId ?? null;
629
1623
  return repo.save(item);
630
1624
  }
1625
+ async supplierDtos(scope, suppliers) {
1626
+ const ids = suppliers.map(item => item.id);
1627
+ const matches = ids.length ? await this.dataSource.getRepository(CompanySanctionMatch).find({ where: { ...activeScope(scope), subjectType: 'SUPPLIER', supplierId: In(ids), deletedAt: IsNull() } }) : [];
1628
+ return suppliers.map(supplier => {
1629
+ const sanctionMatchCount = matches.filter(match => match.supplierId === supplier.id).length;
1630
+ return { ...supplier, sanctionMatchCount, sanctionStatus: supplier.complianceCheckedAt == null ? 'UNCHECKED' : sanctionMatchCount ? 'HIT' : 'CLEAR' };
1631
+ });
1632
+ }
1633
+ async salesCustomerDtos(scope, customers) {
1634
+ const ids = customers.map(item => item.id);
1635
+ const matches = ids.length ? await this.dataSource.getRepository(CompanySanctionMatch).find({ where: { ...activeScope(scope), subjectType: 'CUSTOMER', customerId: In(ids), deletedAt: IsNull() } }) : [];
1636
+ return customers.map(customer => {
1637
+ const sanctionMatchCount = matches.filter(match => match.customerId === customer.id).length;
1638
+ return { ...customer, profitRateStatus: customer.pricingProfitRateStatus, sanctionMatchCount, sanctionStatus: customer.complianceCheckStatus === 'UNCHECKED' ? 'UNCHECKED' : sanctionMatchCount ? 'HIT' : 'CLEAR' };
1639
+ });
1640
+ }
1641
+ async salesOrderDtos(scope, orders) {
1642
+ if (!orders.length)
1643
+ return [];
1644
+ const purchaseIds = [...new Set(orders.map(item => item.purchaseOrderId))];
1645
+ const [customers, purchases] = await Promise.all([
1646
+ this.dataSource.getRepository(Customer).find({ where: { ...activeScope(scope), id: In([...new Set(orders.map(item => item.customerId))]), deletedAt: IsNull() } }),
1647
+ this.dataSource.getRepository(PurchaseOrder).find({ where: { ...activeScope(scope), id: In(purchaseIds), deletedAt: IsNull() } })
1648
+ ]);
1649
+ const supplierIds = [...new Set(purchases.map(item => item.supplierId))];
1650
+ const suppliers = supplierIds.length ? await this.dataSource.getRepository(Supplier).find({ where: { ...activeScope(scope), id: In(supplierIds), deletedAt: IsNull() } }) : [];
1651
+ const customerNames = new Map(customers.map(item => [item.id, item.name]));
1652
+ const purchaseSuppliers = new Map(purchases.map(item => [item.id, item.supplierId]));
1653
+ const supplierNames = new Map(suppliers.map(item => [item.id, item.name]));
1654
+ return orders.map(order => {
1655
+ const received = new Decimal(order.receivedAmountCny);
1656
+ const total = new Decimal(order.orderAmountCny);
1657
+ const paymentStatus = received.lte(0) ? 'UNPAID' : received.gte(total) ? 'PAID' : 'PARTIAL';
1658
+ return { ...order, customerName: customerNames.get(order.customerId) ?? '', supplierName: supplierNames.get(purchaseSuppliers.get(order.purchaseOrderId) ?? '') ?? '', pricingProfitRateStatus: 'CONFIGURED', paymentStatus, lines: order.lines.map(line => ({ ...line, complianceCategories: [...new Set(line.complianceMatches.map(match => match.catalogType))] })) };
1659
+ });
1660
+ }
1661
+ async purchaseOrderDtos(scope, orders) {
1662
+ const ids = orders.map(item => item.id);
1663
+ const lines = ids.length ? await this.dataSource.getRepository(PurchaseOrderLine).find({ where: { ...activeScope(scope), purchaseOrderId: In(ids), deletedAt: IsNull() } }) : [];
1664
+ const normalizedCustomerNos = [...new Set(orders.map(order => `${parsePurchaseContractNo(order.contractNo).baseCode}X`))];
1665
+ const contracts = normalizedCustomerNos.length ? await this.dataSource.getRepository(CustomerContract).find({ where: { ...activeScope(scope), normalizedContractNo: In(normalizedCustomerNos), deletedAt: IsNull() } }) : [];
1666
+ return orders.map(order => {
1667
+ const orderLines = lines.filter(line => line.purchaseOrderId === order.id);
1668
+ const customerContract = contracts.find(contract => parseCustomerContractNo(contract.contractNo).baseCode === parsePurchaseContractNo(order.contractNo).baseCode);
1669
+ const estimatedRefundAmountCny = orderLines.reduce((total, line) => line.refundRate ? total.plus(new Decimal(line.lineAmountTaxIncluded).div(new Decimal(line.vatRate ?? 0).add(1)).mul(line.refundRate)) : total, new Decimal(0)).toFixed(2);
1670
+ return { ...order, lineCount: orderLines.length, estimatedRefundAmountCny, customerContractId: customerContract?.id ?? null, customerContractNo: customerContract?.contractNo ?? null, customerContractLinkStatus: customerContract ? 'LINKED' : 'UNLINKED' };
1671
+ });
1672
+ }
631
1673
  async upsertCustomer(manager, scope, input) {
632
1674
  const repo = manager.getRepository(Customer);
633
1675
  const normalizedName = normalizeExactName(input.name);
@@ -636,67 +1678,100 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
636
1678
  item = repo.create({ ...auditScope(scope), name: input.name.trim(), normalizedName });
637
1679
  const manualCode = item.code;
638
1680
  const profitRate = item.defaultPricingProfitRate;
1681
+ const profitRateStatus = item.pricingProfitRateStatus;
639
1682
  Object.assign(item, pick(input, ['name', 'shortName', 'country', 'address', 'contactName', 'phone', 'wechat', 'email', 'taxNumber', 'paymentAccount', 'bankName', 'taxpayerRating', 'website', 'businessProducts']));
640
1683
  item.normalizedName = normalizedName;
641
- item.code = manualCode ?? input.code ?? null;
1684
+ item.code = await this.lockAndAssertPartyCodeAvailable(manager, scope, Customer, manualCode ?? input.code, item.id);
642
1685
  item.defaultPricingProfitRate = profitRate ?? input.defaultPricingProfitRate ?? null;
1686
+ item.pricingProfitRateStatus = profitRateStatus ?? (input.defaultPricingProfitRate != null ? 'CONFIGURED' : 'MISSING');
643
1687
  item.updatedById = scope.userId ?? null;
644
1688
  return repo.save(item);
645
1689
  }
646
- async deriveSingleOrder(scope, customerContractId, purchaseOrderId, override) {
647
- const contract = await required(this.dataSource.getRepository(CustomerContract).findOne({ where: { ...activeScope(scope), id: customerContractId, deletedAt: IsNull() } }), '客户合同不存在');
648
- const customer = await required(this.dataSource.getRepository(Customer).findOne({ where: { ...activeScope(scope), id: contract.customerId, deletedAt: IsNull() } }), '客户不存在');
649
- const purchase = await required(this.dataSource.getRepository(PurchaseOrder).findOne({ where: { ...activeScope(scope), id: purchaseOrderId, deletedAt: IsNull() } }), '采购订单不存在');
650
- const rate = override ?? customer.defaultPricingProfitRate;
651
- if (rate == null)
652
- throw new DomainError(DomainErrorCodes.CUSTOMER_PROFIT_RATE_REQUIRED, '客户默认利润率未维护');
653
- const lines = await this.dataSource.getRepository(PurchaseOrderLine).find({ where: { ...activeScope(scope), purchaseOrderId, deletedAt: IsNull() } });
654
- const amount = lines.reduce((sum, line) => sum.plus(deriveSalesLine({ purchaseUnitPriceCny: line.unitPriceTaxIncluded, quantity: line.quantity }, rate).salesLineAmountCny), new Decimal(0));
655
- return { contract, customer, purchase, rate, orderAmountCny: amount.toFixed(0) };
656
- }
657
- async assertExistingReceiptsRemainValid(scope, customerId, fallbackRate) {
658
- const contracts = await this.dataSource.getRepository(CustomerContract).find({ where: { ...activeScope(scope), customerId, deletedAt: IsNull() } });
659
- const manuals = contracts.length ? await this.dataSource.getRepository(DerivedSalesOrderManualData).find({ where: { ...activeScope(scope), customerContractId: In(contracts.map(item => item.id)), deletedAt: IsNull() } }) : [];
660
- for (const row of manuals.filter(item => item.pricingProfitRateOverride == null)) {
661
- const derived = await this.deriveSingleOrder(scope, row.customerContractId, row.purchaseOrderId, fallbackRate);
662
- assertReceivedAmount(derived.orderAmountCny, row.receivedAmountCny);
663
- }
1690
+ async lockCustomerName(manager, scope, normalizedName) {
1691
+ await manager.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [`trade-compliance:customer-name:${scope.tenantId}:${scope.organizationId}`, normalizedName]);
664
1692
  }
665
- async assertPurchaseOrderReceiptsRemainValid(scope, purchaseOrderId, changedLine, deletedLineId) {
666
- const purchase = await required(this.dataSource.getRepository(PurchaseOrder).findOne({ where: { ...activeScope(scope), id: purchaseOrderId, deletedAt: IsNull() } }), '采购订单不存在');
667
- const sourceLines = await this.dataSource.getRepository(PurchaseOrderLine).find({ where: { ...activeScope(scope), purchaseOrderId, deletedAt: IsNull() } });
668
- const lines = sourceLines.filter(line => line.id !== deletedLineId).map(line => changedLine?.id === line.id ? changedLine : line);
669
- const baseCode = parsePurchaseContractNo(purchase.contractNo).baseCode;
670
- const contracts = await this.dataSource.getRepository(CustomerContract).find({ where: { ...activeScope(scope), deletedAt: IsNull() } });
671
- for (const contract of contracts.filter(item => parseCustomerContractNo(item.contractNo).baseCode === baseCode)) {
672
- const [customer, manual] = await Promise.all([
673
- this.dataSource.getRepository(Customer).findOne({ where: { ...activeScope(scope), id: contract.customerId, deletedAt: IsNull() } }),
674
- this.dataSource.getRepository(DerivedSalesOrderManualData).findOne({ where: { ...activeScope(scope), customerContractId: contract.id, purchaseOrderId, deletedAt: IsNull() } })
675
- ]);
676
- if (!customer || !manual || new Decimal(manual.receivedAmountCny).eq(0))
1693
+ async lockAndAssertPartyCodeAvailable(manager, scope, entity, value, currentId) {
1694
+ const code = normalizePartyCode(value);
1695
+ if (code == null)
1696
+ return null;
1697
+ const partyType = entity === Supplier ? 'supplier' : 'customer';
1698
+ const partyLabel = entity === Supplier ? '供应商' : '客户';
1699
+ await manager.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [`trade-compliance:${partyType}-code:${scope.tenantId}:${scope.organizationId}`, code]);
1700
+ const repo = manager.getRepository(entity);
1701
+ const query = repo.createQueryBuilder('party')
1702
+ .where('party."tenantId" = :tenantId', { tenantId: scope.tenantId })
1703
+ .andWhere('party."organizationId" = :organizationId', { organizationId: scope.organizationId })
1704
+ .andWhere('party."deletedAt" IS NULL')
1705
+ .andWhere('party."code" IS NOT NULL')
1706
+ .andWhere('UPPER(BTRIM(party."code")) = :normalizedCode', { normalizedCode: code });
1707
+ if (currentId)
1708
+ query.andWhere('party."id" <> :currentId', { currentId });
1709
+ const duplicate = (await query.getMany()).find(candidate => normalizePartyCode(candidate.code) === code);
1710
+ if (duplicate)
1711
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, `${partyLabel}代码已存在`, { code, duplicateId: duplicate.id });
1712
+ return code;
1713
+ }
1714
+ async withLockedPurchaseLines(scope, purchaseOrderId, callback) {
1715
+ return this.dataSource.transaction(async (manager) => {
1716
+ const purchaseRepo = manager.getRepository(PurchaseOrder);
1717
+ const preview = await required(purchaseRepo.findOne({ where: { ...activeScope(scope), id: purchaseOrderId, deletedAt: IsNull() } }), '采购订单不存在');
1718
+ const baseCode = parsePurchaseContractNo(preview.contractNo).baseCode;
1719
+ const contracts = (await manager.getRepository(CustomerContract).find({ where: { ...activeScope(scope), deletedAt: IsNull() }, order: { id: 'ASC' } }))
1720
+ .filter(contract => parseCustomerContractNo(contract.contractNo).baseCode === baseCode);
1721
+ const customerRepo = manager.getRepository(Customer);
1722
+ const customers = [];
1723
+ for (const customerId of [...new Set(contracts.map(contract => contract.customerId))].sort()) {
1724
+ customers.push(await required(customerRepo.findOne({ where: { ...activeScope(scope), id: customerId, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } }), '客户不存在'));
1725
+ }
1726
+ const purchase = await required(purchaseRepo.findOne({ where: { ...activeScope(scope), id: purchaseOrderId, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } }), '采购订单不存在');
1727
+ const lineRepo = manager.getRepository(PurchaseOrderLine);
1728
+ const lines = await lineRepo.find({ where: { ...activeScope(scope), purchaseOrderId, deletedAt: IsNull() }, order: { id: 'ASC' }, lock: { mode: 'pessimistic_write' } });
1729
+ const contractIds = contracts.map(contract => contract.id);
1730
+ const manuals = contractIds.length ? await manager.getRepository(DerivedSalesOrderManualData).find({ where: { ...activeScope(scope), customerContractId: In(contractIds), purchaseOrderId, deletedAt: IsNull() }, order: { customerContractId: 'ASC', purchaseOrderId: 'ASC' }, lock: { mode: 'pessimistic_write' } }) : [];
1731
+ return callback({ manager, purchase, lineRepo, lines, contracts, customers, manuals });
1732
+ });
1733
+ }
1734
+ assertLockedPurchaseReceipts(lines, contracts, customers, manuals) {
1735
+ const contractsById = new Map(contracts.map(contract => [contract.id, contract]));
1736
+ const customersById = new Map(customers.map(customer => [customer.id, customer]));
1737
+ for (const manual of manuals) {
1738
+ const contract = contractsById.get(manual.customerContractId);
1739
+ const customer = contract ? customersById.get(contract.customerId) : undefined;
1740
+ if (!customer || new Decimal(manual.receivedAmountCny).eq(0))
677
1741
  continue;
678
- const rate = manual.pricingProfitRateOverride ?? customer.defaultPricingProfitRate;
1742
+ const rate = manual.pricingProfitRateOverride ?? (customer.pricingProfitRateStatus === 'CONFIGURED' ? customer.defaultPricingProfitRate : null);
679
1743
  if (rate == null)
680
1744
  continue;
681
- const total = lines.reduce((sum, line) => sum.plus(deriveSalesLine({ purchaseUnitPriceCny: line.unitPriceTaxIncluded, quantity: line.quantity }, rate).salesLineAmountCny), new Decimal(0));
682
- assertReceivedAmount(total, manual.receivedAmountCny);
1745
+ assertReceivedAmount(derivedOrderAmount(lines, rate), manual.receivedAmountCny);
683
1746
  }
684
1747
  }
685
- async cascadePurchaseOrder(scope, purchaseOrderId) {
686
- const now = new Date();
687
- const lineRepo = this.dataSource.getRepository(PurchaseOrderLine);
1748
+ async deletePurchaseLine(scope, id) {
1749
+ const existing = await required(this.dataSource.getRepository(PurchaseOrderLine).findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() } }), '记录不存在');
1750
+ return this.withLockedPurchaseLines(scope, existing.purchaseOrderId, async ({ manager, lineRepo, lines, contracts, customers, manuals }) => {
1751
+ const row = await required(Promise.resolve(lines.find(line => line.id === id) ?? null), '记录不存在');
1752
+ this.assertLockedPurchaseReceipts(lines.filter(line => line.id !== id), contracts, customers, manuals);
1753
+ row.deletedAt = new Date();
1754
+ row.deletedById = scope.userId ?? null;
1755
+ row.deleteOperation = 'USER_DELETE';
1756
+ row.updatedById = scope.userId ?? null;
1757
+ await lineRepo.save(row);
1758
+ await manager.getRepository(ProductComplianceMatch).update({ ...activeScope(scope), purchaseOrderLineId: id, deletedAt: IsNull() }, { deletedAt: new Date(), invalidationReason: 'SOURCE_CHANGED' });
1759
+ return row;
1760
+ });
1761
+ }
1762
+ async cascadePurchaseOrder(manager, scope, purchaseOrderId, now) {
1763
+ const lineRepo = manager.getRepository(PurchaseOrderLine);
688
1764
  const lines = await lineRepo.find({ where: { ...activeScope(scope), purchaseOrderId, deletedAt: IsNull() } });
689
1765
  if (lines.length) {
690
1766
  await lineRepo.update({ ...activeScope(scope), purchaseOrderId, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SOURCE_CASCADE' });
691
- await this.dataSource.getRepository(ProductComplianceMatch).update({ ...activeScope(scope), purchaseOrderLineId: In(lines.map(line => line.id)), deletedAt: IsNull() }, { deletedAt: now, invalidationReason: 'SOURCE_CHANGED' });
1767
+ await manager.getRepository(ProductComplianceMatch).update({ ...activeScope(scope), purchaseOrderLineId: In(lines.map(line => line.id)), deletedAt: IsNull() }, { deletedAt: now, invalidationReason: 'SOURCE_CHANGED' });
692
1768
  }
693
- await this.dataSource.getRepository(DerivedSalesOrderManualData).update({ ...activeScope(scope), purchaseOrderId, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SOURCE_CASCADE' });
1769
+ await manager.getRepository(DerivedSalesOrderManualData).update({ ...activeScope(scope), purchaseOrderId, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SOURCE_CASCADE' });
694
1770
  }
695
- async cascadeCustomerContract(scope, customerContractId) {
696
- const now = new Date();
1771
+ async cascadeCustomerContract(manager, scope, customerContractId, now) {
697
1772
  await Promise.all([
698
- this.dataSource.getRepository(CustomerContractClause).update({ ...activeScope(scope), customerContractId, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SOURCE_CASCADE' }),
699
- this.dataSource.getRepository(DerivedSalesOrderManualData).update({ ...activeScope(scope), customerContractId, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SOURCE_CASCADE' })
1773
+ manager.getRepository(CustomerContractClause).update({ ...activeScope(scope), customerContractId, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SOURCE_CASCADE' }),
1774
+ manager.getRepository(DerivedSalesOrderManualData).update({ ...activeScope(scope), customerContractId, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SOURCE_CASCADE' })
700
1775
  ]);
701
1776
  }
702
1777
  async expireStaleImportTasks(scope) {
@@ -735,21 +1810,464 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
735
1810
  TradeComplianceWorkbenchService = __decorate([
736
1811
  Injectable(),
737
1812
  __param(0, InjectDataSource()),
738
- __metadata("design:paramtypes", [DataSource])
1813
+ __param(1, Optional()),
1814
+ __metadata("design:paramtypes", [DataSource,
1815
+ TradeComplianceFileStorage])
739
1816
  ], TradeComplianceWorkbenchService);
740
1817
  export { TradeComplianceWorkbenchService };
741
1818
  function activeScope(scope) { return { tenantId: scope.tenantId, organizationId: scope.organizationId }; }
1819
+ function isPostgresUniqueViolation(error) {
1820
+ if (!error || typeof error !== 'object')
1821
+ return false;
1822
+ const candidate = error;
1823
+ return candidate.code === '23505' || candidate.driverError?.code === '23505';
1824
+ }
1825
+ function normalizePartyCode(value) {
1826
+ if (value == null)
1827
+ return null;
1828
+ const normalized = String(value).normalize('NFKC').trim().toUpperCase();
1829
+ return normalized || null;
1830
+ }
1831
+ async function lockCustomerContractReanalysis(manager, scope, id) {
1832
+ const runRepo = manager.getRepository(CustomerContractAnalysisRun);
1833
+ const snapshot = await required(runRepo.findOne({ where: { ...activeScope(scope), id } }), '重新分析任务不存在');
1834
+ if (snapshot.status !== 'PENDING' && snapshot.status !== 'RUNNING') {
1835
+ const run = await required(runRepo.findOne({ where: { ...activeScope(scope), id }, lock: { mode: 'pessimistic_write' } }), '重新分析任务不存在');
1836
+ return { run, contract: null, task: null };
1837
+ }
1838
+ if (snapshot.customerContractId) {
1839
+ const contract = await required(manager.getRepository(CustomerContract).findOne({ where: { ...activeScope(scope), id: snapshot.customerContractId, deletedAt: IsNull() }, lock: { mode: 'pessimistic_write' } }), '客户合同不存在');
1840
+ const run = await required(runRepo.findOne({ where: { ...activeScope(scope), id }, lock: { mode: 'pessimistic_write' } }), '重新分析任务不存在');
1841
+ return { run, contract, task: null };
1842
+ }
1843
+ const task = await required(manager.getRepository(ImportTask).findOne({ where: { ...activeScope(scope), id: snapshot.importTaskId }, lock: { mode: 'pessimistic_write' } }), '解析任务不存在');
1844
+ const run = await required(runRepo.findOne({ where: { ...activeScope(scope), id }, lock: { mode: 'pessimistic_write' } }), '重新分析任务不存在');
1845
+ return { run, contract: null, task };
1846
+ }
1847
+ function lockedCurrentReanalysisRunId(locked) {
1848
+ return locked.contract?.currentReanalysisRunId ?? locked.task?.currentReanalysisRunId ?? null;
1849
+ }
1850
+ async function timeoutCustomerContractReanalysis(manager, locked, now = new Date()) {
1851
+ const { run, contract, task } = locked;
1852
+ if (run.status !== 'PENDING' && run.status !== 'RUNNING')
1853
+ return false;
1854
+ const expiresAt = run.expiresAt ?? new Date((run.createdAt ?? now).getTime() + CUSTOMER_REANALYSIS_TIMEOUT_MS);
1855
+ run.expiresAt = expiresAt;
1856
+ if (expiresAt.getTime() > now.getTime())
1857
+ return false;
1858
+ run.status = 'TIMED_OUT';
1859
+ run.errorCode = 'AI_REANALYSIS_TIMEOUT';
1860
+ run.errorMessage = 'AI 重新分析超过 20 分钟';
1861
+ run.completedAt = now;
1862
+ if (contract?.currentReanalysisRunId === run.id) {
1863
+ contract.currentReanalysisRunId = null;
1864
+ await manager.getRepository(CustomerContract).save(contract);
1865
+ }
1866
+ if (task?.currentReanalysisRunId === run.id) {
1867
+ task.currentReanalysisRunId = null;
1868
+ await manager.getRepository(ImportTask).save(task);
1869
+ }
1870
+ await manager.getRepository(CustomerContractAnalysisRun).save(run);
1871
+ return true;
1872
+ }
1873
+ async function invalidateCustomerContractReanalysis(manager, scope, target) {
1874
+ const currentRunId = target.reviewSource === 'EXISTING_CONTRACT' ? target.contract.currentReanalysisRunId : target.task.currentReanalysisRunId;
1875
+ if (!currentRunId)
1876
+ return;
1877
+ const runRepo = manager.getRepository(CustomerContractAnalysisRun);
1878
+ const run = await required(runRepo.findOne({ where: { ...activeScope(scope), id: currentRunId }, lock: { mode: 'pessimistic_write' } }), '重新分析任务不存在');
1879
+ const locked = target.reviewSource === 'EXISTING_CONTRACT'
1880
+ ? { run, contract: target.contract, task: null }
1881
+ : { run, contract: null, task: target.task };
1882
+ if (!await timeoutCustomerContractReanalysis(manager, locked)) {
1883
+ if (run.status === 'PENDING' || run.status === 'RUNNING') {
1884
+ run.status = 'FAILED';
1885
+ run.errorCode = 'AI_REANALYSIS_INPUT_CHANGED';
1886
+ run.errorMessage = '重新分析输入在运行期间已变更';
1887
+ run.completedAt = new Date();
1888
+ await runRepo.save(run);
1889
+ }
1890
+ if (target.reviewSource === 'EXISTING_CONTRACT') {
1891
+ target.contract.currentReanalysisRunId = null;
1892
+ await manager.getRepository(CustomerContract).save(target.contract);
1893
+ }
1894
+ else {
1895
+ target.task.currentReanalysisRunId = null;
1896
+ await manager.getRepository(ImportTask).save(target.task);
1897
+ }
1898
+ }
1899
+ }
1900
+ function reanalysisTimeoutError() {
1901
+ return new DomainError(DomainErrorCodes.INVALID_INPUT, 'AI 重新分析超过 20 分钟');
1902
+ }
1903
+ function requiredCustomerContractReanalysisInput(run) {
1904
+ const input = run.analysisInput;
1905
+ if (!input || (input.reviewSource !== 'EXISTING_CONTRACT' && input.reviewSource !== 'IMPORT_DRAFT') || !Array.isArray(input.clauses))
1906
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析运行缺少输入快照');
1907
+ const clauses = input.clauses.map(reanalysisClauseInput);
1908
+ if (clauses.length !== ClauseTypes.length || new Set(clauses.map(clause => clause.clauseType)).size !== ClauseTypes.length)
1909
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析运行输入快照必须完整包含七类关键条款');
1910
+ return { reviewSource: input.reviewSource, clauses };
1911
+ }
1912
+ function normalizeCustomerContractAnalysisInput(value, enforceInvariant = true) {
1913
+ if (value.extractionStatus !== 'FOUND' && value.extractionStatus !== 'NOT_FOUND')
1914
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '客户合同条款提取状态无效');
1915
+ if (value.reviewedText != null && typeof value.reviewedText !== 'string')
1916
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '客户合同条款审核内容无效');
1917
+ const reviewedText = value.reviewedText?.trim() || null;
1918
+ if (enforceInvariant && value.extractionStatus === 'FOUND' && !reviewedText)
1919
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '已找到条款必须填写人工审核内容');
1920
+ if (enforceInvariant && value.extractionStatus === 'NOT_FOUND' && reviewedText)
1921
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '未找到条款不能填写人工审核内容');
1922
+ return { reviewedText, extractionStatus: value.extractionStatus };
1923
+ }
1924
+ function customerContractAnalysisInputChanged(currentByType, updates) {
1925
+ const seen = new Set();
1926
+ let changed = false;
1927
+ for (const update of updates) {
1928
+ if (!ClauseTypes.includes(update.clauseType) || seen.has(update.clauseType))
1929
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '客户合同条款类型无效');
1930
+ seen.add(update.clauseType);
1931
+ const current = currentByType.get(update.clauseType);
1932
+ if (!current || current.id !== update.id)
1933
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '客户合同条款不存在');
1934
+ const normalizedCurrent = normalizeCustomerContractAnalysisInput(current, false);
1935
+ const normalizedUpdate = normalizeCustomerContractAnalysisInput(update);
1936
+ if (normalizedCurrent.reviewedText !== normalizedUpdate.reviewedText || normalizedCurrent.extractionStatus !== normalizedUpdate.extractionStatus)
1937
+ changed = true;
1938
+ }
1939
+ return changed;
1940
+ }
1941
+ function reanalysisClauseInput(value) {
1942
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1943
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析条款输入无效');
1944
+ const clause = value;
1945
+ if (!ClauseTypes.includes(clause.clauseType))
1946
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析条款类型无效');
1947
+ if (clause.extractionStatus !== 'FOUND' && clause.extractionStatus !== 'NOT_FOUND')
1948
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析条款提取状态无效');
1949
+ const optionalText = (field) => clause[field] == null ? null : typeof clause[field] === 'string' ? clause[field] : (() => { throw new DomainError(DomainErrorCodes.INVALID_INPUT, `重新分析条款 ${field} 无效`); })();
1950
+ return { clauseType: clause.clauseType, extractionStatus: clause.extractionStatus, sourceText: optionalText('sourceText'), reviewedText: optionalText('reviewedText'), sourceLocation: optionalText('sourceLocation'), sourceContent: optionalText('sourceContent') };
1951
+ }
1952
+ function importIssueExportLimit() { const configured = Number(process.env.TRADE_COMPLIANCE_IMPORT_ISSUE_EXPORT_MAX_ROWS); return Math.min(HARD_IMPORT_ISSUE_EXPORT_MAX_ROWS, Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : DEFAULT_IMPORT_ISSUE_EXPORT_MAX_ROWS); }
1953
+ function hasQueryValue(value) { return value !== undefined && value !== null && String(value).trim() !== ''; }
1954
+ function derivedOrderKey(customerContractId, purchaseOrderId) { return `${customerContractId}:${purchaseOrderId}`; }
1955
+ function groupBy(rows, keyOf) { const grouped = new Map(); for (const row of rows) {
1956
+ const key = keyOf(row);
1957
+ const values = grouped.get(key);
1958
+ if (values)
1959
+ values.push(row);
1960
+ else
1961
+ grouped.set(key, [row]);
1962
+ } return grouped; }
1963
+ function compareDerivedSalesOrders(left, right) { return left.salesOrderNo.localeCompare(right.salesOrderNo) || left.purchaseOrderId.localeCompare(right.purchaseOrderId) || left.customerContractId.localeCompare(right.customerContractId); }
1964
+ function derivedOrderAmount(lines, rate) { return lines.reduce((total, line) => total.plus(deriveSalesLine({ purchaseUnitPriceCny: line.unitPriceTaxIncluded, quantity: line.quantity }, rate).salesLineAmountCny), new Decimal(0)).toFixed(0); }
1965
+ function assertOptionalEnum(value, allowed, label) { if (hasQueryValue(value) && !allowed.includes(value))
1966
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, `${label}无效`); }
1967
+ function fuzzyValue(value, filter) { return !hasQueryValue(filter) || String(value ?? '').toLocaleLowerCase().includes(String(filter).trim().toLocaleLowerCase()); }
1968
+ function pageRows(rows, query) { const pageSize = Math.min(200, Math.max(1, Number(query.pageSize ?? 20))); const pageCount = Math.max(1, Math.ceil(rows.length / pageSize)); const page = Math.min(pageCount, Math.max(1, Number(query.page ?? 1))); return { items: rows.slice((page - 1) * pageSize, page * pageSize), total: rows.length, page, pageSize }; }
1969
+ function filterSalesOrders(rows, parameters, includeParties = false) {
1970
+ const date = parameters.orderDate && typeof parameters.orderDate === 'object' && !Array.isArray(parameters.orderDate) ? parameters.orderDate : {};
1971
+ return rows.filter(row => fuzzyValue(row.salesOrderNo, parameters.salesOrderNo) && fuzzyValue(row.customerContractNo, parameters.customerContractNo) && fuzzyValue(row.purchaseContractNo, parameters.purchaseContractNo) && (!includeParties || fuzzyValue(row.supplierName, parameters.supplierName) && fuzzyValue(row.customerName, parameters.customerName)) && (!hasQueryValue(parameters.paymentStatus) || row.paymentStatus === parameters.paymentStatus) && (!hasQueryValue(date.from) || String(row.orderDate ?? '') >= String(date.from)) && (!hasQueryValue(date.to) || String(row.orderDate ?? '') <= String(date.to)));
1972
+ }
1973
+ function salesMetrics(rows) {
1974
+ const currentYear = String(new Date().getFullYear());
1975
+ const currentYearRows = rows.filter(row => String(row.orderDate ?? '').slice(0, 4) === currentYear);
1976
+ const productAmounts = new Map();
1977
+ for (const row of rows)
1978
+ for (const line of row.lines)
1979
+ productAmounts.set(line.productName, (productAmounts.get(line.productName) ?? new Decimal(0)).plus(line.salesLineAmountCny));
1980
+ return {
1981
+ orderCount: rows.length,
1982
+ salesAmountCny: sum(rows, 'orderAmountCny'),
1983
+ receivedAmountCny: sum(rows, 'receivedAmountCny'),
1984
+ unreceivedAmountCny: sum(rows, 'unreceivedAmountCny'),
1985
+ currentYearOrderCount: currentYearRows.length,
1986
+ currentYearSalesAmountCny: sum(currentYearRows, 'orderAmountCny'),
1987
+ topProducts: [...productAmounts].sort((left, right) => right[1].comparedTo(left[1]) || left[0].localeCompare(right[0])).slice(0, 5).map(([productName, amount]) => ({ productName, amountCny: amount.toFixed(2) }))
1988
+ };
1989
+ }
1990
+ function sum(rows, key) { return rows.reduce((value, row) => value.plus(row[key] ?? 0), new Decimal(0)).toFixed(2); }
1991
+ function booleanFilter(value) { return value === true || value === 'true' ? true : value === false || value === 'false' ? false : null; }
1992
+ function purchaseEstimatedRefundSql(alias) { return `(SELECT COALESCE(SUM(refund_line."lineAmountTaxIncluded" / (1 + COALESCE(refund_line."vatRate", 0)) * COALESCE(refund_line."refundRate", 0)), 0) FROM plugin_trade_compliance_purchase_order_line refund_line WHERE refund_line."purchaseOrderId" = ${alias}.id AND refund_line."tenantId" = ${alias}."tenantId" AND refund_line."organizationId" = ${alias}."organizationId" AND refund_line."deletedAt" IS NULL)`; }
1993
+ function linkedCustomerContractSql(alias) { return `SELECT 1 FROM plugin_trade_compliance_customer_contract linked_contract WHERE linked_contract."tenantId" = :tenantId AND linked_contract."organizationId" = :organizationId AND linked_contract."deletedAt" IS NULL AND linked_contract."normalizedContractNo" = regexp_replace(${alias}."normalizedContractNo", '(-[0-9]+)?C$', '') || 'X'`; }
1994
+ function customerSanctionMatchSql(alias) { return `SELECT 1 FROM plugin_trade_compliance_company_sanction_match customer_match WHERE customer_match."tenantId" = :tenantId AND customer_match."organizationId" = :organizationId AND customer_match."deletedAt" IS NULL AND customer_match."subjectType" = 'CUSTOMER' AND customer_match."customerId" = ${alias}."customerId"`; }
742
1995
  function createdScope(scope) { return { ...activeScope(scope), createdById: scope.userId ?? null }; }
743
1996
  function auditScope(scope) { return { ...createdScope(scope), updatedById: scope.userId ?? null, deletedAt: null, deletedById: null, deleteOperation: null }; }
1997
+ function settingsCreateScope(scope) { return { ...createdScope(scope), updatedById: scope.userId ?? null }; }
1998
+ async function saveSettingsHistory(manager, scope, settingsType, settingsId, operationType, beforeRevision, beforeSnapshot, saved) {
1999
+ const historyRepo = manager.getRepository(SettingsHistory);
2000
+ const afterSnapshot = settingsSnapshot(settingsType, saved);
2001
+ await historyRepo.save(historyRepo.create({ ...activeScope(scope), settingsType, settingsId, operationType, snapshotVersion: 'v1', beforeRevision, afterRevision: afterSnapshot.revision, beforeSnapshot, afterSnapshot, operatedById: scope.userId ?? null }));
2002
+ }
2003
+ function settingsSnapshot(settingsType, row) {
2004
+ const value = row;
2005
+ const revision = Number(value.revision);
2006
+ if (!Number.isInteger(revision) || revision < 1)
2007
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '设置修订号无效', { settingsType, revision: value.revision });
2008
+ if (settingsType === 'RISK')
2009
+ return { settingsType, snapshotVersion: 'v1', revision, content: String(value.content ?? '') };
2010
+ if (settingsType === 'COMPANY')
2011
+ return { settingsType, snapshotVersion: 'v1', revision, logoFileId: nullableSnapshotText(value.logoFileId), companyNameZh: nullableSnapshotText(value.companyNameZh), companyNameEn: nullableSnapshotText(value.companyNameEn), unifiedSocialCreditCode: nullableSnapshotText(value.unifiedSocialCreditCode), addressZh: nullableSnapshotText(value.addressZh), addressEn: nullableSnapshotText(value.addressEn), phone: nullableSnapshotText(value.phone), email: nullableSnapshotText(value.email) };
2012
+ if (settingsType === 'BANK')
2013
+ return { settingsType, snapshotVersion: 'v1', revision, ...validateBankSettingsInput(value) };
2014
+ if (settingsType === 'PROFIT') {
2015
+ if (value.status !== 'UNCONFIGURED' && value.status !== 'ACTIVE')
2016
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '利润率规则状态无效');
2017
+ if (value.ruleVersion !== 'COMPLIANCE_V1')
2018
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '利润率公式版本无效');
2019
+ return { settingsType, snapshotVersion: 'v1', revision, status: value.status, ruleVersion: value.ruleVersion, minimumOrderProfitRate: finiteDecimalSetting(value.minimumOrderProfitRate, '最低整单利润率'), exchangeCostMinimum: finiteDecimalSetting(value.exchangeCostMinimum, '换汇成本下限'), exchangeCostMaximum: finiteDecimalSetting(value.exchangeCostMaximum, '换汇成本上限') };
2020
+ }
2021
+ if (value.currencyCode !== 'USD' || value.sourceType !== 'MANUAL')
2022
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '月度汇率类型无效');
2023
+ return { settingsType, snapshotVersion: 'v1', revision, year: Number(value.year), month: Number(value.month), currencyCode: value.currencyCode, usdToCnyRate: finiteDecimalSetting(value.usdToCnyRate, '美元兑人民币汇率'), sourceType: value.sourceType, sourceReference: nullableSnapshotText(value.sourceReference), deletedAt: value.deletedAt instanceof Date ? value.deletedAt.toISOString() : nullableSnapshotText(value.deletedAt) };
2024
+ }
2025
+ function nullableSnapshotText(value) { return value == null ? null : String(value); }
2026
+ function assertExpectedRevision(actual, expected) {
2027
+ if (actual !== expected)
2028
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '设置已被其他操作修改,请刷新后重试', { actualRevision: actual, expectedRevision: expected });
2029
+ }
2030
+ function settingString(value, label) { if (typeof value !== 'string')
2031
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, `${label}必须是字符串`); return value; }
2032
+ function settingNullableString(value, label) { return value == null ? null : settingString(value, label); }
2033
+ function finiteDecimalSetting(value, label) { try {
2034
+ const parsed = new Decimal(value);
2035
+ if (parsed.isFinite())
2036
+ return parsed.toString();
2037
+ }
2038
+ catch { } throw new DomainError(DomainErrorCodes.INVALID_INPUT, `${label}必须是有限数值`, { value }); }
744
2039
  async function required(promise, message) { const value = await promise; if (!value)
745
2040
  throw new DomainError(DomainErrorCodes.NOT_FOUND, message); return value; }
746
2041
  function pick(input, keys) { return Object.fromEntries(keys.filter(key => key in input).map(key => [key, input[key]])); }
747
2042
  function maxRisk(levels) { return levels.includes('HIGH') ? 'HIGH' : levels.includes('MEDIUM') ? 'MEDIUM' : levels.includes('LOW') ? 'LOW' : null; }
748
- function summarizeRows(rows, totalKey, paidKey, unpaidKey) { return { count: rows.length, totalCny: sum(rows, totalKey), paidCny: sum(rows, paidKey), unpaidCny: sum(rows, unpaidKey), rows }; }
749
- function sum(rows, key) { return rows.reduce((value, row) => value.plus(row[key] ?? 0), new Decimal(0)).toFixed(2); }
750
- function topProducts(lines, amountKey) { const map = new Map(); for (const line of lines)
751
- map.set(line.productName, (map.get(line.productName) ?? new Decimal(0)).plus(String(line[amountKey] ?? 0))); return [...map].sort((a, b) => b[1].cmp(a[1])).slice(0, 5).map(([name, amountCny]) => ({ name, amountCny: amountCny.toFixed(2) })); }
752
- function topDerivedProducts(orders) { const map = new Map(); for (const order of orders)
753
- for (const line of order.lines)
754
- map.set(line.productName, (map.get(line.productName) ?? new Decimal(0)).plus(line.salesLineAmountCny)); return [...map].sort((a, b) => b[1].cmp(a[1])).slice(0, 5).map(([name, amountCny]) => ({ name, amountCny: amountCny.toFixed(2) })); }
2043
+ function assertPurchaseDraft(draft) {
2044
+ if (!String(draft.order.orderDate ?? '').trim())
2045
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '下单日期为必填项');
2046
+ const contractAmount = decimalInput(draft.order.contractAmountCny, '采购订单总额必须为有效金额');
2047
+ const paidAmount = decimalInput(draft.order.paidAmountCny ?? 0, '已付款必须为有效金额');
2048
+ const refundedAmount = decimalInput(draft.order.refundedAmountCny ?? 0, '已退税必须为有效金额');
2049
+ if (paidAmount.isNegative())
2050
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '已付款不能为负数');
2051
+ if (paidAmount.gt(contractAmount))
2052
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '已付款不能超过采购订单总额');
2053
+ if (refundedAmount.isNegative())
2054
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '已退税不能为负数');
2055
+ if (draft.order.amountMismatch === true && draft.order.amountMismatchConfirmed !== true)
2056
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '订单总额差异必须确认');
2057
+ let estimatedRefund = new Decimal(0);
2058
+ for (let index = 0; index < draft.lines.length; index += 1) {
2059
+ const line = draft.lines[index];
2060
+ const prefix = `第${index + 1}条商品`;
2061
+ if (!hasQueryValue(line.refundRate))
2062
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, `${prefix}退税率为必填项`);
2063
+ if (!hasQueryValue(line.refundPolicyReference))
2064
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, `${prefix}退税政策依据为必填项`);
2065
+ if (!hasQueryValue(line.refundPolicyDate))
2066
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, `${prefix}退税政策日期为必填项`);
2067
+ if (String(line.refundPolicyDate) > String(draft.order.orderDate))
2068
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, `${prefix}退税政策日期不得晚于下单日期`);
2069
+ if (!hasQueryValue(line.refundValidationMethod))
2070
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, `${prefix}退税校验方式为必填项`);
2071
+ if (!RefundValidationMethods.some(method => method === line.refundValidationMethod))
2072
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, `${prefix}退税校验方式无效`);
2073
+ validatePurchaseLineDiscriminators({ ...line, sourceType: line.sourceType ?? 'CONTRACT_EXTRACTED', hsCodeStatus: line.hsCodeStatus ?? 'UNCONFIRMED' });
2074
+ if (line.amountMismatch === true && line.amountMismatchConfirmed !== true)
2075
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, `${prefix}金额差异必须确认`);
2076
+ const lineAmount = decimalInput(line.lineAmountTaxIncluded, `${prefix}含税金额必须为有效金额`);
2077
+ const rates = validateTaxRates(line, { lineNumber: index + 1, productName: line.productName });
2078
+ const vatRate = new Decimal(rates.vatRate);
2079
+ const refundRate = new Decimal(rates.refundRate);
2080
+ estimatedRefund = estimatedRefund.plus(lineAmount.div(vatRate.add(1)).mul(refundRate));
2081
+ }
2082
+ if (refundedAmount.gt(estimatedRefund))
2083
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '已退税不能超过预计退税总额');
2084
+ }
2085
+ function decimalInput(value, message) {
2086
+ try {
2087
+ return new Decimal(value);
2088
+ }
2089
+ catch {
2090
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, message);
2091
+ }
2092
+ }
2093
+ function completeCustomerClauseAnalyses(draft) {
2094
+ return ClauseTypes.map(clauseType => {
2095
+ const clause = draft.clauses.find(item => item.clauseType === clauseType);
2096
+ if (!clause || clause.extractionStatus === 'NOT_FOUND')
2097
+ return { clauseType, riskLevel: 'HIGH', riskMessageZh: '合同缺少该关键条款', suggestionZh: '请人工补充并确认该关键条款。' };
2098
+ const analysis = draft.analyses?.find(item => item.clauseType === clauseType);
2099
+ if (!analysis)
2100
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '已找到的关键条款缺少分析结果', { clauseType });
2101
+ return analysis;
2102
+ });
2103
+ }
2104
+ function requiredId(value, label) { const id = String(value ?? '').trim(); if (!id)
2105
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, `缺少${label}`); return id; }
2106
+ function assertCompleteClauseAnalyses(analyses) {
2107
+ if (analyses.length !== ClauseTypes.length)
2108
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析结果必须完整包含七类关键条款');
2109
+ const seen = new Set();
2110
+ for (const analysis of analyses) {
2111
+ if (!ClauseTypes.includes(analysis.clauseType))
2112
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析结果包含未知条款类型', { clauseType: analysis.clauseType });
2113
+ if (seen.has(analysis.clauseType))
2114
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析结果包含重复条款类型', { clauseType: analysis.clauseType });
2115
+ seen.add(analysis.clauseType);
2116
+ }
2117
+ if (ClauseTypes.some(clauseType => !seen.has(clauseType)))
2118
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '重新分析结果必须完整包含七类关键条款');
2119
+ }
2120
+ function decimalSum(rows, read) { return rows.reduce((value, row) => value.plus(read(row)), new Decimal(0)).toNumber(); }
2121
+ function fuzzyMatches(value, filter) { return !filter || String(value ?? '').trim().toLocaleLowerCase().includes(filter.trim().toLocaleLowerCase()); }
2122
+ function shanghaiCurrentYear(now = new Date()) {
2123
+ return Number(new Intl.DateTimeFormat('en-US', { timeZone: 'Asia/Shanghai', year: 'numeric' }).format(now));
2124
+ }
2125
+ function currentYearDateRange(currentYear, requested) {
2126
+ const yearFrom = `${currentYear}-01-01`;
2127
+ const yearTo = `${currentYear}-12-31`;
2128
+ return { from: requested?.from && requested.from > yearFrom ? requested.from : yearFrom, to: requested?.to && requested.to < yearTo ? requested.to : yearTo };
2129
+ }
2130
+ function analyticsAggregationStatement(scope, query, currentYear, purpose) {
2131
+ const parameters = [];
2132
+ const parameter = (value) => { parameters.push(value); return `$${parameters.length}`; };
2133
+ const tenant = parameter(scope.tenantId);
2134
+ const organization = parameter(scope.organizationId);
2135
+ const range = currentYearDateRange(currentYear, query.filters.orderDate);
2136
+ const from = parameter(range.from);
2137
+ const to = parameter(range.to);
2138
+ const fuzzy = (expression, value) => value ? `${expression} ILIKE ${parameter(`%${value.trim()}%`)}` : null;
2139
+ const lineConditions = (alias) => [
2140
+ `${alias}."tenantId" = ${tenant}`,
2141
+ `${alias}."organizationId" = ${organization}`,
2142
+ `${alias}."deletedAt" IS NULL`,
2143
+ fuzzy(`${alias}."productName"`, query.filters.productName),
2144
+ fuzzy(`${alias}."hsCode"`, query.filters.hsCode)
2145
+ ].filter((condition) => Boolean(condition));
2146
+ let sourceSql;
2147
+ if (query.mode === 'PURCHASE') {
2148
+ const expectedRefund = `(SELECT COALESCE(SUM(refund_line."lineAmountTaxIncluded" / (1 + COALESCE(refund_line."vatRate", 0)) * COALESCE(refund_line."refundRate", 0)), 0) FROM plugin_trade_compliance_purchase_order_line refund_line WHERE refund_line."purchaseOrderId" = purchase.id AND refund_line."tenantId" = purchase."tenantId" AND refund_line."organizationId" = purchase."organizationId" AND refund_line."deletedAt" IS NULL)`;
2149
+ const orderConditions = [
2150
+ `purchase."tenantId" = ${tenant}`,
2151
+ `purchase."organizationId" = ${organization}`,
2152
+ 'purchase."deletedAt" IS NULL',
2153
+ `purchase."orderDate" BETWEEN ${from} AND ${to}`,
2154
+ fuzzy('supplier.name', query.filters.supplier),
2155
+ query.filters.unpaidOnly ? 'GREATEST(purchase."contractAmountCny" - purchase."paidAmountCny", 0) > 0' : null,
2156
+ query.filters.unrefundedOnly ? `GREATEST(${expectedRefund} - purchase."refundedAmountCny", 0) > 0` : null,
2157
+ `EXISTS (SELECT 1 FROM plugin_trade_compliance_purchase_order_line matched_line WHERE matched_line."purchaseOrderId" = purchase.id AND ${lineConditions('matched_line').join(' AND ')})`
2158
+ ].filter((condition) => Boolean(condition));
2159
+ const orders = `purchase_orders AS (
2160
+ SELECT purchase.id, purchase."supplierId", supplier.name AS "subjectLabel",
2161
+ purchase."contractAmountCny" AS "amountCny", purchase."paidAmountCny" AS "paidCny",
2162
+ GREATEST(purchase."contractAmountCny" - purchase."paidAmountCny", 0) AS "unpaidCny",
2163
+ purchase."refundedAmountCny" AS "refundedCny", GREATEST(${expectedRefund} - purchase."refundedAmountCny", 0) AS "unrefundedCny"
2164
+ FROM plugin_trade_compliance_purchase_order purchase
2165
+ INNER JOIN plugin_trade_compliance_supplier supplier ON supplier.id = purchase."supplierId" AND supplier."tenantId" = purchase."tenantId" AND supplier."organizationId" = purchase."organizationId" AND supplier."deletedAt" IS NULL
2166
+ WHERE ${orderConditions.join(' AND ')}
2167
+ )`;
2168
+ sourceSql = query.dimension === 'PRODUCT'
2169
+ ? `${orders}, grouped AS (
2170
+ SELECT LOWER(BTRIM(line."productName")) AS key, MIN(BTRIM(line."productName")) AS label,
2171
+ COUNT(DISTINCT orders.id)::bigint AS "orderCount", SUM(line.quantity) AS "productQuantity", SUM(line."lineAmountTaxIncluded") AS "amountCny"
2172
+ FROM purchase_orders orders
2173
+ INNER JOIN plugin_trade_compliance_purchase_order_line line ON line."purchaseOrderId" = orders.id AND ${lineConditions('line').join(' AND ')}
2174
+ GROUP BY LOWER(BTRIM(line."productName"))
2175
+ )`
2176
+ : `${orders}, grouped AS (
2177
+ SELECT orders."supplierId"::text AS key, MIN(orders."subjectLabel") AS label, COUNT(DISTINCT orders.id)::bigint AS "orderCount",
2178
+ SUM(orders."amountCny") AS "amountCny", SUM(orders."paidCny") AS "paidCny", SUM(orders."unpaidCny") AS "unpaidCny",
2179
+ SUM(orders."refundedCny") AS "refundedCny", SUM(orders."unrefundedCny") AS "unrefundedCny"
2180
+ FROM purchase_orders orders GROUP BY orders."supplierId"
2181
+ )`;
2182
+ }
2183
+ else {
2184
+ const effectiveRate = 'COALESCE(manual."pricingProfitRateOverride", customer."defaultPricingProfitRate")';
2185
+ const lineAmount = (alias) => `ROUND(ROUND(${alias}."unitPriceTaxIncluded" * (1 + ${effectiveRate}), 0) * ${alias}.quantity, 0)`;
2186
+ const orderAmount = `(SELECT COALESCE(SUM(${lineAmount('sales_line')}), 0) FROM plugin_trade_compliance_purchase_order_line sales_line WHERE sales_line."purchaseOrderId" = purchase.id AND sales_line."tenantId" = purchase."tenantId" AND sales_line."organizationId" = purchase."organizationId" AND sales_line."deletedAt" IS NULL)`;
2187
+ const orderConditions = [
2188
+ `contract."tenantId" = ${tenant}`,
2189
+ `contract."organizationId" = ${organization}`,
2190
+ 'contract."deletedAt" IS NULL',
2191
+ `purchase."orderDate" BETWEEN ${from} AND ${to}`,
2192
+ `(manual."pricingProfitRateOverride" IS NOT NULL OR (customer."pricingProfitRateStatus" = 'CONFIGURED' AND customer."defaultPricingProfitRate" IS NOT NULL))`,
2193
+ fuzzy('customer.name', query.filters.customer),
2194
+ query.filters.unreceivedOnly ? `GREATEST(${orderAmount} - COALESCE(manual."receivedAmountCny", 0), 0) > 0` : null,
2195
+ `EXISTS (SELECT 1 FROM plugin_trade_compliance_purchase_order_line matched_line WHERE matched_line."purchaseOrderId" = purchase.id AND ${lineConditions('matched_line').join(' AND ')})`
2196
+ ].filter((condition) => Boolean(condition));
2197
+ const orders = `sales_orders AS (
2198
+ SELECT contract.id AS "customerContractId", purchase.id AS "purchaseOrderId", customer.id AS "customerId", customer.name AS "subjectLabel", ${effectiveRate} AS "effectiveRate",
2199
+ ${orderAmount} AS "amountCny", COALESCE(manual."receivedAmountCny", 0) AS "receivedCny",
2200
+ GREATEST(${orderAmount} - COALESCE(manual."receivedAmountCny", 0), 0) AS "unreceivedCny"
2201
+ FROM plugin_trade_compliance_customer_contract contract
2202
+ INNER JOIN plugin_trade_compliance_customer customer ON customer.id = contract."customerId" AND customer."tenantId" = contract."tenantId" AND customer."organizationId" = contract."organizationId" AND customer."deletedAt" IS NULL
2203
+ INNER JOIN plugin_trade_compliance_purchase_order purchase ON purchase."tenantId" = contract."tenantId" AND purchase."organizationId" = contract."organizationId" AND purchase."deletedAt" IS NULL AND contract."normalizedContractNo" = regexp_replace(purchase."normalizedContractNo", '(-[0-9]+)?C$', '') || 'X'
2204
+ LEFT JOIN plugin_trade_compliance_derived_sales_manual manual ON manual."customerContractId" = contract.id AND manual."purchaseOrderId" = purchase.id AND manual."tenantId" = contract."tenantId" AND manual."organizationId" = contract."organizationId" AND manual."deletedAt" IS NULL
2205
+ WHERE ${orderConditions.join(' AND ')}
2206
+ )`;
2207
+ sourceSql = query.dimension === 'PRODUCT'
2208
+ ? `${orders}, grouped AS (
2209
+ SELECT LOWER(BTRIM(line."productName")) AS key, MIN(BTRIM(line."productName")) AS label,
2210
+ COUNT(DISTINCT orders."customerContractId"::text || ':' || orders."purchaseOrderId"::text)::bigint AS "orderCount",
2211
+ SUM(line.quantity) AS "productQuantity", SUM(ROUND(ROUND(line."unitPriceTaxIncluded" * (1 + orders."effectiveRate"), 0) * line.quantity, 0)) AS "amountCny"
2212
+ FROM sales_orders orders
2213
+ INNER JOIN plugin_trade_compliance_purchase_order_line line ON line."purchaseOrderId" = orders."purchaseOrderId" AND ${lineConditions('line').join(' AND ')}
2214
+ GROUP BY LOWER(BTRIM(line."productName"))
2215
+ )`
2216
+ : `${orders}, grouped AS (
2217
+ SELECT orders."customerId"::text AS key, MIN(orders."subjectLabel") AS label,
2218
+ COUNT(DISTINCT orders."customerContractId"::text || ':' || orders."purchaseOrderId"::text)::bigint AS "orderCount",
2219
+ SUM(orders."amountCny") AS "amountCny", SUM(orders."receivedCny") AS "receivedCny", SUM(orders."unreceivedCny") AS "unreceivedCny"
2220
+ FROM sales_orders orders GROUP BY orders."customerId"
2221
+ )`;
2222
+ }
2223
+ const bounded = `${sourceSql}, bounded AS (SELECT * FROM grouped ORDER BY "amountCny" DESC, key ASC LIMIT ${ANALYTICS_EXPORT_MAX_ROWS + 1})`;
2224
+ if (purpose === 'EXPORT')
2225
+ return { sql: `WITH ${bounded} SELECT * FROM bounded ORDER BY "amountCny" DESC, key ASC`, parameters };
2226
+ const metricColumns = query.dimension === 'PRODUCT'
2227
+ ? 'SUM("amountCny") OVER () AS "metricAmountCny"'
2228
+ : query.mode === 'PURCHASE'
2229
+ ? 'SUM("amountCny") OVER () AS "metricAmountCny", SUM("paidCny") OVER () AS "metricPaidCny", SUM("unpaidCny") OVER () AS "metricUnpaidCny", SUM("refundedCny") OVER () AS "metricRefundedCny", SUM("unrefundedCny") OVER () AS "metricUnrefundedCny"'
2230
+ : 'SUM("amountCny") OVER () AS "metricAmountCny", SUM("receivedCny") OVER () AS "metricReceivedCny", SUM("unreceivedCny") OVER () AS "metricUnreceivedCny"';
2231
+ const start = (query.page - 1) * query.pageSize + 1;
2232
+ const end = start + query.pageSize - 1;
2233
+ return {
2234
+ sql: `WITH ${bounded}, ranked AS (SELECT bounded.*, ROW_NUMBER() OVER (ORDER BY "amountCny" DESC, key ASC) AS rank, COUNT(*) OVER () AS total, ${metricColumns} FROM bounded) SELECT * FROM ranked WHERE rank <= 5 OR rank BETWEEN ${parameter(start)} AND ${parameter(end)} ORDER BY rank`,
2235
+ parameters
2236
+ };
2237
+ }
2238
+ function analyticsDetailFromRaw(row, query) {
2239
+ const base = { key: String(row.key), label: String(row.label), orderCount: Number(row.orderCount), amountCny: moneyString(row.amountCny) };
2240
+ if (query.dimension === 'PRODUCT')
2241
+ return { ...base, productQuantity: decimalString(row.productQuantity) };
2242
+ if (query.mode === 'PURCHASE')
2243
+ return { ...base, paidCny: moneyString(row.paidCny), unpaidCny: moneyString(row.unpaidCny), refundedCny: moneyString(row.refundedCny), unrefundedCny: moneyString(row.unrefundedCny) };
2244
+ return { ...base, receivedCny: moneyString(row.receivedCny), unreceivedCny: moneyString(row.unreceivedCny) };
2245
+ }
2246
+ function analyticsMetricsFromRaw(row, query) {
2247
+ if (query.mode === 'PURCHASE')
2248
+ return {
2249
+ orderTotalCny: moneyString(row?.metricAmountCny ?? row?.amountCny),
2250
+ paidCny: query.dimension === 'PRODUCT' ? '0.00' : moneyString(row?.metricPaidCny ?? row?.paidCny),
2251
+ unpaidCny: query.dimension === 'PRODUCT' ? '0.00' : moneyString(row?.metricUnpaidCny ?? row?.unpaidCny),
2252
+ refundedCny: query.dimension === 'PRODUCT' ? '0.00' : moneyString(row?.metricRefundedCny ?? row?.refundedCny),
2253
+ unrefundedCny: query.dimension === 'PRODUCT' ? '0.00' : moneyString(row?.metricUnrefundedCny ?? row?.unrefundedCny)
2254
+ };
2255
+ return {
2256
+ orderTotalCny: moneyString(row?.metricAmountCny ?? row?.amountCny),
2257
+ receivedCny: query.dimension === 'PRODUCT' ? '0.00' : moneyString(row?.metricReceivedCny ?? row?.receivedCny),
2258
+ unreceivedCny: query.dimension === 'PRODUCT' ? '0.00' : moneyString(row?.metricUnreceivedCny ?? row?.unreceivedCny)
2259
+ };
2260
+ }
2261
+ function moneyString(value) { try {
2262
+ return new Decimal(value == null ? 0 : value).toFixed(2);
2263
+ }
2264
+ catch {
2265
+ return '0.00';
2266
+ } }
2267
+ function decimalString(value) { try {
2268
+ return new Decimal(value == null ? 0 : value).toString();
2269
+ }
2270
+ catch {
2271
+ return '0';
2272
+ } }
755
2273
  //# sourceMappingURL=workbench.service.js.map