@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.
- package/README.md +46 -1
- package/dist/assets/branding/yuneec-logo.png +0 -0
- package/dist/assets/templates/CI.xlsx +0 -0
- package/dist/assets/templates/CUSTOMS_DECLARATION.xlsx +0 -0
- package/dist/assets/templates/PI.xlsx +0 -0
- package/dist/assets/templates/PL.xlsx +0 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lib/adapters/file-storage.d.ts +4 -2
- package/dist/lib/adapters/file-storage.d.ts.map +1 -1
- package/dist/lib/adapters/file-storage.js +25 -10
- package/dist/lib/adapters/file-storage.js.map +1 -1
- package/dist/lib/adapters/hsbianma.provider.d.ts +1 -0
- package/dist/lib/adapters/hsbianma.provider.d.ts.map +1 -1
- package/dist/lib/adapters/hsbianma.provider.js +32 -3
- package/dist/lib/adapters/hsbianma.provider.js.map +1 -1
- package/dist/lib/adapters/translation.provider.d.ts +3 -0
- package/dist/lib/adapters/translation.provider.d.ts.map +1 -1
- package/dist/lib/adapters/translation.provider.js +15 -6
- package/dist/lib/adapters/translation.provider.js.map +1 -1
- package/dist/lib/constants.d.ts +3 -2
- package/dist/lib/constants.d.ts.map +1 -1
- package/dist/lib/constants.js +3 -2
- package/dist/lib/constants.js.map +1 -1
- package/dist/lib/demo-data.definitions.d.ts +28 -0
- package/dist/lib/demo-data.definitions.d.ts.map +1 -0
- package/dist/lib/demo-data.definitions.js +236 -0
- package/dist/lib/demo-data.definitions.js.map +1 -0
- package/dist/lib/demo-data.entity.d.ts +9 -0
- package/dist/lib/demo-data.entity.d.ts.map +1 -0
- package/dist/lib/demo-data.entity.js +24 -0
- package/dist/lib/demo-data.entity.js.map +1 -0
- package/dist/lib/demo-data.ids.d.ts +4 -0
- package/dist/lib/demo-data.ids.d.ts.map +1 -0
- package/dist/lib/demo-data.ids.js +19 -0
- package/dist/lib/demo-data.ids.js.map +1 -0
- package/dist/lib/demo-data.service.d.ts +26 -0
- package/dist/lib/demo-data.service.d.ts.map +1 -0
- package/dist/lib/demo-data.service.js +124 -0
- package/dist/lib/demo-data.service.js.map +1 -0
- package/dist/lib/domain/default-settings.d.ts +1 -0
- package/dist/lib/domain/default-settings.d.ts.map +1 -1
- package/dist/lib/domain/default-settings.js +1 -0
- package/dist/lib/domain/default-settings.js.map +1 -1
- package/dist/lib/domain/risk-notice.d.ts +59 -0
- package/dist/lib/domain/risk-notice.d.ts.map +1 -0
- package/dist/lib/domain/risk-notice.js +30 -0
- package/dist/lib/domain/risk-notice.js.map +1 -0
- package/dist/lib/entities/catalog.entity.d.ts.map +1 -1
- package/dist/lib/entities/catalog.entity.js +4 -2
- package/dist/lib/entities/catalog.entity.js.map +1 -1
- package/dist/lib/entities/customer.entity.d.ts +9 -2
- package/dist/lib/entities/customer.entity.d.ts.map +1 -1
- package/dist/lib/entities/customer.entity.js +29 -1
- package/dist/lib/entities/customer.entity.js.map +1 -1
- package/dist/lib/entities/index.d.ts +2 -2
- package/dist/lib/entities/index.d.ts.map +1 -1
- package/dist/lib/entities/index.js +2 -2
- package/dist/lib/entities/index.js.map +1 -1
- package/dist/lib/entities/procurement.entity.d.ts +6 -4
- package/dist/lib/entities/procurement.entity.d.ts.map +1 -1
- package/dist/lib/entities/procurement.entity.js +9 -1
- package/dist/lib/entities/procurement.entity.js.map +1 -1
- package/dist/lib/entities/settings.entity.d.ts +12 -2
- package/dist/lib/entities/settings.entity.d.ts.map +1 -1
- package/dist/lib/entities/settings.entity.js +40 -0
- package/dist/lib/entities/settings.entity.js.map +1 -1
- package/dist/lib/entities/shared.entity.d.ts +10 -5
- package/dist/lib/entities/shared.entity.d.ts.map +1 -1
- package/dist/lib/entities/shared.entity.js +22 -1
- package/dist/lib/entities/shared.entity.js.map +1 -1
- package/dist/lib/remote-components/trade-compliance-workbench/app.css +1 -0
- package/dist/lib/remote-components/trade-compliance-workbench/app.js +63 -277
- package/dist/lib/remote-ui/analytics-contracts.d.ts +106 -0
- package/dist/lib/remote-ui/analytics-contracts.d.ts.map +1 -0
- package/dist/lib/remote-ui/analytics-contracts.js +4 -0
- package/dist/lib/remote-ui/analytics-contracts.js.map +1 -0
- package/dist/lib/remote-ui/app-source.d.ts +2 -0
- package/dist/lib/remote-ui/app-source.d.ts.map +1 -0
- package/dist/lib/remote-ui/app-source.js +753 -0
- package/dist/lib/remote-ui/app-source.js.map +1 -0
- package/dist/lib/remote-ui/bridge.d.ts +36 -0
- package/dist/lib/remote-ui/bridge.d.ts.map +1 -0
- package/dist/lib/remote-ui/bridge.js +159 -0
- package/dist/lib/remote-ui/bridge.js.map +1 -0
- package/dist/lib/remote-ui/components/data-grid.d.ts +34 -0
- package/dist/lib/remote-ui/components/data-grid.d.ts.map +1 -0
- package/dist/lib/remote-ui/components/data-grid.js +59 -0
- package/dist/lib/remote-ui/components/data-grid.js.map +1 -0
- package/dist/lib/remote-ui/components/forms.d.ts +63 -0
- package/dist/lib/remote-ui/components/forms.d.ts.map +1 -0
- package/dist/lib/remote-ui/components/forms.js +117 -0
- package/dist/lib/remote-ui/components/forms.js.map +1 -0
- package/dist/lib/remote-ui/components/layout.d.ts +34 -0
- package/dist/lib/remote-ui/components/layout.d.ts.map +1 -0
- package/dist/lib/remote-ui/components/layout.js +89 -0
- package/dist/lib/remote-ui/components/layout.js.map +1 -0
- package/dist/lib/remote-ui/contract-import.d.ts +82 -0
- package/dist/lib/remote-ui/contract-import.d.ts.map +1 -0
- package/dist/lib/remote-ui/contract-import.js +102 -0
- package/dist/lib/remote-ui/contract-import.js.map +1 -0
- package/dist/lib/remote-ui/contracts.d.ts +257 -0
- package/dist/lib/remote-ui/contracts.d.ts.map +1 -0
- package/dist/lib/remote-ui/contracts.js +12 -0
- package/dist/lib/remote-ui/contracts.js.map +1 -0
- package/dist/lib/remote-ui/customer-contract-contracts.d.ts +227 -0
- package/dist/lib/remote-ui/customer-contract-contracts.d.ts.map +1 -0
- package/dist/lib/remote-ui/customer-contract-contracts.js +10 -0
- package/dist/lib/remote-ui/customer-contract-contracts.js.map +1 -0
- package/dist/lib/remote-ui/model.d.ts +31 -0
- package/dist/lib/remote-ui/model.d.ts.map +1 -0
- package/dist/lib/remote-ui/model.js +248 -0
- package/dist/lib/remote-ui/model.js.map +1 -0
- package/dist/lib/remote-ui/pages/analytics.d.ts +12 -0
- package/dist/lib/remote-ui/pages/analytics.d.ts.map +1 -0
- package/dist/lib/remote-ui/pages/analytics.js +123 -0
- package/dist/lib/remote-ui/pages/analytics.js.map +1 -0
- package/dist/lib/remote-ui/pages/catalogs.d.ts +225 -0
- package/dist/lib/remote-ui/pages/catalogs.d.ts.map +1 -0
- package/dist/lib/remote-ui/pages/catalogs.js +572 -0
- package/dist/lib/remote-ui/pages/catalogs.js.map +1 -0
- package/dist/lib/remote-ui/pages/contracts.d.ts +80 -0
- package/dist/lib/remote-ui/pages/contracts.d.ts.map +1 -0
- package/dist/lib/remote-ui/pages/contracts.js +396 -0
- package/dist/lib/remote-ui/pages/contracts.js.map +1 -0
- package/dist/lib/remote-ui/pages/files.d.ts +31 -0
- package/dist/lib/remote-ui/pages/files.d.ts.map +1 -0
- package/dist/lib/remote-ui/pages/files.js +64 -0
- package/dist/lib/remote-ui/pages/files.js.map +1 -0
- package/dist/lib/remote-ui/pages/home.d.ts +71 -0
- package/dist/lib/remote-ui/pages/home.d.ts.map +1 -0
- package/dist/lib/remote-ui/pages/home.js +136 -0
- package/dist/lib/remote-ui/pages/home.js.map +1 -0
- package/dist/lib/remote-ui/pages/procurement.d.ts +36 -0
- package/dist/lib/remote-ui/pages/procurement.d.ts.map +1 -0
- package/dist/lib/remote-ui/pages/procurement.js +370 -0
- package/dist/lib/remote-ui/pages/procurement.js.map +1 -0
- package/dist/lib/remote-ui/pages/sales.d.ts +12 -0
- package/dist/lib/remote-ui/pages/sales.d.ts.map +1 -0
- package/dist/lib/remote-ui/pages/sales.js +106 -0
- package/dist/lib/remote-ui/pages/sales.js.map +1 -0
- package/dist/lib/remote-ui/pages/settings.d.ts +34 -0
- package/dist/lib/remote-ui/pages/settings.d.ts.map +1 -0
- package/dist/lib/remote-ui/pages/settings.js +259 -0
- package/dist/lib/remote-ui/pages/settings.js.map +1 -0
- package/dist/lib/remote-ui/procurement-contracts.d.ts +177 -0
- package/dist/lib/remote-ui/procurement-contracts.d.ts.map +1 -0
- package/dist/lib/remote-ui/procurement-contracts.js +2 -0
- package/dist/lib/remote-ui/procurement-contracts.js.map +1 -0
- package/dist/lib/remote-ui/sales-contracts.d.ts +133 -0
- package/dist/lib/remote-ui/sales-contracts.d.ts.map +1 -0
- package/dist/lib/remote-ui/sales-contracts.js +2 -0
- package/dist/lib/remote-ui/sales-contracts.js.map +1 -0
- package/dist/lib/remote-ui/settings-contracts.d.ts +114 -0
- package/dist/lib/remote-ui/settings-contracts.d.ts.map +1 -0
- package/dist/lib/remote-ui/settings-contracts.js +6 -0
- package/dist/lib/remote-ui/settings-contracts.js.map +1 -0
- package/dist/lib/remote-ui/upload-policy.d.ts +18 -0
- package/dist/lib/remote-ui/upload-policy.d.ts.map +1 -0
- package/dist/lib/remote-ui/upload-policy.js +56 -0
- package/dist/lib/remote-ui/upload-policy.js.map +1 -0
- package/dist/lib/runtime-validation.d.ts +61 -0
- package/dist/lib/runtime-validation.d.ts.map +1 -0
- package/dist/lib/runtime-validation.js +243 -0
- package/dist/lib/runtime-validation.js.map +1 -0
- package/dist/lib/sales-file.service.d.ts +54 -6
- package/dist/lib/sales-file.service.d.ts.map +1 -1
- package/dist/lib/sales-file.service.js +278 -33
- package/dist/lib/sales-file.service.js.map +1 -1
- package/dist/lib/trade-compliance.config.d.ts +32 -0
- package/dist/lib/trade-compliance.config.d.ts.map +1 -1
- package/dist/lib/trade-compliance.config.js +16 -4
- package/dist/lib/trade-compliance.config.js.map +1 -1
- package/dist/lib/types.d.ts +253 -1
- package/dist/lib/types.d.ts.map +1 -1
- package/dist/lib/types.js +24 -0
- package/dist/lib/types.js.map +1 -1
- package/dist/lib/workbench-view.provider.d.ts +675 -38
- package/dist/lib/workbench-view.provider.d.ts.map +1 -1
- package/dist/lib/workbench-view.provider.js +411 -40
- package/dist/lib/workbench-view.provider.js.map +1 -1
- package/dist/lib/workbench.middleware.d.ts +70 -19
- package/dist/lib/workbench.middleware.d.ts.map +1 -1
- package/dist/lib/workbench.middleware.js +5 -1
- package/dist/lib/workbench.middleware.js.map +1 -1
- package/dist/lib/workbench.plugin.d.ts.map +1 -1
- package/dist/lib/workbench.plugin.js +6 -4
- package/dist/lib/workbench.plugin.js.map +1 -1
- package/dist/lib/workbench.service.d.ts +629 -48
- package/dist/lib/workbench.service.d.ts.map +1 -1
- package/dist/lib/workbench.service.js +1818 -300
- package/dist/lib/workbench.service.js.map +1 -1
- package/package.json +15 -4
- 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
|
|
7
|
-
import {
|
|
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
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
54
|
-
catalogType: query.parameters?.catalogType,
|
|
55
|
-
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
221
|
-
task.
|
|
222
|
-
task.
|
|
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
|
|
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.
|
|
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,
|
|
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
|
|
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 = {}) {
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
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.
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
301
|
-
const
|
|
302
|
-
const
|
|
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
|
|
317
|
-
const overallRiskLevel = maxRisk(
|
|
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 =
|
|
323
|
-
const clause = await clauseRepo.save(clauseRepo.create({ ...auditScope(scope), customerContractId: entity.id, clauseType: type, extractionStatus: item?.extractionStatus ??
|
|
324
|
-
const analysis =
|
|
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
|
|
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
|
|
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
|
|
350
|
-
|
|
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
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
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
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
const
|
|
387
|
-
const
|
|
388
|
-
const
|
|
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
|
|
391
|
-
const
|
|
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)
|
|
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
|
|
399
|
-
|
|
400
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
|
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
|
-
|
|
472
|
-
row
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
this.dataSource.getRepository(
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
647
|
-
|
|
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
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
const
|
|
670
|
-
const
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
682
|
-
assertReceivedAmount(total, manual.receivedAmountCny);
|
|
1745
|
+
assertReceivedAmount(derivedOrderAmount(lines, rate), manual.receivedAmountCny);
|
|
683
1746
|
}
|
|
684
1747
|
}
|
|
685
|
-
async
|
|
686
|
-
const
|
|
687
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
699
|
-
|
|
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
|
-
|
|
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
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|