@chenchaolong/plugin-trade-compliance-workbench 0.1.74 → 1.0.1

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 (180) hide show
  1. package/README.md +41 -22
  2. package/dist/assets/templates/CI.xlsx +0 -0
  3. package/dist/assets/templates/CUSTOMS_DECLARATION.xlsx +0 -0
  4. package/dist/assets/templates/PI.xlsx +0 -0
  5. package/dist/assets/templates/PL.xlsx +0 -0
  6. package/dist/index.d.ts +115 -228
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +19 -108
  9. package/dist/index.js.map +1 -1
  10. package/dist/lib/{document-text-extractor.d.ts → adapters/document-text-extractor.d.ts} +2 -2
  11. package/dist/lib/adapters/document-text-extractor.d.ts.map +1 -0
  12. package/dist/lib/{document-text-extractor.js → adapters/document-text-extractor.js} +8 -2
  13. package/dist/lib/adapters/document-text-extractor.js.map +1 -0
  14. package/dist/lib/adapters/file-storage.d.ts +9 -0
  15. package/dist/lib/adapters/file-storage.d.ts.map +1 -0
  16. package/dist/lib/adapters/file-storage.js +37 -0
  17. package/dist/lib/adapters/file-storage.js.map +1 -0
  18. package/dist/lib/{trade-compliance.enrichment.d.ts → adapters/hsbianma.provider.d.ts} +6 -6
  19. package/dist/lib/adapters/hsbianma.provider.d.ts.map +1 -0
  20. package/dist/lib/{trade-compliance.enrichment.js → adapters/hsbianma.provider.js} +1 -1
  21. package/dist/lib/adapters/hsbianma.provider.js.map +1 -0
  22. package/dist/lib/adapters/translation.provider.d.ts +4 -0
  23. package/dist/lib/adapters/translation.provider.d.ts.map +1 -0
  24. package/dist/lib/adapters/translation.provider.js +35 -0
  25. package/dist/lib/adapters/translation.provider.js.map +1 -0
  26. package/dist/lib/constants.d.ts +17 -11
  27. package/dist/lib/constants.d.ts.map +1 -1
  28. package/dist/lib/constants.js +17 -21
  29. package/dist/lib/constants.js.map +1 -1
  30. package/dist/lib/domain/contract-number.d.ts +14 -0
  31. package/dist/lib/domain/contract-number.d.ts.map +1 -0
  32. package/dist/lib/domain/contract-number.js +36 -0
  33. package/dist/lib/domain/contract-number.js.map +1 -0
  34. package/dist/lib/domain/default-settings.d.ts +28 -0
  35. package/dist/lib/domain/default-settings.d.ts.map +1 -0
  36. package/dist/lib/domain/default-settings.js +16 -0
  37. package/dist/lib/domain/default-settings.js.map +1 -0
  38. package/dist/lib/domain/finance.d.ts +37 -0
  39. package/dist/lib/domain/finance.d.ts.map +1 -0
  40. package/dist/lib/domain/finance.js +60 -0
  41. package/dist/lib/domain/finance.js.map +1 -0
  42. package/dist/lib/domain/import-issues.d.ts +10 -0
  43. package/dist/lib/domain/import-issues.d.ts.map +1 -0
  44. package/dist/lib/domain/import-issues.js +23 -0
  45. package/dist/lib/domain/import-issues.js.map +1 -0
  46. package/dist/lib/domain/normalization.d.ts +8 -0
  47. package/dist/lib/domain/normalization.d.ts.map +1 -0
  48. package/dist/lib/domain/normalization.js +50 -0
  49. package/dist/lib/domain/normalization.js.map +1 -0
  50. package/dist/lib/domain/workflow.d.ts +4 -0
  51. package/dist/lib/domain/workflow.d.ts.map +1 -0
  52. package/dist/lib/domain/workflow.js +14 -0
  53. package/dist/lib/domain/workflow.js.map +1 -0
  54. package/dist/lib/entities/base.d.ts +20 -0
  55. package/dist/lib/entities/base.d.ts.map +1 -0
  56. package/dist/lib/entities/base.js +51 -0
  57. package/dist/lib/entities/base.js.map +1 -0
  58. package/dist/lib/entities/catalog.entity.d.ts +80 -0
  59. package/dist/lib/entities/catalog.entity.d.ts.map +1 -0
  60. package/dist/lib/entities/catalog.entity.js +308 -0
  61. package/dist/lib/entities/catalog.entity.js.map +1 -0
  62. package/dist/lib/entities/customer.entity.d.ts +72 -0
  63. package/dist/lib/entities/customer.entity.d.ts.map +1 -0
  64. package/dist/lib/entities/customer.entity.js +276 -0
  65. package/dist/lib/entities/customer.entity.js.map +1 -0
  66. package/dist/lib/entities/index.d.ts +12 -6
  67. package/dist/lib/entities/index.d.ts.map +1 -1
  68. package/dist/lib/entities/index.js +20 -7
  69. package/dist/lib/entities/index.js.map +1 -1
  70. package/dist/lib/entities/procurement.entity.d.ts +99 -0
  71. package/dist/lib/entities/procurement.entity.d.ts.map +1 -0
  72. package/dist/lib/entities/procurement.entity.js +386 -0
  73. package/dist/lib/entities/procurement.entity.js.map +1 -0
  74. package/dist/lib/entities/settings.entity.d.ts +64 -0
  75. package/dist/lib/entities/settings.entity.d.ts.map +1 -0
  76. package/dist/lib/entities/settings.entity.js +243 -0
  77. package/dist/lib/entities/settings.entity.js.map +1 -0
  78. package/dist/lib/entities/shared.entity.d.ts +40 -0
  79. package/dist/lib/entities/shared.entity.d.ts.map +1 -0
  80. package/dist/lib/entities/shared.entity.js +149 -0
  81. package/dist/lib/entities/shared.entity.js.map +1 -0
  82. package/dist/lib/remote-components/trade-compliance-workbench/app.js +245 -2664
  83. package/dist/lib/remote-components/trade-compliance-workbench/editor-source.mjs +53 -0
  84. package/dist/lib/remote-components/trade-compliance-workbench/editor.css +1 -0
  85. package/dist/lib/remote-components/trade-compliance-workbench/editor.js +533 -0
  86. package/dist/lib/sales-file.service.d.ts +29 -0
  87. package/dist/lib/sales-file.service.d.ts.map +1 -0
  88. package/dist/lib/sales-file.service.js +300 -0
  89. package/dist/lib/sales-file.service.js.map +1 -0
  90. package/dist/lib/trade-compliance.config.d.ts +94 -187
  91. package/dist/lib/trade-compliance.config.d.ts.map +1 -1
  92. package/dist/lib/trade-compliance.config.js +12 -96
  93. package/dist/lib/trade-compliance.config.js.map +1 -1
  94. package/dist/lib/types.d.ts +104 -1
  95. package/dist/lib/types.d.ts.map +1 -1
  96. package/dist/lib/types.js +47 -1
  97. package/dist/lib/types.js.map +1 -1
  98. package/dist/lib/workbench-view.provider.d.ts +276 -0
  99. package/dist/lib/workbench-view.provider.d.ts.map +1 -0
  100. package/dist/lib/workbench-view.provider.js +222 -0
  101. package/dist/lib/workbench-view.provider.js.map +1 -0
  102. package/dist/lib/workbench.middleware.d.ts +351 -0
  103. package/dist/lib/workbench.middleware.d.ts.map +1 -0
  104. package/dist/lib/workbench.middleware.js +48 -0
  105. package/dist/lib/workbench.middleware.js.map +1 -0
  106. package/dist/lib/workbench.plugin.d.ts +5 -0
  107. package/dist/lib/workbench.plugin.d.ts.map +1 -0
  108. package/dist/lib/workbench.plugin.js +24 -0
  109. package/dist/lib/workbench.plugin.js.map +1 -0
  110. package/dist/lib/workbench.service.d.ts +257 -0
  111. package/dist/lib/workbench.service.d.ts.map +1 -0
  112. package/dist/lib/workbench.service.js +755 -0
  113. package/dist/lib/workbench.service.js.map +1 -0
  114. package/package.json +19 -25
  115. package/dist/assets/customs-workbook-template.xls +0 -0
  116. package/dist/lib/controlled-goods-extracted-text-store.d.ts +0 -29
  117. package/dist/lib/controlled-goods-extracted-text-store.d.ts.map +0 -1
  118. package/dist/lib/controlled-goods-extracted-text-store.js +0 -42
  119. package/dist/lib/controlled-goods-extracted-text-store.js.map +0 -1
  120. package/dist/lib/controlled-goods-file-parser.d.ts +0 -9
  121. package/dist/lib/controlled-goods-file-parser.d.ts.map +0 -1
  122. package/dist/lib/controlled-goods-file-parser.js +0 -298
  123. package/dist/lib/controlled-goods-file-parser.js.map +0 -1
  124. package/dist/lib/document-text-extractor.d.ts.map +0 -1
  125. package/dist/lib/document-text-extractor.js.map +0 -1
  126. package/dist/lib/entities/controlled-goods-record.entity.d.ts +0 -5
  127. package/dist/lib/entities/controlled-goods-record.entity.d.ts.map +0 -1
  128. package/dist/lib/entities/controlled-goods-record.entity.js +0 -68
  129. package/dist/lib/entities/controlled-goods-record.entity.js.map +0 -1
  130. package/dist/lib/entities/customs-workbook-generation.entity.d.ts +0 -5
  131. package/dist/lib/entities/customs-workbook-generation.entity.d.ts.map +0 -1
  132. package/dist/lib/entities/customs-workbook-generation.entity.js +0 -72
  133. package/dist/lib/entities/customs-workbook-generation.entity.js.map +0 -1
  134. package/dist/lib/entities/trade-compliance-import-batch.entity.d.ts +0 -5
  135. package/dist/lib/entities/trade-compliance-import-batch.entity.d.ts.map +0 -1
  136. package/dist/lib/entities/trade-compliance-import-batch.entity.js +0 -60
  137. package/dist/lib/entities/trade-compliance-import-batch.entity.js.map +0 -1
  138. package/dist/lib/entities/trade-compliance-review-item.entity.d.ts +0 -5
  139. package/dist/lib/entities/trade-compliance-review-item.entity.d.ts.map +0 -1
  140. package/dist/lib/entities/trade-compliance-review-item.entity.js +0 -84
  141. package/dist/lib/entities/trade-compliance-review-item.entity.js.map +0 -1
  142. package/dist/lib/entities/trade-product.entity.d.ts +0 -5
  143. package/dist/lib/entities/trade-product.entity.d.ts.map +0 -1
  144. package/dist/lib/entities/trade-product.entity.js +0 -105
  145. package/dist/lib/entities/trade-product.entity.js.map +0 -1
  146. package/dist/lib/entities/trade-supplier.entity.d.ts +0 -5
  147. package/dist/lib/entities/trade-supplier.entity.d.ts.map +0 -1
  148. package/dist/lib/entities/trade-supplier.entity.js +0 -55
  149. package/dist/lib/entities/trade-supplier.entity.js.map +0 -1
  150. package/dist/lib/trade-compliance-workbench-view.provider.d.ts +0 -129
  151. package/dist/lib/trade-compliance-workbench-view.provider.d.ts.map +0 -1
  152. package/dist/lib/trade-compliance-workbench-view.provider.js +0 -863
  153. package/dist/lib/trade-compliance-workbench-view.provider.js.map +0 -1
  154. package/dist/lib/trade-compliance-workbench.middleware.d.ts +0 -485
  155. package/dist/lib/trade-compliance-workbench.middleware.d.ts.map +0 -1
  156. package/dist/lib/trade-compliance-workbench.middleware.js +0 -494
  157. package/dist/lib/trade-compliance-workbench.middleware.js.map +0 -1
  158. package/dist/lib/trade-compliance-workbench.plugin.d.ts +0 -11
  159. package/dist/lib/trade-compliance-workbench.plugin.d.ts.map +0 -1
  160. package/dist/lib/trade-compliance-workbench.plugin.js +0 -39
  161. package/dist/lib/trade-compliance-workbench.plugin.js.map +0 -1
  162. package/dist/lib/trade-compliance-workbench.service.d.ts +0 -33
  163. package/dist/lib/trade-compliance-workbench.service.d.ts.map +0 -1
  164. package/dist/lib/trade-compliance-workbench.service.js +0 -369
  165. package/dist/lib/trade-compliance-workbench.service.js.map +0 -1
  166. package/dist/lib/trade-compliance-workbench.templates.d.ts +0 -31
  167. package/dist/lib/trade-compliance-workbench.templates.d.ts.map +0 -1
  168. package/dist/lib/trade-compliance-workbench.templates.js +0 -60
  169. package/dist/lib/trade-compliance-workbench.templates.js.map +0 -1
  170. package/dist/lib/trade-compliance-workbook.d.ts +0 -28
  171. package/dist/lib/trade-compliance-workbook.d.ts.map +0 -1
  172. package/dist/lib/trade-compliance-workbook.js +0 -546
  173. package/dist/lib/trade-compliance-workbook.js.map +0 -1
  174. package/dist/lib/trade-compliance.enrichment.d.ts.map +0 -1
  175. package/dist/lib/trade-compliance.enrichment.js.map +0 -1
  176. package/dist/lib/trade-compliance.matching.d.ts +0 -10
  177. package/dist/lib/trade-compliance.matching.d.ts.map +0 -1
  178. package/dist/lib/trade-compliance.matching.js +0 -63
  179. package/dist/lib/trade-compliance.matching.js.map +0 -1
  180. package/dist/xpert-trade-compliance-workbench-assistant.yaml +0 -27
