@chenchaolong/plugin-trade-compliance-workbench 1.0.180 → 1.0.181
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 +2 -1
- package/dist/assets/templates/PL.xlsx +0 -0
- package/dist/lib/adapters/file-storage.d.ts +33 -3
- package/dist/lib/adapters/file-storage.d.ts.map +1 -1
- package/dist/lib/adapters/file-storage.js +176 -17
- package/dist/lib/adapters/file-storage.js.map +1 -1
- package/dist/lib/constants.d.ts +1 -0
- package/dist/lib/constants.d.ts.map +1 -1
- package/dist/lib/constants.js +1 -0
- package/dist/lib/constants.js.map +1 -1
- package/dist/lib/entities/index.d.ts +3 -1
- package/dist/lib/entities/index.d.ts.map +1 -1
- package/dist/lib/entities/index.js +4 -1
- package/dist/lib/entities/index.js.map +1 -1
- package/dist/lib/entities/settings.entity.d.ts +5 -0
- package/dist/lib/entities/settings.entity.d.ts.map +1 -1
- package/dist/lib/entities/settings.entity.js +4 -0
- package/dist/lib/entities/settings.entity.js.map +1 -1
- package/dist/lib/entities/supplier-packing.entity.d.ts +35 -0
- package/dist/lib/entities/supplier-packing.entity.d.ts.map +1 -0
- package/dist/lib/entities/supplier-packing.entity.js +110 -0
- package/dist/lib/entities/supplier-packing.entity.js.map +1 -0
- package/dist/lib/import-jobs/import-command.builder.d.ts +1 -0
- package/dist/lib/import-jobs/import-command.builder.d.ts.map +1 -1
- package/dist/lib/import-jobs/import-command.builder.js +9 -4
- package/dist/lib/import-jobs/import-command.builder.js.map +1 -1
- package/dist/lib/import-jobs/import-job.processor.d.ts.map +1 -1
- package/dist/lib/import-jobs/import-job.processor.js +2 -1
- package/dist/lib/import-jobs/import-job.processor.js.map +1 -1
- package/dist/lib/packing/supplier-packing.service.d.ts +75 -0
- package/dist/lib/packing/supplier-packing.service.d.ts.map +1 -0
- package/dist/lib/packing/supplier-packing.service.js +229 -0
- package/dist/lib/packing/supplier-packing.service.js.map +1 -0
- package/dist/lib/remote-components/trade-compliance-workbench/app.js +4 -4
- package/dist/lib/remote-ui/action-execution.d.ts.map +1 -1
- package/dist/lib/remote-ui/action-execution.js +4 -1
- package/dist/lib/remote-ui/action-execution.js.map +1 -1
- package/dist/lib/remote-ui/app-source.js +1 -1
- package/dist/lib/remote-ui/app-source.js.map +1 -1
- package/dist/lib/remote-ui/components/bulk-delete.d.ts +1 -1
- package/dist/lib/remote-ui/components/bulk-delete.d.ts.map +1 -1
- package/dist/lib/remote-ui/components/bulk-delete.js +1 -1
- package/dist/lib/remote-ui/components/bulk-delete.js.map +1 -1
- package/dist/lib/remote-ui/components/data-grid.d.ts +1 -1
- package/dist/lib/remote-ui/components/data-grid.d.ts.map +1 -1
- package/dist/lib/remote-ui/pages/files.d.ts +7 -3
- package/dist/lib/remote-ui/pages/files.d.ts.map +1 -1
- package/dist/lib/remote-ui/pages/files.js +49 -11
- package/dist/lib/remote-ui/pages/files.js.map +1 -1
- package/dist/lib/remote-ui/pages/sales.js +2 -2
- package/dist/lib/sales-file.service.d.ts +6 -1
- package/dist/lib/sales-file.service.d.ts.map +1 -1
- package/dist/lib/sales-file.service.js +207 -52
- package/dist/lib/sales-file.service.js.map +1 -1
- package/dist/lib/schema-maintenance.d.ts.map +1 -1
- package/dist/lib/schema-maintenance.js +1 -0
- package/dist/lib/schema-maintenance.js.map +1 -1
- package/dist/lib/types.d.ts +37 -5
- package/dist/lib/types.d.ts.map +1 -1
- package/dist/lib/types.js +7 -5
- package/dist/lib/types.js.map +1 -1
- package/dist/lib/workbench-action-contract.d.ts +1 -1
- package/dist/lib/workbench-action-contract.d.ts.map +1 -1
- package/dist/lib/workbench-action-contract.js +4 -1
- package/dist/lib/workbench-action-contract.js.map +1 -1
- package/dist/lib/workbench-view.provider.d.ts +11 -3
- package/dist/lib/workbench-view.provider.d.ts.map +1 -1
- package/dist/lib/workbench-view.provider.js +40 -7
- package/dist/lib/workbench-view.provider.js.map +1 -1
- package/dist/lib/workbench.middleware.d.ts +86 -1
- package/dist/lib/workbench.middleware.d.ts.map +1 -1
- package/dist/lib/workbench.middleware.js +12 -2
- 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 +2 -1
- package/dist/lib/workbench.plugin.js.map +1 -1
- package/dist/lib/workbench.service.d.ts +5 -0
- package/dist/lib/workbench.service.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
import ExcelJS from 'exceljs';
|
|
9
9
|
import * as yauzl from 'yauzl';
|
|
10
10
|
import { Decimal } from 'decimal.js';
|
|
11
|
-
import { BankAccountSettings, CompanySettings, Customer, CustomerContract, CustomerContractClause, GeneratedSalesFile, MonthlySafeExchangeRate, ProfitRuleSettings, PurchaseOrder, PurchaseOrderLine } from './entities/index.js';
|
|
11
|
+
import { BankAccountSettings, CompanySettings, Customer, CustomerContract, CustomerContractClause, GeneratedSalesFile, MonthlySafeExchangeRate, ProfitRuleSettings, PurchaseOrder, PurchaseOrderLine, SupplierPacking, SupplierPackingLine } from './entities/index.js';
|
|
12
12
|
import { TradeComplianceWorkbenchService } from './workbench.service.js';
|
|
13
13
|
import { TranslationProvider } from './adapters/translation.provider.js';
|
|
14
14
|
import { TradeComplianceFileStorage } from './adapters/file-storage.js';
|
|
@@ -38,18 +38,19 @@ let SalesFileService = class SalesFileService {
|
|
|
38
38
|
async preflight(scope, input) {
|
|
39
39
|
try {
|
|
40
40
|
const evaluated = await this.evaluate(scope, input);
|
|
41
|
-
|
|
41
|
+
const packing = await this.packingStatusForOrders(scope, evaluated.derived.purchaseOrderIds?.length ? evaluated.derived.purchaseOrderIds : [input.purchaseOrderId]);
|
|
42
|
+
return preflightResult(input, evaluated.compliance.complianceProfitRate.toString(), evaluated.exchangeCost?.toString() ?? null, evaluated.warnings, packing);
|
|
42
43
|
}
|
|
43
44
|
catch (error) {
|
|
44
45
|
if (!(error instanceof DomainError))
|
|
45
46
|
throw error;
|
|
46
47
|
const metrics = error instanceof SalesFileEvaluationError ? error : null;
|
|
47
|
-
return preflightResult(input, metrics?.complianceProfitRate ?? null, metrics?.exchangeCost ?? null, metrics?.warnings ?? [], error);
|
|
48
|
+
return preflightResult(input, metrics?.complianceProfitRate ?? null, metrics?.exchangeCost ?? null, metrics?.warnings ?? [], undefined, error);
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
async generate(scope, input) {
|
|
51
52
|
const evaluated = await this.evaluate(scope, input);
|
|
52
|
-
const { derived, customerContract, customer, purchaseOrder, purchaseLines, rule, compliance, exchangeCost, exchangeRate, bank, warnings } = evaluated;
|
|
53
|
+
const { derived, customerContract, customer, purchaseOrder, purchaseLines, rule, compliance, exchangeCost, exchangeRate, bank, warnings, orders, ordersKey } = evaluated;
|
|
53
54
|
const generationWarnings = [...warnings];
|
|
54
55
|
const company = await this.dataSource.getRepository(CompanySettings).findOne({ where: scoped(scope) });
|
|
55
56
|
const clauses = await this.dataSource.getRepository(CustomerContractClause).find({ where: { ...scoped(scope), customerContractId: customerContract.id, deletedAt: IsNull() } });
|
|
@@ -60,6 +61,12 @@ let SalesFileService = class SalesFileService {
|
|
|
60
61
|
const fileNumber = input.fileNumber?.trim() || derived.salesOrderNo;
|
|
61
62
|
const remarks = input.remarks ?? '';
|
|
62
63
|
const bankType = input.bankType ?? null;
|
|
64
|
+
const needsPacking = input.fileType === 'PL' || input.fileType === 'PI_CI_PL' || input.fileType === 'CUSTOMS_DECLARATION';
|
|
65
|
+
const purchaseIds = derived.purchaseOrderIds?.length ? derived.purchaseOrderIds : [purchaseOrder.id];
|
|
66
|
+
const packing = needsPacking ? await this.loadPackingData(scope, purchaseIds) : null;
|
|
67
|
+
if (packing && (input.fileType === 'PL' || input.fileType === 'PI_CI_PL'))
|
|
68
|
+
generationWarnings.push(...this.packingWarningsForPl(derived, packing));
|
|
69
|
+
const mergedOrderRefs = orders.length > 1 ? orders.map(order => ({ customerContractId: order.customerContractId, purchaseOrderId: order.purchaseOrderId, salesOrderNo: order.salesOrderNo })) : null;
|
|
63
70
|
const newStorageFileIds = [];
|
|
64
71
|
try {
|
|
65
72
|
const files = await this.dataSource.transaction(async (manager) => {
|
|
@@ -73,22 +80,22 @@ let SalesFileService = class SalesFileService {
|
|
|
73
80
|
throw new DomainError(DomainErrorCodes.NOT_FOUND, '采购订单不存在');
|
|
74
81
|
}
|
|
75
82
|
const candidates = input.fileType === 'PI_CI_PL' ? await repo.find({ where: { ...scoped(scope), customerContractId: customerContract.id, purchaseOrderId: purchaseOrder.id, fileType: In(requestedTypes), deletedAt: IsNull() }, order: { generatedAt: 'DESC' }, lock: { mode: 'pessimistic_write' } }) : [];
|
|
76
|
-
const existing = candidates.filter(item => generationRequestMatches(item, { fileNumber, templateVersion, remarks, bankType, lineIds: derived.lines.map(line => line.purchaseOrderLineId) }));
|
|
83
|
+
const existing = candidates.filter(item => generationRequestMatches(item, { fileNumber, templateVersion, remarks, bankType, lineIds: derived.lines.map(line => line.purchaseOrderLineId), packingFingerprint: packing?.fingerprint ?? '', ordersKey }));
|
|
77
84
|
const existingByType = new Map(existing.map(item => [item.fileType, item]));
|
|
78
85
|
const missingTypes = requestedTypes.filter(fileType => !existingByType.has(fileType));
|
|
79
86
|
const created = [];
|
|
80
87
|
for (const fileType of missingTypes) {
|
|
81
|
-
const request = { templateVersion, fileNumber, remarks, bankType };
|
|
82
|
-
const snapshot = { version: 'v1', generatedAt: generatedAt.toISOString(), fileType, request, customer: { ...customer }, customerContract: { ...customerContract }, purchaseOrder: { ...purchaseOrder }, purchaseLines: purchaseLines.map(item => ({ ...item })), derived, company: company ? { ...company } : null, bank: bank ? { ...bank } : null, clauses: clauses.map(item => ({ ...item })), translated, compliance: { profitCny: compliance.profitCny.toString(), complianceProfitRate: compliance.complianceProfitRate.toString() }, exchangeRate: exchangeRate ? { ...exchangeRate } : null, exchangeCost: exchangeCost?.toString() ?? null, rule: { ...rule } };
|
|
88
|
+
const request = { templateVersion, fileNumber, remarks, bankType, packingFingerprint: packing?.fingerprint ?? '', ordersKey };
|
|
89
|
+
const snapshot = { version: 'v1', generatedAt: generatedAt.toISOString(), fileType, request, orders, customer: { ...customer }, customerContract: { ...customerContract }, purchaseOrder: { ...purchaseOrder }, purchaseLines: purchaseLines.map(item => ({ ...item })), derived, company: company ? { ...company } : null, bank: bank ? { ...bank } : null, clauses: clauses.map(item => ({ ...item })), translated, packing: packing ? { fingerprint: packing.fingerprint, byPurchaseLine: packing.byPurchaseLine } : null, compliance: { profitCny: compliance.profitCny.toString(), complianceProfitRate: compliance.complianceProfitRate.toString() }, exchangeRate: exchangeRate ? { ...exchangeRate } : null, exchangeCost: exchangeCost?.toString() ?? null, rule: { ...rule } };
|
|
83
90
|
const logo = await this.readCompanyLogo(scope, fileType, company?.logoFileId);
|
|
84
|
-
const buffer = await this.fillWorkbook(fileType, { snapshot, customer, customerContract, purchaseOrder, purchaseLines, derived, company, bank, clauses, translated }, logo);
|
|
91
|
+
const buffer = await this.fillWorkbook(fileType, { snapshot, customer, customerContract, purchaseOrder, purchaseLines, derived, company, bank, clauses, translated, packing }, logo);
|
|
85
92
|
await validateWorkbook(buffer);
|
|
86
|
-
const documentNo = fileType === 'PI' || fileType === 'CI' ? salesDocumentNo(derived.salesOrderNo, fileType,
|
|
93
|
+
const documentNo = fileType === 'PI' || fileType === 'CI' ? salesDocumentNo(derived.salesOrderNo, fileType, customer.name) : null;
|
|
87
94
|
const fileName = `${fileNumber}-${salesFileNameTypeLabels[fileType]}-${beijingFileTimestamp(generatedAt)}.xlsx`;
|
|
88
95
|
const checksum = createHash('sha256').update(await templateBuffer(fileType)).digest('hex');
|
|
89
96
|
const storageFileId = await this.storage.save(buffer, fileName, 'generated');
|
|
90
97
|
newStorageFileIds.push(storageFileId);
|
|
91
|
-
created.push(repo.create({ tenantId: scope.tenantId, organizationId: scope.organizationId, createdById: scope.userId ?? null, updatedById: scope.userId ?? null, deletedAt: null, deletedById: null, deleteOperation: null, customerContractId: customerContract.id, purchaseOrderId: purchaseOrder.id, salesOrderNoSnapshot: derived.salesOrderNo, fileType, documentNo, fileName, storageFileId, templateVersion, templateChecksum: checksum, generationSnapshotVersion: 'v1', generationSnapshot: snapshot, generationWarnings, pricingProfitRate: derived.effectivePricingProfitRate, complianceProfitRate: compliance.complianceProfitRate.toString(), exchangeCost: exchangeCost?.toString() ?? null, profitRuleSnapshot: { ...rule }, bankType: bank?.bankType ?? null, bankSnapshot: bank ? { ...bank } : null, generatedById: scope.userId ?? null, generatedAt, lastEditedById: null, lastEditedAt: null }));
|
|
98
|
+
created.push(repo.create({ tenantId: scope.tenantId, organizationId: scope.organizationId, createdById: scope.userId ?? null, updatedById: scope.userId ?? null, deletedAt: null, deletedById: null, deleteOperation: null, customerContractId: customerContract.id, purchaseOrderId: purchaseOrder.id, salesOrderNoSnapshot: derived.salesOrderNo, fileType, documentNo, fileName, storageFileId, templateVersion, templateChecksum: checksum, generationSnapshotVersion: 'v1', generationSnapshot: snapshot, mergedOrderRefs, generationWarnings, pricingProfitRate: derived.effectivePricingProfitRate, complianceProfitRate: compliance.complianceProfitRate.toString(), exchangeCost: exchangeCost?.toString() ?? null, profitRuleSnapshot: { ...rule }, bankType: bank?.bankType ?? null, bankSnapshot: bank ? { ...bank } : null, generatedById: scope.userId ?? null, generatedAt, lastEditedById: null, lastEditedAt: null }));
|
|
92
99
|
}
|
|
93
100
|
const saved = created.length ? await repo.save(created) : [];
|
|
94
101
|
const savedByType = new Map(saved.map(item => [item.fileType, item]));
|
|
@@ -102,13 +109,39 @@ let SalesFileService = class SalesFileService {
|
|
|
102
109
|
}
|
|
103
110
|
}
|
|
104
111
|
async evaluate(scope, input) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
+
// 合并生成:orders 指定同客户的多个销售订单(合同+采购单);缺省退化为单订单生成
|
|
113
|
+
const orderRefs = normalizeGenerationOrders(input);
|
|
114
|
+
const allDerived = await this.workbench.listDerivedSalesOrders(scope);
|
|
115
|
+
const orderEntries = orderRefs.map(ref => {
|
|
116
|
+
const derived = allDerived.find(item => item.customerContractId === ref.customerContractId && item.purchaseOrderId === ref.purchaseOrderId);
|
|
117
|
+
if (!derived)
|
|
118
|
+
throw new DomainError(DomainErrorCodes.NOT_FOUND, '未找到已关联且具备定价利润率的销售订单');
|
|
119
|
+
if (!derived.lines.length)
|
|
120
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '销售订单至少需要一条商品明细');
|
|
121
|
+
return { ref, derived };
|
|
122
|
+
}).sort((a, b) => a.derived.salesOrderNo.localeCompare(b.derived.salesOrderNo));
|
|
123
|
+
const primary = orderEntries[0];
|
|
124
|
+
const contracts = await Promise.all(orderEntries.map(entry => this.required(CustomerContract, scope, entry.ref.customerContractId, '客户合同不存在')));
|
|
125
|
+
const customerContract = contracts[0];
|
|
126
|
+
const customers = await Promise.all(orderEntries.map(entry => this.required(Customer, scope, entry.derived.customerId, '客户不存在')));
|
|
127
|
+
const customer = customers[0];
|
|
128
|
+
if (customers.some(item => item.id !== customer.id))
|
|
129
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '合并生成的销售订单必须属于同一客户');
|
|
130
|
+
// 合并生成要求各订单付款条款一致(PI/CI 上只有一处付款条款栏,错不得)
|
|
131
|
+
if (orderEntries.length > 1) {
|
|
132
|
+
const clauseRows = await this.dataSource.getRepository(CustomerContractClause).find({ where: { ...scoped(scope), customerContractId: In(orderEntries.map(entry => entry.ref.customerContractId)), deletedAt: IsNull() } });
|
|
133
|
+
const termsByContract = new Map();
|
|
134
|
+
for (const clause of clauseRows) {
|
|
135
|
+
if (clause.clauseType !== 'PAYMENT_TERMS')
|
|
136
|
+
continue;
|
|
137
|
+
const text = (clause.reviewedText || clause.sourceText || '').replace(/\s+/g, '').trim();
|
|
138
|
+
if (text && !termsByContract.has(clause.customerContractId))
|
|
139
|
+
termsByContract.set(clause.customerContractId, text);
|
|
140
|
+
}
|
|
141
|
+
const terms = new Set(orderEntries.map(entry => entry.ref.customerContractId).map(id => termsByContract.get(id) ?? ''));
|
|
142
|
+
if (terms.size > 1)
|
|
143
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '合并生成的销售订单付款条款不一致,请先统一各合同付款条款', { contracts: orderEntries.map(entry => ({ customerContractId: entry.ref.customerContractId, salesOrderNo: entry.derived.salesOrderNo, paymentTerms: termsByContract.get(entry.ref.customerContractId) ?? '' })) });
|
|
144
|
+
}
|
|
112
145
|
// 信用额度硬拦截:客户未收款总额超出信用额度(剩余额度为负)时,禁止生成任何销售文件。
|
|
113
146
|
if (customer.creditLimit != null) {
|
|
114
147
|
const unreceivedByCustomer = await this.workbench.customerUnreceivedAmounts(scope, [customer.id]);
|
|
@@ -118,11 +151,12 @@ let SalesFileService = class SalesFileService {
|
|
|
118
151
|
throw new DomainError(DomainErrorCodes.CREDIT_LIMIT_EXCEEDED, `客户「${customer.name}」已超出信用额度,无法生成销售文件:信用额度 ${new Decimal(customer.creditLimit).toFixed(2)} CNY,未收款总额 ${unreceivedAmountCny} CNY,剩余 ${remainingCreditCny.toFixed(2)} CNY。请先回收货款或上调信用额度`, { customerId: customer.id, customerName: customer.name, creditLimit: customer.creditLimit, unreceivedAmountCny, remainingCreditCny: remainingCreditCny.toFixed(2) });
|
|
119
152
|
}
|
|
120
153
|
}
|
|
121
|
-
const
|
|
122
|
-
const
|
|
154
|
+
const purchaseOrderEntities = await Promise.all([...new Set(orderEntries.map(entry => entry.ref.purchaseOrderId))].map(id => this.required(PurchaseOrder, scope, id, '采购订单不存在')));
|
|
155
|
+
const purchaseOrder = purchaseOrderEntities.find(item => item.id === primary.ref.purchaseOrderId);
|
|
156
|
+
const purchaseIds = [...new Set(orderEntries.flatMap(entry => entry.derived.purchaseOrderIds?.length ? entry.derived.purchaseOrderIds : [entry.ref.purchaseOrderId]))];
|
|
123
157
|
const sourcePurchaseLines = await this.dataSource.getRepository(PurchaseOrderLine).find({ where: { tenantId: scope.tenantId, organizationId: scope.organizationId, purchaseOrderId: In(purchaseIds), deletedAt: IsNull() }, order: { purchaseOrderId: 'ASC', lineNumber: 'ASC' } });
|
|
124
158
|
const purchaseLinesById = new Map(sourcePurchaseLines.map(line => [line.id, line]));
|
|
125
|
-
const lineIds = derived.lines.map(line => line.purchaseOrderLineId);
|
|
159
|
+
const lineIds = orderEntries.flatMap(entry => entry.derived.lines.map(line => line.purchaseOrderLineId));
|
|
126
160
|
if (sourcePurchaseLines.length !== lineIds.length || purchaseLinesById.size !== sourcePurchaseLines.length || new Set(lineIds).size !== lineIds.length || lineIds.some(id => !purchaseLinesById.has(id))) {
|
|
127
161
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '销售商品与采购商品明细不一致,请刷新后重试');
|
|
128
162
|
}
|
|
@@ -142,7 +176,7 @@ let SalesFileService = class SalesFileService {
|
|
|
142
176
|
const labels = missingFields.map(field => field === 'vatRate' ? '增值税率' : '退税率');
|
|
143
177
|
throw new DomainError(DomainErrorCodes.SALES_LINE_TAX_DATA_REQUIRED, `销售商品缺少${labels.join('、')}`, { missingFields, lines: missingTaxLines.map(({ missingFields: _, ...line }) => line) });
|
|
144
178
|
}
|
|
145
|
-
const derivedLines = new Map(derived.lines.map(line => [line.purchaseOrderLineId, line]));
|
|
179
|
+
const derivedLines = new Map(orderEntries.flatMap(entry => entry.derived.lines).map(line => [line.purchaseOrderLineId, line]));
|
|
146
180
|
const unpricedLines = purchaseLines.map(line => {
|
|
147
181
|
const derivedLine = derivedLines.get(line.id);
|
|
148
182
|
const amount = derivedLine?.salesLineAmountCny;
|
|
@@ -164,8 +198,8 @@ let SalesFileService = class SalesFileService {
|
|
|
164
198
|
let exchangeCost = null;
|
|
165
199
|
let exchangeRate = null;
|
|
166
200
|
if (refundable.length) {
|
|
167
|
-
//
|
|
168
|
-
const orderDate = derived.orderDate ??
|
|
201
|
+
// 合并生成/多采购单聚合:换汇月份按批次内最早下单日期确定。
|
|
202
|
+
const orderDate = orderEntries.map(entry => entry.derived.orderDate ?? purchaseOrderDates.get(entry.ref.purchaseOrderId) ?? null).filter(Boolean).sort()[0] ?? null;
|
|
169
203
|
if (!orderDate)
|
|
170
204
|
throw new SalesFileEvaluationError(DomainErrorCodes.EXCHANGE_RATE_REQUIRED, '采购订单下单日期为空,无法确定月度美元汇率', {}, compliance.complianceProfitRate.toString(), null, warnings);
|
|
171
205
|
const date = new Date(`${orderDate}T00:00:00Z`);
|
|
@@ -197,7 +231,24 @@ let SalesFileService = class SalesFileService {
|
|
|
197
231
|
if (missingFields.length)
|
|
198
232
|
throw new SalesFileEvaluationError(DomainErrorCodes.BANK_ACCOUNT_REQUIRED, '所选银行信息配置不完整', { bankType: bank.bankType, missingFields }, compliance.complianceProfitRate.toString(), exchangeCost?.toString() ?? null, warnings);
|
|
199
233
|
}
|
|
200
|
-
|
|
234
|
+
const purchaseOrderDates = new Map(purchaseOrderEntities.map(item => [item.id, item.orderDate]));
|
|
235
|
+
const earliestOrderDate = orderEntries.map(entry => entry.derived.orderDate ?? purchaseOrderDates.get(entry.ref.purchaseOrderId) ?? null).filter(Boolean).sort()[0] ?? null;
|
|
236
|
+
const orders = orderEntries.map(entry => ({
|
|
237
|
+
customerContractId: entry.ref.customerContractId,
|
|
238
|
+
purchaseOrderId: entry.ref.purchaseOrderId,
|
|
239
|
+
salesOrderNo: entry.derived.salesOrderNo,
|
|
240
|
+
contractNo: contracts.find(contract => contract.id === entry.ref.customerContractId)?.contractNo ?? entry.derived.customerContractNo,
|
|
241
|
+
orderAmountCny: entry.derived.orderAmountCny
|
|
242
|
+
}));
|
|
243
|
+
const ordersKey = orderEntries.length > 1 ? JSON.stringify(orders.map(order => [order.customerContractId, order.purchaseOrderId])) : '';
|
|
244
|
+
const derived = {
|
|
245
|
+
...primary.derived,
|
|
246
|
+
purchaseOrderIds: purchaseIds,
|
|
247
|
+
orderDate: earliestOrderDate,
|
|
248
|
+
orderAmountCny: orders.reduce((sum, order) => sum + Number(order.orderAmountCny ?? 0), 0).toFixed(0),
|
|
249
|
+
lines: orderEntries.flatMap(entry => entry.derived.lines)
|
|
250
|
+
};
|
|
251
|
+
return { derived, customerContract, customer, purchaseOrder, purchaseLines, rule, compliance, exchangeCost, exchangeRate, bank, warnings, orders, ordersKey };
|
|
201
252
|
}
|
|
202
253
|
async download(scope, id) {
|
|
203
254
|
const item = await this.required(GeneratedSalesFile, scope, id, '历史销售文件不存在');
|
|
@@ -248,7 +299,8 @@ let SalesFileService = class SalesFileService {
|
|
|
248
299
|
return result ?? '';
|
|
249
300
|
};
|
|
250
301
|
return {
|
|
251
|
-
|
|
302
|
+
// 海外公司名称原样写入,不做 AI 翻译(翻译结果不可控且客户名常已是外文)
|
|
303
|
+
customerName: customer.name, customerAddress: await translate('customer.address', customer.address),
|
|
252
304
|
customerContact: await translate('customer.contactName', customer.contactName), customerCountry: await translate('customer.country', customer.country),
|
|
253
305
|
paymentTerms: await translate('clause.PAYMENT_TERMS', clauseText(clauses, 'PAYMENT_TERMS')),
|
|
254
306
|
lines: await Promise.all(lines.map(async (line) => ({ productName: line.englishName || await translate(`line.${line.id}.productName`, [line.productName, line.modelSpecification].filter(Boolean).join(' ')), description: await translate(`line.${line.id}.description`, line.productDescription), model: await translate(`line.${line.id}.model`, line.modelSpecification), unit: await translate(`line.${line.id}.unit`, line.unit) })))
|
|
@@ -268,6 +320,57 @@ let SalesFileService = class SalesFileService {
|
|
|
268
320
|
throw new DomainError(DomainErrorCodes.NOT_FOUND, message);
|
|
269
321
|
return item;
|
|
270
322
|
}
|
|
323
|
+
/** 加载订单涉及采购合同的装箱数据并按采购行聚合;无装箱数据返回 null(相关列保持模板默认值)。 */
|
|
324
|
+
async loadPackingData(scope, purchaseIds) {
|
|
325
|
+
const packings = await this.dataSource.getRepository(SupplierPacking).find({ where: { ...scoped(scope), purchaseOrderId: In(purchaseIds), deletedAt: IsNull() } });
|
|
326
|
+
if (!packings.length)
|
|
327
|
+
return null;
|
|
328
|
+
const lines = await this.dataSource.getRepository(SupplierPackingLine).find({ where: { ...scoped(scope), packingId: In(packings.map(item => item.id)), deletedAt: IsNull() }, order: { lineIndex: 'ASC' } });
|
|
329
|
+
if (!lines.length)
|
|
330
|
+
return null;
|
|
331
|
+
const byPurchaseLine = {};
|
|
332
|
+
let totalCartons = 0;
|
|
333
|
+
let totalGrossKg = 0;
|
|
334
|
+
let totalVolumeCbm = 0;
|
|
335
|
+
for (const line of lines) {
|
|
336
|
+
const cartons = Number(line.cartons ?? 0);
|
|
337
|
+
totalCartons += cartons;
|
|
338
|
+
totalGrossKg += Number(line.totalGrossWeightKg ?? 0);
|
|
339
|
+
totalVolumeCbm += Number(line.totalVolumeCbm ?? 0);
|
|
340
|
+
if (!line.purchaseOrderLineId)
|
|
341
|
+
continue;
|
|
342
|
+
const aggregate = byPurchaseLine[line.purchaseOrderLineId] ??= { cartons: 0, dimensions: [], grossKg: 0, weightPerCtn: null, boxVolumeCbm: null, totalVolumeCbm: 0 };
|
|
343
|
+
aggregate.cartons += cartons;
|
|
344
|
+
if (line.dimensionCm && !aggregate.dimensions.includes(line.dimensionCm))
|
|
345
|
+
aggregate.dimensions.push(line.dimensionCm);
|
|
346
|
+
aggregate.grossKg += Number(line.totalGrossWeightKg ?? 0);
|
|
347
|
+
aggregate.weightPerCtn ??= Number(line.grossWeightPerCtnKg ?? 0) || null;
|
|
348
|
+
aggregate.boxVolumeCbm ??= Number(line.boxVolumeCbm ?? 0) || null;
|
|
349
|
+
aggregate.totalVolumeCbm += Number(line.totalVolumeCbm ?? 0);
|
|
350
|
+
}
|
|
351
|
+
const fingerprint = `v${Math.max(...packings.map(item => item.revision))}n${lines.length}b${Object.keys(byPurchaseLine).length}`;
|
|
352
|
+
return { fingerprint, byPurchaseLine, totals: { cartons: totalCartons, grossKg: Math.round(totalGrossKg * 100) / 100, volumeCbm: Math.round(totalVolumeCbm * 1_000_000) / 1_000_000 } };
|
|
353
|
+
}
|
|
354
|
+
/** 预检用装箱状态:按采购合同列出装箱数据版本、行数与绑定行数(未导入的合同 revision=0)。 */
|
|
355
|
+
async packingStatusForOrders(scope, purchaseIds) {
|
|
356
|
+
const purchaseOrders = purchaseIds.length ? await this.dataSource.getRepository(PurchaseOrder).find({ where: { ...scoped(scope), id: In(purchaseIds), deletedAt: IsNull() } }) : [];
|
|
357
|
+
const packings = purchaseIds.length ? await this.dataSource.getRepository(SupplierPacking).find({ where: { ...scoped(scope), purchaseOrderId: In(purchaseIds), deletedAt: IsNull() } }) : [];
|
|
358
|
+
const lines = packings.length ? await this.dataSource.getRepository(SupplierPackingLine).find({ where: { ...scoped(scope), packingId: In(packings.map(item => item.id)), deletedAt: IsNull() } }) : [];
|
|
359
|
+
const status = purchaseOrders.map(order => {
|
|
360
|
+
const packing = packings.find(item => item.purchaseOrderId === order.id);
|
|
361
|
+
const boundRowCount = lines.filter(line => line.packingId === packing?.id && Boolean(line.purchaseOrderLineId)).length;
|
|
362
|
+
return { purchaseOrderId: order.id, contractNo: order.contractNo, revision: packing?.revision ?? 0, rowCount: lines.filter(line => line.packingId === packing?.id).length, boundRowCount };
|
|
363
|
+
});
|
|
364
|
+
return status.sort((a, b) => a.revision - b.revision || a.contractNo.localeCompare(b.contractNo));
|
|
365
|
+
}
|
|
366
|
+
/** PL 专属告警:装箱数据存在时的行缺失与净重提示。 */
|
|
367
|
+
packingWarningsForPl(derived, packing) {
|
|
368
|
+
const warnings = derived.lines
|
|
369
|
+
.filter(line => !packing.byPurchaseLine[line.purchaseOrderLineId])
|
|
370
|
+
.map(line => ({ code: 'PACKING_LINE_MISSING', purchaseOrderLineId: line.purchaseOrderLineId, productName: line.productName, message: `商品“${line.productName}”未匹配到装箱数据,尺寸/重量相关列留空` }));
|
|
371
|
+
warnings.push({ code: 'PACKING_NET_WEIGHT_MISSING', message: '供应商装箱数据不含净重,单件净重/总净重列留空,Total Net weight 合计为空' });
|
|
372
|
+
return warnings;
|
|
373
|
+
}
|
|
271
374
|
};
|
|
272
375
|
SalesFileService = __decorate([
|
|
273
376
|
Injectable(),
|
|
@@ -325,12 +428,32 @@ export function negativeProfitWarnings(complianceLines, derivedLines) {
|
|
|
325
428
|
}
|
|
326
429
|
function generationFileTypes(fileType) { return fileType === 'PI_CI_PL' ? ['PI', 'CI', 'PL'] : [fileType]; }
|
|
327
430
|
function generationResult(item) { return { ...item, translationFailures: (item.generationWarnings ?? []).filter(isTranslationFailure) }; }
|
|
431
|
+
function normalizeGenerationOrders(input) {
|
|
432
|
+
const refs = input.orders?.length ? input.orders : [{ customerContractId: input.customerContractId, purchaseOrderId: input.purchaseOrderId }];
|
|
433
|
+
const seen = new Set();
|
|
434
|
+
const result = [];
|
|
435
|
+
for (const ref of refs) {
|
|
436
|
+
const customerContractId = String(ref.customerContractId ?? '').trim();
|
|
437
|
+
const purchaseOrderId = String(ref.purchaseOrderId ?? '').trim();
|
|
438
|
+
if (!customerContractId || !purchaseOrderId)
|
|
439
|
+
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '合并生成的销售订单缺少客户合同或采购单标识');
|
|
440
|
+
const key = `${customerContractId}:${purchaseOrderId}`;
|
|
441
|
+
if (seen.has(key))
|
|
442
|
+
continue;
|
|
443
|
+
seen.add(key);
|
|
444
|
+
result.push({ customerContractId, purchaseOrderId });
|
|
445
|
+
}
|
|
446
|
+
return result;
|
|
447
|
+
}
|
|
328
448
|
function generationRequestMatches(item, expected) {
|
|
329
449
|
const request = item.generationSnapshot?.request;
|
|
330
450
|
const snapshotDerived = item.generationSnapshot?.derived;
|
|
331
451
|
const snapshotLines = snapshotDerived?.lines;
|
|
332
452
|
const lineOrderMatches = Array.isArray(snapshotLines) && snapshotLines.length === expected.lineIds.length && snapshotLines.every((line, index) => line?.purchaseOrderLineId === expected.lineIds[index]);
|
|
333
453
|
const bankType = request && Object.prototype.hasOwnProperty.call(request, 'bankType') ? request.bankType : item.bankType ?? null;
|
|
454
|
+
// 装箱数据只影响 PL 与报关单:指纹变化仅使这两类失效重生成,PI/CI 不受供应商装箱单重导入影响
|
|
455
|
+
const packingMatches = item.fileType !== 'PL' && item.fileType !== 'CUSTOMS_DECLARATION' || (request?.packingFingerprint ?? '') === expected.packingFingerprint;
|
|
456
|
+
const ordersKeyMatches = (request?.ordersKey ?? '') === expected.ordersKey;
|
|
334
457
|
return Boolean(request &&
|
|
335
458
|
typeof request === 'object' &&
|
|
336
459
|
!Array.isArray(request) &&
|
|
@@ -338,7 +461,9 @@ function generationRequestMatches(item, expected) {
|
|
|
338
461
|
request.templateVersion === expected.templateVersion &&
|
|
339
462
|
request.remarks === expected.remarks &&
|
|
340
463
|
bankType === expected.bankType &&
|
|
341
|
-
lineOrderMatches
|
|
464
|
+
lineOrderMatches &&
|
|
465
|
+
packingMatches &&
|
|
466
|
+
ordersKeyMatches);
|
|
342
467
|
}
|
|
343
468
|
class SalesFileEvaluationError extends DomainError {
|
|
344
469
|
constructor(code, message, details, complianceProfitRate, exchangeCost, warnings) {
|
|
@@ -348,7 +473,7 @@ class SalesFileEvaluationError extends DomainError {
|
|
|
348
473
|
this.warnings = warnings;
|
|
349
474
|
}
|
|
350
475
|
}
|
|
351
|
-
function preflightResult(input, complianceProfitRate, exchangeCost, warnings, error) {
|
|
476
|
+
function preflightResult(input, complianceProfitRate, exchangeCost, warnings, packing, error) {
|
|
352
477
|
return {
|
|
353
478
|
allowed: !error,
|
|
354
479
|
fileType: input.fileType,
|
|
@@ -357,7 +482,8 @@ function preflightResult(input, complianceProfitRate, exchangeCost, warnings, er
|
|
|
357
482
|
complianceProfitRate,
|
|
358
483
|
exchangeCost,
|
|
359
484
|
warnings,
|
|
360
|
-
blockingReasons: error ? [{ code: error.code, message: error.message, details: error.details }] : []
|
|
485
|
+
blockingReasons: error ? [{ code: error.code, message: error.message, details: error.details }] : [],
|
|
486
|
+
packing
|
|
361
487
|
};
|
|
362
488
|
}
|
|
363
489
|
function beijingFileTimestamp(value) {
|
|
@@ -372,13 +498,15 @@ function fillInvoiceSheet(sheet, type, data) {
|
|
|
372
498
|
set(sheet, 'B4', translated.customerName);
|
|
373
499
|
set(sheet, 'F4', company?.companyNameEn, true);
|
|
374
500
|
set(sheet, 'B5', translated.customerAddress);
|
|
375
|
-
set(sheet, 'F5', salesDocumentNo(derived.salesOrderNo, type,
|
|
501
|
+
set(sheet, 'F5', salesDocumentNo(derived.salesOrderNo, type, customer.name));
|
|
376
502
|
set(sheet, 'B6', translated.customerContact);
|
|
377
503
|
set(sheet, 'F6', dateValue(derived.orderDate ?? purchaseOrder.orderDate));
|
|
378
504
|
set(sheet, 'B7', customer.phone ?? '');
|
|
379
505
|
set(sheet, 'F7', customerContract.contractNo);
|
|
380
506
|
set(sheet, 'F8', translated.paymentTerms, true);
|
|
381
507
|
set(sheet, 'E9', type === 'PI' ? 'Remark: The quotation is valid for one week.' : '');
|
|
508
|
+
// "To:" 目的国:写客户国别;为空时覆盖模板默认值(MOSCOW),留空即可
|
|
509
|
+
set(sheet, 'B9', translated.customerCountry);
|
|
382
510
|
set(sheet, 'G11', 'CNY to RMB');
|
|
383
511
|
for (let index = 0; index < derived.lines.length; index += 1) {
|
|
384
512
|
const row = 12 + index;
|
|
@@ -514,37 +642,59 @@ function imageDimensions(buffer) {
|
|
|
514
642
|
throw new DomainError(DomainErrorCodes.INVALID_INPUT, '公司 Logo 图片尺寸无法识别');
|
|
515
643
|
}
|
|
516
644
|
function fillPackingSheet(sheet, data) {
|
|
517
|
-
const { customer, purchaseOrder, derived, company,
|
|
518
|
-
resizeSingleRowSection(sheet, 12,
|
|
645
|
+
const { customer, purchaseOrder, derived, company, translated, packing } = data;
|
|
646
|
+
resizeSingleRowSection(sheet, 12, 20, derived.lines.length, 12);
|
|
519
647
|
set(sheet, 'B1', company?.addressEn, true);
|
|
520
648
|
set(sheet, 'A2', company?.phone || company?.email ? `Tel:${company?.phone ?? ''} Email:${company?.email ?? ''}` : undefined, true);
|
|
521
649
|
set(sheet, 'A3', 'PACKING LIST');
|
|
650
|
+
// 收货信息(左)与卖家信息(右):标签为模板静态内容,值由生成数据填充
|
|
522
651
|
set(sheet, 'B4', translated.customerName);
|
|
523
|
-
set(sheet, 'F4', company?.companyNameEn, true);
|
|
524
652
|
set(sheet, 'B5', translated.customerAddress);
|
|
653
|
+
set(sheet, 'B6', '');
|
|
654
|
+
set(sheet, 'B7', translated.customerCountry);
|
|
655
|
+
set(sheet, 'B8', translated.customerContact);
|
|
656
|
+
set(sheet, 'B9', customer.phone ?? '');
|
|
657
|
+
set(sheet, 'F4', company?.companyNameEn, true);
|
|
525
658
|
set(sheet, 'F5', dateValue(derived.orderDate ?? purchaseOrder.orderDate));
|
|
526
|
-
set(sheet, '
|
|
527
|
-
set(sheet, '
|
|
528
|
-
set(sheet, '
|
|
529
|
-
set(sheet, 'B8', customer.phone ?? '');
|
|
530
|
-
set(sheet, 'F8', company?.phone, true);
|
|
659
|
+
set(sheet, 'F6', translated.customerContact);
|
|
660
|
+
set(sheet, 'F7', company?.email ?? '');
|
|
661
|
+
set(sheet, 'F8', company?.phone ?? '');
|
|
531
662
|
for (let index = 0; index < derived.lines.length; index += 1) {
|
|
532
663
|
const row = 12 + index;
|
|
533
|
-
const
|
|
534
|
-
|
|
664
|
+
const linePacking = packing?.byPurchaseLine?.[derived.lines[index].purchaseOrderLineId];
|
|
665
|
+
let packingValues = null;
|
|
666
|
+
if (linePacking) {
|
|
667
|
+
// 列序(E 起):尺寸(cm)/单箱毛重/箱数/单箱体积/总体积/单件净重(缺)/总净重(缺)/总毛重
|
|
668
|
+
packingValues = [
|
|
669
|
+
linePacking.dimensions.join(' / '),
|
|
670
|
+
linePacking.weightPerCtn ?? '',
|
|
671
|
+
linePacking.cartons > 0 ? linePacking.cartons : '',
|
|
672
|
+
linePacking.boxVolumeCbm ?? '',
|
|
673
|
+
linePacking.totalVolumeCbm ?? '',
|
|
674
|
+
'',
|
|
675
|
+
'',
|
|
676
|
+
linePacking.grossKg ? Math.round(linePacking.grossKg * 100) / 100 : ''
|
|
677
|
+
];
|
|
678
|
+
}
|
|
679
|
+
else if (packing) {
|
|
680
|
+
// 订单已有装箱数据但该行未匹配到:清空(该行已触发 PACKING_LINE_MISSING 告警)
|
|
681
|
+
packingValues = [null, null, null, null, null, null, null, null];
|
|
682
|
+
}
|
|
683
|
+
const values = [
|
|
684
|
+
translated.lines[index]?.productName ?? '',
|
|
685
|
+
translated.lines[index]?.model ?? '',
|
|
686
|
+
null,
|
|
687
|
+
Number(derived.lines[index].quantity),
|
|
688
|
+
...(packingValues ?? [null, null, null, null, null, null, null, null])
|
|
689
|
+
];
|
|
690
|
+
values.forEach((value, offset) => { sheet.getCell(row, offset + 1).value = value; });
|
|
535
691
|
}
|
|
536
|
-
|
|
537
|
-
const
|
|
538
|
-
set(sheet, `
|
|
539
|
-
set(sheet, `
|
|
540
|
-
set(sheet, `
|
|
541
|
-
set(sheet, `
|
|
542
|
-
set(sheet, `B${bankStart + 3}`, bank.accountNo ?? '');
|
|
543
|
-
set(sheet, `B${bankStart + 4}`, bank.swiftCode ?? '');
|
|
544
|
-
set(sheet, `B${bankStart + 5}`, bank.cnapsCode ?? '');
|
|
545
|
-
sheet.getCell(`B${bankStart + 3}`).numFmt = '@';
|
|
546
|
-
sheet.getCell(`B${bankStart + 5}`).numFmt = '@';
|
|
547
|
-
fillIntermediaryChannelBankRows(sheet, { labelEnd: 'A', valueStart: 'B', valueEnd: 'I', lastColumn: 9 }, bankStart, null, bank, derived, null, { addressEn: company?.addressEn });
|
|
692
|
+
// 合计区(模板行 32-35,随行数平移):总毛重/总净重(供应商不提供,留空)/总体积/总箱数
|
|
693
|
+
const totalsRow = 12 + derived.lines.length;
|
|
694
|
+
set(sheet, `F${totalsRow}`, packing && packing.totals.grossKg > 0 ? packing.totals.grossKg : '');
|
|
695
|
+
set(sheet, `F${totalsRow + 1}`, '');
|
|
696
|
+
set(sheet, `F${totalsRow + 2}`, packing && packing.totals.volumeCbm > 0 ? packing.totals.volumeCbm : '');
|
|
697
|
+
set(sheet, `F${totalsRow + 3}`, packing && packing.totals.cartons > 0 ? packing.totals.cartons : '');
|
|
548
698
|
}
|
|
549
699
|
/**
|
|
550
700
|
* 银行信息区块按通道分两种布局:
|
|
@@ -659,7 +809,7 @@ export function remittanceInformation(cnapsCode, goodsName, derived, civilUse) {
|
|
|
659
809
|
return parts.join(',');
|
|
660
810
|
}
|
|
661
811
|
function fillCustomsSheet(sheet, data) {
|
|
662
|
-
const { customer, purchaseOrder, purchaseLines, derived, company } = data;
|
|
812
|
+
const { customer, purchaseOrder, purchaseLines, derived, company, packing } = data;
|
|
663
813
|
resizeTwoRowSection(sheet, 14, 2, derived.lines.length, 12);
|
|
664
814
|
const companyText = [company?.companyNameZh, company?.unifiedSocialCreditCode ? `(${company.unifiedSocialCreditCode})` : ''].join('');
|
|
665
815
|
set(sheet, 'K3', '1/1');
|
|
@@ -669,6 +819,11 @@ function fillCustomsSheet(sheet, data) {
|
|
|
669
819
|
set(sheet, 'B6', companyText, true);
|
|
670
820
|
set(sheet, 'F7', customer.country, true);
|
|
671
821
|
set(sheet, 'H7', customer.country, true);
|
|
822
|
+
// 报关单表头件数/毛重:来自供应商装箱数据合计(净重供应商不提供,留空)
|
|
823
|
+
if (packing) {
|
|
824
|
+
set(sheet, 'H8', packing.totals.cartons > 0 ? packing.totals.cartons : '');
|
|
825
|
+
set(sheet, 'J8', packing.totals.grossKg > 0 ? packing.totals.grossKg : '');
|
|
826
|
+
}
|
|
672
827
|
for (let index = 0; index < derived.lines.length; index += 1) {
|
|
673
828
|
const row = 14 + index * 2;
|
|
674
829
|
safeMerge(sheet, `A${row}:A${row + 1}`);
|