@chenchaolong/plugin-trade-compliance-workbench 1.0.179 → 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.
Files changed (82) hide show
  1. package/README.md +2 -1
  2. package/dist/assets/templates/PL.xlsx +0 -0
  3. package/dist/lib/adapters/file-storage.d.ts +33 -3
  4. package/dist/lib/adapters/file-storage.d.ts.map +1 -1
  5. package/dist/lib/adapters/file-storage.js +176 -17
  6. package/dist/lib/adapters/file-storage.js.map +1 -1
  7. package/dist/lib/constants.d.ts +1 -0
  8. package/dist/lib/constants.d.ts.map +1 -1
  9. package/dist/lib/constants.js +1 -0
  10. package/dist/lib/constants.js.map +1 -1
  11. package/dist/lib/entities/index.d.ts +3 -1
  12. package/dist/lib/entities/index.d.ts.map +1 -1
  13. package/dist/lib/entities/index.js +4 -1
  14. package/dist/lib/entities/index.js.map +1 -1
  15. package/dist/lib/entities/settings.entity.d.ts +5 -0
  16. package/dist/lib/entities/settings.entity.d.ts.map +1 -1
  17. package/dist/lib/entities/settings.entity.js +4 -0
  18. package/dist/lib/entities/settings.entity.js.map +1 -1
  19. package/dist/lib/entities/supplier-packing.entity.d.ts +35 -0
  20. package/dist/lib/entities/supplier-packing.entity.d.ts.map +1 -0
  21. package/dist/lib/entities/supplier-packing.entity.js +110 -0
  22. package/dist/lib/entities/supplier-packing.entity.js.map +1 -0
  23. package/dist/lib/import-jobs/import-command.builder.d.ts +1 -0
  24. package/dist/lib/import-jobs/import-command.builder.d.ts.map +1 -1
  25. package/dist/lib/import-jobs/import-command.builder.js +9 -4
  26. package/dist/lib/import-jobs/import-command.builder.js.map +1 -1
  27. package/dist/lib/import-jobs/import-job.processor.d.ts.map +1 -1
  28. package/dist/lib/import-jobs/import-job.processor.js +2 -1
  29. package/dist/lib/import-jobs/import-job.processor.js.map +1 -1
  30. package/dist/lib/packing/supplier-packing.service.d.ts +75 -0
  31. package/dist/lib/packing/supplier-packing.service.d.ts.map +1 -0
  32. package/dist/lib/packing/supplier-packing.service.js +229 -0
  33. package/dist/lib/packing/supplier-packing.service.js.map +1 -0
  34. package/dist/lib/remote-components/trade-compliance-workbench/app.js +4 -4
  35. package/dist/lib/remote-ui/action-execution.d.ts.map +1 -1
  36. package/dist/lib/remote-ui/action-execution.js +4 -1
  37. package/dist/lib/remote-ui/action-execution.js.map +1 -1
  38. package/dist/lib/remote-ui/app-source.js +1 -1
  39. package/dist/lib/remote-ui/app-source.js.map +1 -1
  40. package/dist/lib/remote-ui/components/bulk-delete.d.ts +1 -1
  41. package/dist/lib/remote-ui/components/bulk-delete.d.ts.map +1 -1
  42. package/dist/lib/remote-ui/components/bulk-delete.js +1 -1
  43. package/dist/lib/remote-ui/components/bulk-delete.js.map +1 -1
  44. package/dist/lib/remote-ui/components/data-grid.d.ts +1 -1
  45. package/dist/lib/remote-ui/components/data-grid.d.ts.map +1 -1
  46. package/dist/lib/remote-ui/pages/files.d.ts +7 -3
  47. package/dist/lib/remote-ui/pages/files.d.ts.map +1 -1
  48. package/dist/lib/remote-ui/pages/files.js +49 -11
  49. package/dist/lib/remote-ui/pages/files.js.map +1 -1
  50. package/dist/lib/remote-ui/pages/sales.js +2 -2
  51. package/dist/lib/sales-file.service.d.ts +6 -1
  52. package/dist/lib/sales-file.service.d.ts.map +1 -1
  53. package/dist/lib/sales-file.service.js +207 -52
  54. package/dist/lib/sales-file.service.js.map +1 -1
  55. package/dist/lib/schema-maintenance.d.ts +0 -7
  56. package/dist/lib/schema-maintenance.d.ts.map +1 -1
  57. package/dist/lib/schema-maintenance.js +1 -39
  58. package/dist/lib/schema-maintenance.js.map +1 -1
  59. package/dist/lib/types.d.ts +37 -5
  60. package/dist/lib/types.d.ts.map +1 -1
  61. package/dist/lib/types.js +7 -5
  62. package/dist/lib/types.js.map +1 -1
  63. package/dist/lib/workbench-action-contract.d.ts +1 -1
  64. package/dist/lib/workbench-action-contract.d.ts.map +1 -1
  65. package/dist/lib/workbench-action-contract.js +4 -1
  66. package/dist/lib/workbench-action-contract.js.map +1 -1
  67. package/dist/lib/workbench-view.provider.d.ts +11 -3
  68. package/dist/lib/workbench-view.provider.d.ts.map +1 -1
  69. package/dist/lib/workbench-view.provider.js +40 -7
  70. package/dist/lib/workbench-view.provider.js.map +1 -1
  71. package/dist/lib/workbench.middleware.d.ts +86 -1
  72. package/dist/lib/workbench.middleware.d.ts.map +1 -1
  73. package/dist/lib/workbench.middleware.js +12 -2
  74. package/dist/lib/workbench.middleware.js.map +1 -1
  75. package/dist/lib/workbench.plugin.d.ts.map +1 -1
  76. package/dist/lib/workbench.plugin.js +2 -1
  77. package/dist/lib/workbench.plugin.js.map +1 -1
  78. package/dist/lib/workbench.service.d.ts +5 -0
  79. package/dist/lib/workbench.service.d.ts.map +1 -1
  80. package/dist/lib/workbench.service.js +2 -16
  81. package/dist/lib/workbench.service.js.map +1 -1
  82. 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