@@ -0,0 +1,755 @@
1
+ import { __decorate, __metadata, __param } from "tslib";
2
+ import { Injectable } from '@nestjs/common';
3
+ import { InjectDataSource } from '@nestjs/typeorm';
4
+ import { Brackets, DataSource, In, IsNull } from 'typeorm';
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';
8
+ import { deriveSalesLine, assertReceivedAmount } from './domain/finance.js';
9
+ import { parseCustomerContractNo, parsePurchaseContractNo } from './domain/contract-number.js';
10
+ import { isProductNameMatch, normalizeExactName, normalizeForProductMatch, normalizeHsCode, normalizeSanctionSource } from './domain/normalization.js';
11
+ import { importTaskTimedOut, nextSettingsRevision, normalizeOptionalDecimalInput } from './domain/workflow.js';
12
+ 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>';
15
+ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
16
+ constructor(dataSource) {
17
+ this.dataSource = dataSource;
18
+ }
19
+ async bootstrapSettings(scope) {
20
+ 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
+ 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
+ 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
+ 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
+ }
40
+ return { risk, profit, company, banks };
41
+ }
42
+ async dashboard(scope) {
43
+ const [{ risk }, controlledRiskCount, sanctionRiskCount, unlinkedPurchaseCount, unlinkedCustomerCount] = await Promise.all([
44
+ this.bootstrapSettings(scope),
45
+ this.dataSource.getRepository(ProductComplianceMatch).count({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
46
+ this.dataSource.getRepository(CompanySanctionMatch).count({ where: { ...activeScope(scope), deletedAt: IsNull() } }),
47
+ this.countUnlinkedPurchases(scope),
48
+ this.countUnlinkedCustomers(scope)
49
+ ]);
50
+ return { riskNotice: risk, controlledRiskCount, sanctionRiskCount, unlinkedPurchaseCount, unlinkedCustomerCount };
51
+ }
52
+ async listControlledGoods(scope, query = {}) {
53
+ return this.pagedQuery(ControlledGoodsRecord, scope, query, ['productName', 'hsCode', 'controlDescription'], {
54
+ catalogType: query.parameters?.catalogType,
55
+ sourceFileName: query.parameters?.sourceFileName
56
+ });
57
+ }
58
+ async listImportTasks(scope, query = {}) {
59
+ await this.expireStaleImportTasks(scope);
60
+ return this.pagedQuery(ImportTask, scope, query, ['sourceFileName', 'documentType', 'status', 'errorMessage'], { documentType: query.parameters?.documentType, status: query.parameters?.status });
61
+ }
62
+ async listControlledBatches(scope, query = {}) { return this.pagedQuery(ControlledCatalogBatch, scope, query, ['sourceFileName'], { catalogType: query.parameters?.catalogType }); }
63
+ async listSanctionBatches(scope, query = {}) { return this.pagedQuery(SanctionCatalogBatch, scope, query, ['sourceFileName']); }
64
+ async saveControlledGoods(scope, input) {
65
+ const repo = this.dataSource.getRepository(ControlledGoodsRecord);
66
+ const productName = input.productName.trim();
67
+ const normalizedProductName = productName.trim();
68
+ const normalizedHsCode = String(input.hsCode ?? '').replace(/\s/g, '');
69
+ const duplicate = await repo.findOne({ where: { ...activeScope(scope), catalogType: input.catalogType, normalizedProductName, normalizedHsCode, deletedAt: IsNull() } });
70
+ if (duplicate)
71
+ throw new DomainError(DomainErrorCodes.DUPLICATE_CATALOG_RECORD, '相同目录类型、商品名称和海关编码的记录已存在', { id: duplicate.id });
72
+ return repo.save(repo.create({ ...auditScope(scope), sourceType: input.sourceType ?? 'MANUAL_ENTRY', catalogType: input.catalogType, productName, normalizedProductName, hsCode: input.hsCode ?? null, normalizedHsCode, controlDescription: input.controlDescription ?? null, sourceLocation: input.sourceLocation ?? null, sourceContent: input.sourceContent ?? null }));
73
+ }
74
+ async updateControlledGoods(scope, id, input) {
75
+ const repo = this.dataSource.getRepository(ControlledGoodsRecord);
76
+ const item = await required(repo.findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() } }), '管控商品不存在');
77
+ Object.assign(item, pick(input, ['catalogType', 'productName', 'hsCode', 'controlDescription']));
78
+ item.normalizedProductName = item.productName.trim();
79
+ item.normalizedHsCode = String(item.hsCode ?? '').replace(/\s/g, '');
80
+ item.updatedById = scope.userId ?? null;
81
+ const saved = await repo.save(item);
82
+ await this.recalculateProductRisks(scope, 'CATALOG_CHANGED');
83
+ return saved;
84
+ }
85
+ async importControlledCatalog(scope, input) {
86
+ return this.dataSource.transaction(async (manager) => {
87
+ const batchRepo = manager.getRepository(ControlledCatalogBatch);
88
+ const recordRepo = manager.getRepository(ControlledGoodsRecord);
89
+ const issueRepo = manager.getRepository(ControlledCatalogImportIssue);
90
+ const taskRepo = manager.getRepository(ImportTask);
91
+ const task = await required(taskRepo.findOne({ where: { ...activeScope(scope), id: input.importTaskId } }), '解析任务不存在');
92
+ task.status = 'PERSISTING';
93
+ await taskRepo.save(task);
94
+ const now = new Date();
95
+ if (input.replaceBatchId) {
96
+ const old = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, deletedAt: IsNull() } }), '被覆盖目录批次不存在');
97
+ old.deletedAt = now;
98
+ old.deletedById = scope.userId ?? null;
99
+ old.deleteOperation = 'CATALOG_REPLACE';
100
+ old.updatedById = scope.userId ?? null;
101
+ await batchRepo.save(old);
102
+ await recordRepo.update({ ...activeScope(scope), batchId: old.id, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'CATALOG_REPLACE' });
103
+ }
104
+ const batch = await batchRepo.save(batchRepo.create({ ...auditScope(scope), catalogType: input.catalogType, sourceFileId: input.sourceFileId, sourceFileName: input.sourceFileName, importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null, totalCount: input.rows.length, successCount: 0, duplicateCount: 0, invalidCount: 0, importTaskId: input.importTaskId }));
105
+ for (let index = 0; index < input.rows.length; index += 1) {
106
+ const row = input.rows[index];
107
+ const productName = String(row.productName ?? '').trim();
108
+ const normalizedHsCode = String(row.hsCode ?? '').replace(/\s/g, '');
109
+ if (!productName) {
110
+ batch.invalidCount += 1;
111
+ await issueRepo.save(issueRepo.create({ ...createdScope(scope), batchId: batch.id, issueType: 'INVALID', sourceSequence: index + 1, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null, parsedPayload: row, message: '商品名称为空' }));
112
+ continue;
113
+ }
114
+ const duplicate = await recordRepo.findOne({ where: { ...activeScope(scope), catalogType: input.catalogType, normalizedProductName: productName, normalizedHsCode, deletedAt: IsNull() } });
115
+ if (duplicate) {
116
+ batch.duplicateCount += 1;
117
+ await issueRepo.save(issueRepo.create({ ...createdScope(scope), batchId: batch.id, issueType: 'DUPLICATE', sourceSequence: index + 1, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null, parsedPayload: row, message: '目录中已存在相同商品名称和海关编码' }));
118
+ continue;
119
+ }
120
+ await recordRepo.save(recordRepo.create({ ...auditScope(scope), batchId: batch.id, sourceType: 'FILE_IMPORT', catalogType: input.catalogType, productName, normalizedProductName: productName, hsCode: row.hsCode ?? null, normalizedHsCode, controlDescription: row.controlDescription ?? null, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null }));
121
+ batch.successCount += 1;
122
+ }
123
+ await batchRepo.save(batch);
124
+ task.status = 'SUCCEEDED';
125
+ task.resultEntityId = batch.id;
126
+ task.completedAt = new Date();
127
+ await taskRepo.save(task);
128
+ return batch;
129
+ }).then(async (batch) => { await this.recalculateProductRisks(scope, 'CATALOG_CHANGED'); return batch; });
130
+ }
131
+ async listSanctionedCompanies(scope, query = {}) {
132
+ return this.pagedQuery(SanctionedCompanyRecord, scope, query, ['companyName', 'countryOrRegion', 'sanctionListSource', 'sanctionReason'], { sanctionListSource: query.parameters?.sanctionListSource });
133
+ }
134
+ async downloadImportIssues(scope, documentType, batchId) {
135
+ const batchEntity = documentType === 'CONTROLLED_CATALOG' ? ControlledCatalogBatch : SanctionCatalogBatch;
136
+ const issueEntity = documentType === 'CONTROLLED_CATALOG' ? ControlledCatalogImportIssue : SanctionCatalogImportIssue;
137
+ 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' } });
139
+ return { fileName: `${batch.sourceFileName}-导入问题明细.xlsx`, buffer: await renderImportIssuesWorkbook(issues) };
140
+ }
141
+ async saveSanctionedCompany(scope, input) {
142
+ const repo = this.dataSource.getRepository(SanctionedCompanyRecord);
143
+ const companyName = input.companyName.trim();
144
+ const normalizedCompanyName = normalizeExactName(companyName);
145
+ const normalizedSanctionListSource = normalizeSanctionSource(input.sanctionListSource);
146
+ const duplicate = await repo.findOne({ where: { ...activeScope(scope), normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull() } });
147
+ if (duplicate)
148
+ throw new DomainError(DomainErrorCodes.DUPLICATE_CATALOG_RECORD, '相同公司名称和制裁名单来源的记录已存在', { id: duplicate.id });
149
+ const saved = await repo.save(repo.create({ ...auditScope(scope), sourceType: input.sourceType ?? 'MANUAL_ENTRY', companyName, normalizedCompanyName, countryOrRegion: input.countryOrRegion ?? null, sanctionListSource: input.sanctionListSource ?? null, normalizedSanctionListSource, sanctionReason: input.sanctionReason ?? null, effectiveDate: input.effectiveDate ?? null, remarks: input.remarks ?? null, sourceLocation: input.sourceLocation ?? null, sourceContent: input.sourceContent ?? null }));
150
+ await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
151
+ return saved;
152
+ }
153
+ async updateSanctionedCompany(scope, id, input) {
154
+ const repo = this.dataSource.getRepository(SanctionedCompanyRecord);
155
+ const item = await required(repo.findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() } }), '制裁公司不存在');
156
+ Object.assign(item, pick(input, ['companyName', 'countryOrRegion', 'sanctionListSource', 'sanctionReason', 'effectiveDate', 'remarks']));
157
+ item.normalizedCompanyName = normalizeExactName(item.companyName);
158
+ item.normalizedSanctionListSource = normalizeSanctionSource(item.sanctionListSource);
159
+ item.updatedById = scope.userId ?? null;
160
+ const saved = await repo.save(item);
161
+ await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
162
+ return saved;
163
+ }
164
+ async importSanctionCatalog(scope, input) {
165
+ const batch = await this.dataSource.transaction(async (manager) => {
166
+ const batchRepo = manager.getRepository(SanctionCatalogBatch);
167
+ const recordRepo = manager.getRepository(SanctionedCompanyRecord);
168
+ const issueRepo = manager.getRepository(SanctionCatalogImportIssue);
169
+ const taskRepo = manager.getRepository(ImportTask);
170
+ const task = await required(taskRepo.findOne({ where: { ...activeScope(scope), id: input.importTaskId } }), '解析任务不存在');
171
+ task.status = 'PERSISTING';
172
+ await taskRepo.save(task);
173
+ const now = new Date();
174
+ if (input.replaceBatchId) {
175
+ const old = await required(batchRepo.findOne({ where: { ...activeScope(scope), id: input.replaceBatchId, deletedAt: IsNull() } }), '被覆盖制裁批次不存在');
176
+ old.deletedAt = now;
177
+ old.deletedById = scope.userId ?? null;
178
+ old.deleteOperation = 'SANCTION_CATALOG_REPLACE';
179
+ old.updatedById = scope.userId ?? null;
180
+ await batchRepo.save(old);
181
+ await recordRepo.update({ ...activeScope(scope), batchId: old.id, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SANCTION_CATALOG_REPLACE' });
182
+ }
183
+ const result = await batchRepo.save(batchRepo.create({ ...auditScope(scope), sourceFileId: input.sourceFileId, sourceFileName: input.sourceFileName, importMode: input.replaceBatchId ? 'REPLACE_BATCH' : 'APPEND', replacedBatchId: input.replaceBatchId ?? null, totalCount: input.rows.length, successCount: 0, duplicateCount: 0, invalidCount: 0, importTaskId: input.importTaskId }));
184
+ for (let index = 0; index < input.rows.length; index += 1) {
185
+ const row = input.rows[index];
186
+ const companyName = String(row.companyName ?? '').trim();
187
+ if (!companyName) {
188
+ result.invalidCount += 1;
189
+ await issueRepo.save(issueRepo.create({ ...createdScope(scope), batchId: result.id, issueType: 'INVALID', sourceSequence: index + 1, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null, parsedPayload: row, message: '公司名称为空' }));
190
+ continue;
191
+ }
192
+ const normalizedCompanyName = normalizeExactName(companyName);
193
+ const normalizedSanctionListSource = normalizeSanctionSource(row.sanctionListSource);
194
+ const duplicate = await recordRepo.findOne({ where: { ...activeScope(scope), normalizedCompanyName, normalizedSanctionListSource, deletedAt: IsNull() } });
195
+ if (duplicate) {
196
+ result.duplicateCount += 1;
197
+ await issueRepo.save(issueRepo.create({ ...createdScope(scope), batchId: result.id, issueType: 'DUPLICATE', sourceSequence: index + 1, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null, parsedPayload: row, message: '制裁目录中已存在相同公司和名单来源' }));
198
+ continue;
199
+ }
200
+ await recordRepo.save(recordRepo.create({ ...auditScope(scope), batchId: result.id, sourceType: 'FILE_IMPORT', companyName, normalizedCompanyName, countryOrRegion: row.countryOrRegion ?? null, sanctionListSource: row.sanctionListSource ?? null, normalizedSanctionListSource, sanctionReason: row.sanctionReason ?? null, effectiveDate: row.effectiveDate ?? null, remarks: row.remarks ?? null, sourceLocation: row.sourceLocation ?? null, sourceContent: row.sourceContent ?? null }));
201
+ result.successCount += 1;
202
+ }
203
+ const saved = await batchRepo.save(result);
204
+ task.status = 'SUCCEEDED';
205
+ task.resultEntityId = saved.id;
206
+ task.completedAt = new Date();
207
+ await taskRepo.save(task);
208
+ return saved;
209
+ });
210
+ await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
211
+ return batch;
212
+ }
213
+ async createImportTask(scope, input) {
214
+ const repo = this.dataSource.getRepository(ImportTask);
215
+ return repo.save(repo.create({ ...createdScope(scope), ...input, status: input.status ?? 'QUEUED', queuedAt: new Date(), createdById: scope.userId ?? null }));
216
+ }
217
+ async updateImportTaskDraft(scope, id, payload, version = 'v1') {
218
+ const repo = this.dataSource.getRepository(ImportTask);
219
+ const task = await required(repo.findOne({ where: { ...activeScope(scope), id } }), '解析任务不存在');
220
+ task.resultPayload = payload;
221
+ task.resultPayloadVersion = version;
222
+ task.status = 'AWAITING_REVIEW';
223
+ task.completedAt = new Date();
224
+ return repo.save(task);
225
+ }
226
+ async markImportTaskParsing(scope, id) {
227
+ const repo = this.dataSource.getRepository(ImportTask);
228
+ const task = await required(repo.findOne({ where: { ...activeScope(scope), id } }), '解析任务不存在');
229
+ task.status = 'AI_PARSING';
230
+ task.startedAt ??= new Date();
231
+ task.completedAt = null;
232
+ return repo.save(task);
233
+ }
234
+ async savePurchaseDraft(scope, importTaskId, draft) {
235
+ const order = await this.dataSource.transaction(async (manager) => {
236
+ const orderRepo = manager.getRepository(PurchaseOrder);
237
+ const normalizedContractNo = parsePurchaseContractNo(draft.order.contractNo).normalized;
238
+ const duplicate = await orderRepo.findOne({ where: { ...activeScope(scope), normalizedContractNo, deletedAt: IsNull() } });
239
+ if (duplicate)
240
+ throw new DomainError(DomainErrorCodes.DUPLICATE_CONTRACT, '采购合同已存在,如需重新上传请先删除旧记录', { id: duplicate.id, contractNo: draft.order.contractNo });
241
+ const supplier = await this.upsertSupplier(manager, scope, draft.supplier);
242
+ const task = await required(manager.getRepository(ImportTask).findOne({ where: { ...activeScope(scope), id: importTaskId } }), '解析任务不存在');
243
+ 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 }));
246
+ const lineRepo = manager.getRepository(PurchaseOrderLine);
247
+ for (let index = 0; index < draft.lines.length; index += 1) {
248
+ const line = draft.lines[index];
249
+ if (new Decimal(line.quantity).lte(0))
250
+ 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 }));
252
+ }
253
+ task.status = 'SUCCEEDED';
254
+ task.resultEntityId = entity.id;
255
+ task.reviewSavedAt = new Date();
256
+ task.completedAt = new Date();
257
+ await manager.getRepository(ImportTask).save(task);
258
+ return entity;
259
+ });
260
+ await this.recalculateProductRisks(scope, 'SOURCE_CHANGED');
261
+ await this.recalculateCompanyRisks(scope, 'SOURCE_CHANGED');
262
+ return order;
263
+ }
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 }); }
267
+ 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);
274
+ await this.recalculateCompanyRisks(scope, 'SOURCE_CHANGED');
275
+ return saved;
276
+ }
277
+ async updatePurchaseOrder(scope, id, input) {
278
+ const repo = this.dataSource.getRepository(PurchaseOrder);
279
+ const item = await required(repo.findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() } }), '采购订单不存在');
280
+ Object.assign(item, pick(input, ['orderDate', 'contractAmountCny', 'paidAmountCny', 'refundedAmountCny']));
281
+ if (new Decimal(item.paidAmountCny).gt(item.contractAmountCny))
282
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '已付款不能超过采购订单总额');
283
+ item.updatedById = scope.userId ?? null;
284
+ return repo.save(item);
285
+ }
286
+ 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))
291
+ 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);
296
+ await this.recalculateProductRisks(scope, 'SOURCE_CHANGED');
297
+ return saved;
298
+ }
299
+ 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 }));
303
+ await this.recalculateProductRisks(scope, 'SOURCE_CHANGED');
304
+ return saved;
305
+ }
306
+ async saveCustomerContractDraft(scope, importTaskId, draft) {
307
+ const contract = await this.dataSource.transaction(async (manager) => {
308
+ const parsed = parseCustomerContractNo(draft.contract.contractNo);
309
+ const contractRepo = manager.getRepository(CustomerContract);
310
+ const duplicate = await contractRepo.findOne({ where: { ...activeScope(scope), normalizedContractNo: parsed.normalized, deletedAt: IsNull() } });
311
+ if (duplicate)
312
+ throw new DomainError(DomainErrorCodes.DUPLICATE_CONTRACT, '客户合同已存在,如需重新上传请先删除旧记录', { id: duplicate.id, contractNo: draft.contract.contractNo });
313
+ const customer = await this.upsertCustomer(manager, scope, draft.customer);
314
+ const task = await required(manager.getRepository(ImportTask).findOne({ where: { ...activeScope(scope), id: importTaskId } }), '解析任务不存在');
315
+ task.status = 'PERSISTING';
316
+ await manager.getRepository(ImportTask).save(task);
317
+ const overallRiskLevel = maxRisk(draft.analyses?.map(item => item.riskLevel) ?? []);
318
+ 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
+ const clauseRepo = manager.getRepository(CustomerContractClause);
320
+ 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
+ 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);
325
+ if (analysis)
326
+ 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
+ }
328
+ entity.latestAnalysisRunId = analysisRun.id;
329
+ await contractRepo.save(entity);
330
+ task.status = 'SUCCEEDED';
331
+ task.resultEntityId = entity.id;
332
+ task.reviewSavedAt = new Date();
333
+ task.completedAt = new Date();
334
+ await manager.getRepository(ImportTask).save(task);
335
+ return entity;
336
+ });
337
+ await this.recalculateCompanyRisks(scope, 'SOURCE_CHANGED');
338
+ return contract;
339
+ }
340
+ 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 }); }
342
+ async getCustomerContractReview(scope, id) {
343
+ const contract = await required(this.dataSource.getRepository(CustomerContract).findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() } }), '客户合同不存在');
344
+ const [customer, clauses, runs] = await Promise.all([
345
+ this.dataSource.getRepository(Customer).findOne({ where: { ...activeScope(scope), id: contract.customerId, deletedAt: IsNull() } }),
346
+ this.dataSource.getRepository(CustomerContractClause).find({ where: { ...activeScope(scope), customerContractId: id, deletedAt: IsNull() } }),
347
+ this.dataSource.getRepository(CustomerContractAnalysisRun).find({ where: { ...activeScope(scope), customerContractId: id }, order: { createdAt: 'DESC' } })
348
+ ]);
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 };
351
+ }
352
+ async updateCustomerContractReview(scope, id, input) {
353
+ const saved = await this.dataSource.transaction(async (manager) => {
354
+ const contractRepo = manager.getRepository(CustomerContract);
355
+ const contract = await required(contractRepo.findOne({ where: { ...activeScope(scope), id, deletedAt: IsNull() } }), '客户合同不存在');
356
+ const customerRepo = manager.getRepository(Customer);
357
+ const customer = await required(customerRepo.findOne({ where: { ...activeScope(scope), id: contract.customerId, deletedAt: IsNull() } }), '客户不存在');
358
+ 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);
363
+ }
364
+ if (input.contract) {
365
+ Object.assign(contract, pick(input.contract, ['contractAmountReviewCny', 'signedAt']));
366
+ contract.updatedById = scope.userId ?? null;
367
+ await contractRepo.save(contract);
368
+ }
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';
374
+ clause.updatedById = scope.userId ?? null;
375
+ await clauseRepo.save(clause);
376
+ }
377
+ return { contract, customer };
378
+ });
379
+ await this.recalculateCompanyRisks(scope, 'SOURCE_CHANGED');
380
+ return saved;
381
+ }
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() } });
389
+ 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() } });
392
+ const results = [];
393
+ for (const contract of contracts) {
394
+ const customer = customerMap.get(contract.customerId) ?? await customerRepo.findOne({ where: { ...activeScope(scope), id: contract.customerId, deletedAt: IsNull() } });
395
+ if (!customer)
396
+ continue;
397
+ 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;
401
+ if (effectiveRate == null)
402
+ continue;
403
+ 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 => {
407
+ 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 })) };
409
+ });
410
+ const amount = derivedLines.reduce((sum, line) => sum.plus(line.salesLineAmountCny), new Decimal(0));
411
+ const received = new Decimal(manual?.receivedAmountCny ?? 0);
412
+ 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
+ }
414
+ }
415
+ return results;
416
+ }
417
+ 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
+ if (new Decimal(rate).isNegative())
421
+ 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);
426
+ }
427
+ 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);
434
+ await this.recalculateCompanyRisks(scope, 'SOURCE_CHANGED');
435
+ return saved;
436
+ }
437
+ 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);
452
+ }
453
+ 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 };
455
+ const entity = map[entityType];
456
+ if (!entity)
457
+ 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
+ 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
+ }
484
+ if (entity === ControlledGoodsRecord)
485
+ await this.recalculateProductRisks(scope, 'CATALOG_CHANGED');
486
+ if (entity === SanctionedCompanyRecord)
487
+ await this.recalculateCompanyRisks(scope, 'CATALOG_CHANGED');
488
+ return row;
489
+ }
490
+ 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
+ async settings(scope) {
492
+ const [{ risk, profit }, company, banks, rates] = await Promise.all([
493
+ this.bootstrapSettings(scope),
494
+ this.dataSource.getRepository(CompanySettings).findOne({ where: activeScope(scope) }),
495
+ this.dataSource.getRepository(BankAccountSettings).find({ where: activeScope(scope), order: { bankType: 'ASC' } }),
496
+ this.dataSource.getRepository(MonthlySafeExchangeRate).find({ where: { ...activeScope(scope), deletedAt: IsNull() }, order: { year: 'DESC', month: 'DESC' } })
497
+ ]);
498
+ return { risk, company, banks, profit, rates };
499
+ }
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();
512
+ }
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;
524
+ }
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
+ };
558
+ }
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);
577
+ }
578
+ return { scanned: lines.length, matches: await matchRepo.count({ where: { ...activeScope(scope), deletedAt: IsNull() } }) };
579
+ }
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);
594
+ }
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);
600
+ }
601
+ return { scanned: suppliers.length + customers.length, matches: await repo.count({ where: { ...activeScope(scope), deletedAt: IsNull() } }) };
602
+ }
603
+ async pagedQuery(entity, scope, query, searchFields, exactFilters = {}) {
604
+ const page = Math.max(1, Number(query.page ?? 1));
605
+ const pageSize = Math.min(200, Math.max(1, Number(query.pageSize ?? 20)));
606
+ const qb = this.dataSource.getRepository(entity).createQueryBuilder('row').where('row.tenantId = :tenantId AND row.organizationId = :organizationId', activeScope(scope));
607
+ if (this.dataSource.getMetadata(entity).findColumnWithPropertyName('deletedAt'))
608
+ qb.andWhere('row.deletedAt IS NULL');
609
+ const search = String(query.search ?? '').trim();
610
+ if (search)
611
+ qb.andWhere(new Brackets(inner => searchFields.forEach((field, index) => index ? inner.orWhere(`CAST(row.${field} AS TEXT) ILIKE :search`) : inner.where(`CAST(row.${field} AS TEXT) ILIKE :search`))), { search: `%${search}%` });
612
+ for (const [key, value] of Object.entries(exactFilters))
613
+ if (value !== undefined && value !== null && value !== '')
614
+ qb.andWhere(`row.${key} = :filter_${key}`, { [`filter_${key}`]: value });
615
+ const [items, total] = await qb.orderBy('row.createdAt', 'DESC').skip((page - 1) * pageSize).take(pageSize).getManyAndCount();
616
+ return { items, total, page, pageSize };
617
+ }
618
+ async upsertSupplier(manager, scope, input) {
619
+ const repo = manager.getRepository(Supplier);
620
+ const normalizedName = normalizeExactName(input.name);
621
+ let item = await repo.findOne({ where: { ...activeScope(scope), normalizedName, deletedAt: IsNull() } });
622
+ if (!item)
623
+ item = repo.create({ ...auditScope(scope), name: input.name.trim(), normalizedName });
624
+ const manualCode = item.code;
625
+ Object.assign(item, pick(input, ['name', 'shortName', 'address', 'contactName', 'phone', 'wechat', 'email', 'taxNumber', 'paymentAccount', 'bankName', 'taxpayerRating', 'website', 'businessProducts']));
626
+ item.normalizedName = normalizedName;
627
+ item.code = manualCode ?? input.code ?? null;
628
+ item.updatedById = scope.userId ?? null;
629
+ return repo.save(item);
630
+ }
631
+ async upsertCustomer(manager, scope, input) {
632
+ const repo = manager.getRepository(Customer);
633
+ const normalizedName = normalizeExactName(input.name);
634
+ let item = await repo.findOne({ where: { ...activeScope(scope), normalizedName, deletedAt: IsNull() } });
635
+ if (!item)
636
+ item = repo.create({ ...auditScope(scope), name: input.name.trim(), normalizedName });
637
+ const manualCode = item.code;
638
+ const profitRate = item.defaultPricingProfitRate;
639
+ Object.assign(item, pick(input, ['name', 'shortName', 'country', 'address', 'contactName', 'phone', 'wechat', 'email', 'taxNumber', 'paymentAccount', 'bankName', 'taxpayerRating', 'website', 'businessProducts']));
640
+ item.normalizedName = normalizedName;
641
+ item.code = manualCode ?? input.code ?? null;
642
+ item.defaultPricingProfitRate = profitRate ?? input.defaultPricingProfitRate ?? null;
643
+ item.updatedById = scope.userId ?? null;
644
+ return repo.save(item);
645
+ }
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
+ }
664
+ }
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))
677
+ continue;
678
+ const rate = manual.pricingProfitRateOverride ?? customer.defaultPricingProfitRate;
679
+ if (rate == null)
680
+ 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);
683
+ }
684
+ }
685
+ async cascadePurchaseOrder(scope, purchaseOrderId) {
686
+ const now = new Date();
687
+ const lineRepo = this.dataSource.getRepository(PurchaseOrderLine);
688
+ const lines = await lineRepo.find({ where: { ...activeScope(scope), purchaseOrderId, deletedAt: IsNull() } });
689
+ if (lines.length) {
690
+ 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' });
692
+ }
693
+ await this.dataSource.getRepository(DerivedSalesOrderManualData).update({ ...activeScope(scope), purchaseOrderId, deletedAt: IsNull() }, { deletedAt: now, deletedById: scope.userId ?? null, deleteOperation: 'SOURCE_CASCADE' });
694
+ }
695
+ async cascadeCustomerContract(scope, customerContractId) {
696
+ const now = new Date();
697
+ 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' })
700
+ ]);
701
+ }
702
+ async expireStaleImportTasks(scope) {
703
+ const repo = this.dataSource.getRepository(ImportTask);
704
+ const tasks = await repo.find({ where: { ...activeScope(scope), status: In(['QUEUED', 'EXTRACTING', 'AI_PARSING', 'VALIDATING', 'PERSISTING']) } });
705
+ const now = new Date();
706
+ for (const task of tasks) {
707
+ const startedAt = task.startedAt ?? task.queuedAt ?? task.createdAt;
708
+ if (!startedAt || !importTaskTimedOut(startedAt, now))
709
+ continue;
710
+ task.status = 'TIMED_OUT';
711
+ task.errorCode = DomainErrorCodes.IMPORT_TIMED_OUT;
712
+ task.errorMessage = '文件解析超过 20 分钟,请重新上传';
713
+ task.completedAt = now;
714
+ task.machineElapsedMs = now.getTime() - startedAt.getTime();
715
+ await repo.save(task);
716
+ }
717
+ }
718
+ async countUnlinkedPurchases(scope) {
719
+ const contracts = await this.dataSource.getRepository(CustomerContract).find({ where: { ...activeScope(scope), deletedAt: IsNull() } });
720
+ const bases = new Set(contracts.map(item => parseCustomerContractNo(item.contractNo).baseCode));
721
+ const orders = await this.dataSource.getRepository(PurchaseOrder).find({ where: { ...activeScope(scope), deletedAt: IsNull() } });
722
+ return orders.filter(item => !bases.has(parsePurchaseContractNo(item.contractNo).baseCode)).length;
723
+ }
724
+ async countUnlinkedCustomers(scope) {
725
+ const purchases = await this.dataSource.getRepository(PurchaseOrder).find({ where: { ...activeScope(scope), deletedAt: IsNull() } });
726
+ const bases = new Set(purchases.map(item => parsePurchaseContractNo(item.contractNo).baseCode));
727
+ const contracts = await this.dataSource.getRepository(CustomerContract).find({ where: { ...activeScope(scope), deletedAt: IsNull() } });
728
+ return contracts.filter(item => !bases.has(parseCustomerContractNo(item.contractNo).baseCode)).length;
729
+ }
730
+ unrefundedAmount(order, lines) {
731
+ const expected = lines.reduce((sum, line) => line.refundRate ? sum.plus(new Decimal(line.lineAmountTaxIncluded).div(new Decimal(line.vatRate ?? 0).add(1)).mul(line.refundRate)) : sum, new Decimal(0));
732
+ return Decimal.max(expected.minus(order.refundedAmountCny), 0).toFixed(2);
733
+ }
734
+ };
735
+ TradeComplianceWorkbenchService = __decorate([
736
+ Injectable(),
737
+ __param(0, InjectDataSource()),
738
+ __metadata("design:paramtypes", [DataSource])
739
+ ], TradeComplianceWorkbenchService);
740
+ export { TradeComplianceWorkbenchService };
741
+ function activeScope(scope) { return { tenantId: scope.tenantId, organizationId: scope.organizationId }; }
742
+ function createdScope(scope) { return { ...activeScope(scope), createdById: scope.userId ?? null }; }
743
+ function auditScope(scope) { return { ...createdScope(scope), updatedById: scope.userId ?? null, deletedAt: null, deletedById: null, deleteOperation: null }; }
744
+ async function required(promise, message) { const value = await promise; if (!value)
745
+ throw new DomainError(DomainErrorCodes.NOT_FOUND, message); return value; }
746
+ function pick(input, keys) { return Object.fromEntries(keys.filter(key => key in input).map(key => [key, input[key]])); }
747
+ 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) })); }
755
+ //# sourceMappingURL=workbench.service.js.map