- return preflightResult(input, evaluated.compliance.complianceProfitRate.toString(), evaluated.exchangeCost?.toString() ?? null, evaluated.warnings);
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, translated.customerName || customer.name) : null;
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
- const derived = (await this.workbench.listDerivedSalesOrders(scope)).find(item => item.customerContractId === input.customerContractId && item.purchaseOrderId === input.purchaseOrderId);
106
- if (!derived)
107
- throw new DomainError(DomainErrorCodes.NOT_FOUND, '未找到已关联且具备定价利润率的销售订单');
108
- if (!derived.lines.length)
109
- throw new DomainError(DomainErrorCodes.INVALID_INPUT, '销售订单至少需要一条商品明细');
110
- const customerContract = await this.required(CustomerContract, scope, input.customerContractId, '客户合同不存在');
111
- const customer = await this.required(Customer, scope, customerContract.customerId, '客户不存在');
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 purchaseOrder = await this.required(PurchaseOrder, scope, input.purchaseOrderId, '采购订单不存在');
122
- const purchaseIds = derived.purchaseOrderIds?.length ? derived.purchaseOrderIds : [input.purchaseOrderId];
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 ?? purchaseOrder.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
- return { derived, customerContract, customer, purchaseOrder, purchaseLines, rule, compliance, exchangeCost, exchangeRate, bank, warnings };
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
- customerName: await translate('customer.name', customer.name), customerAddress: await translate('customer.address', customer.address),
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, translated.customerName || customer.name));
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, bank, translated } = data;
518
- resizeSingleRowSection(sheet, 12, 2, derived.lines.length, 9);
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, 'B6', translated.customerCountry);
527
- set(sheet, 'B7', translated.customerContact);
528
- set(sheet, 'F7', company?.email, true);
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 defaults = [sheet.getCell(row, 6).value, sheet.getCell(row, 7).value, sheet.getCell(row, 8).value, sheet.getCell(row, 9).value];
534
- [translated.lines[index]?.productName ?? '', translated.lines[index]?.model ?? '', translated.lines[index]?.description ?? '', Number(derived.lines[index].quantity), translated.lines[index]?.unit ?? '', ...defaults].forEach((value, offset) => sheet.getCell(row, offset + 1).value = value);
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
- const total = 12 + derived.lines.length;
537
- const bankStart = total + 4;
538
- set(sheet, `B${bankStart}`, bank.beneficiary ?? '');
539
- set(sheet, `B${bankStart + 1}`, bank.bankName ?? '');
540
- set(sheet, `B${bankStart + 2}`, bank.bankAddress ?? '');
541
- set(sheet, `A${bankStart + 3}`, '4. Account No.');
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}`);