feat(contract): update contract template with amount fields
更新合同模板和 PDF 生成服务,支持动态计算金额字段。 ## 合同模板更新 - 替换为新版联合种植协议模板(3页,带公章) - 新增表单字段:totalAmount、totalAmountChinese、greenPointsAmount ## PDF 生成服务更新 - 新增单价常量: - PRICE_PER_TREE_CNY = 17414.1(人民币含税价) - PRICE_PER_TREE_GREEN_POINTS = 15831(绿积分价格) - 新增 numberToChineseAmount() 函数:数字转中文大写金额 - 更新 ContractPdfData 接口:新增可选字段 totalAmount、greenPointsAmount - 更新 fillFormFields():根据认种棵数自动计算金额 - 移除坐标定位填充方式,仅使用表单字段方式 - 所有表单字段现为必需,缺少时抛出明确错误 ## 金额计算逻辑 - 人民币金额 = 棵数 × 17414.1 - 绿积分金额 = 棵数 × 15831 - 大写金额自动生成(如:壹万柒仟肆佰壹拾肆元壹角) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
17b9c09381
commit
9c17140b33
|
|
@ -1,7 +1,7 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { PDFDocument, PDFFont, rgb } from 'pdf-lib';
|
||||
import { PDFDocument, PDFFont /* , rgb */ } from 'pdf-lib';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fontkit = require('@pdf-lib/fontkit');
|
||||
|
|
@ -16,12 +16,104 @@ const FORM_FIELDS = {
|
|||
USER_ID_CARD: 'userIdCard',
|
||||
USER_PHONE: 'userPhone',
|
||||
TREE_COUNT: 'treeCount',
|
||||
TOTAL_AMOUNT: 'totalAmount', // 认种金额(数字)
|
||||
TOTAL_AMOUNT_CHINESE: 'totalAmountChinese', // 认种金额(大写)
|
||||
GREEN_POINTS_AMOUNT: 'greenPointsAmount', // 绿积分金额
|
||||
SIGN_YEAR: 'signYear',
|
||||
SIGN_MONTH: 'signMonth',
|
||||
SIGN_DAY: 'signDay',
|
||||
SIGNATURE: 'signature', // 签名图片按钮字段
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 数字转中文大写金额
|
||||
* @param num 数字金额
|
||||
* @returns 中文大写金额字符串
|
||||
*/
|
||||
function numberToChineseAmount(num: number): string {
|
||||
if (num === 0) return '零元整';
|
||||
|
||||
const digits = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'];
|
||||
const units = ['', '拾', '佰', '仟'];
|
||||
const bigUnits = ['', '万', '亿', '兆'];
|
||||
|
||||
// 分离整数和小数部分
|
||||
const [integerPart, decimalPart] = num.toFixed(2).split('.');
|
||||
const intNum = parseInt(integerPart, 10);
|
||||
const jiao = parseInt(decimalPart[0], 10); // 角
|
||||
const fen = parseInt(decimalPart[1], 10); // 分
|
||||
|
||||
let result = '';
|
||||
|
||||
// 处理整数部分
|
||||
if (intNum > 0) {
|
||||
const intStr = intNum.toString();
|
||||
const len = intStr.length;
|
||||
let zeroFlag = false; // 是否需要补零
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const digit = parseInt(intStr[i], 10);
|
||||
const unitIndex = (len - i - 1) % 4;
|
||||
const bigUnitIndex = Math.floor((len - i - 1) / 4);
|
||||
|
||||
if (digit === 0) {
|
||||
zeroFlag = true;
|
||||
// 在万、亿位置需要加单位
|
||||
if (unitIndex === 0 && bigUnitIndex > 0) {
|
||||
// 检查这个大单位区间是否全为零
|
||||
const start = Math.max(0, i - 3);
|
||||
let allZero = true;
|
||||
for (let j = start; j <= i; j++) {
|
||||
if (parseInt(intStr[j], 10) !== 0) {
|
||||
allZero = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!allZero) {
|
||||
result += bigUnits[bigUnitIndex];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (zeroFlag) {
|
||||
result += digits[0]; // 补零
|
||||
zeroFlag = false;
|
||||
}
|
||||
result += digits[digit] + units[unitIndex];
|
||||
if (unitIndex === 0) {
|
||||
result += bigUnits[bigUnitIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
result += '元';
|
||||
}
|
||||
|
||||
// 处理小数部分
|
||||
if (jiao === 0 && fen === 0) {
|
||||
result += '整';
|
||||
} else {
|
||||
if (jiao > 0) {
|
||||
result += digits[jiao] + '角';
|
||||
} else if (intNum > 0) {
|
||||
result += '零';
|
||||
}
|
||||
if (fen > 0) {
|
||||
result += digits[fen] + '分';
|
||||
}
|
||||
}
|
||||
|
||||
return result || '零元整';
|
||||
}
|
||||
|
||||
/**
|
||||
* 单棵榴莲树含税价格(人民币)
|
||||
*/
|
||||
export const PRICE_PER_TREE_CNY = 17414.1;
|
||||
|
||||
/**
|
||||
* 单棵榴莲树绿积分价格
|
||||
*/
|
||||
export const PRICE_PER_TREE_GREEN_POINTS = 15831;
|
||||
|
||||
/**
|
||||
* 合同 PDF 生成数据
|
||||
*/
|
||||
|
|
@ -31,6 +123,8 @@ export interface ContractPdfData {
|
|||
userIdCard: string;
|
||||
userPhone: string;
|
||||
treeCount: number;
|
||||
totalAmount?: number; // 认种金额(人民币),可选,默认根据 treeCount 计算
|
||||
greenPointsAmount?: number; // 绿积分金额,可选
|
||||
signingDate: string; // YYYY-MM-DD 格式
|
||||
}
|
||||
|
||||
|
|
@ -100,17 +194,15 @@ export class PdfGeneratorService {
|
|||
const fontBytes = fs.readFileSync(this.fontPath);
|
||||
const customFont = await pdfDoc.embedFont(fontBytes);
|
||||
|
||||
// 4. 选择填充方式:优先使用表单字段,否则使用坐标定位
|
||||
if (this.hasFormFields(pdfDoc)) {
|
||||
this.logger.log('Using form fields mode');
|
||||
await this.fillFormFields(pdfDoc, data, customFont);
|
||||
// 预览PDF时扁平化所有字段(包括签名字段)
|
||||
const form = pdfDoc.getForm();
|
||||
form.flatten();
|
||||
} else {
|
||||
this.logger.log('Using coordinate mode (no form fields found)');
|
||||
this.fillByCoordinates(pdfDoc, data, customFont);
|
||||
// 4. 使用表单字段填充
|
||||
if (!this.hasFormFields(pdfDoc)) {
|
||||
throw new Error('PDF模板缺少表单字段,请使用带表单字段的模板');
|
||||
}
|
||||
this.logger.log('Using form fields mode');
|
||||
await this.fillFormFields(pdfDoc, data, customFont);
|
||||
// 预览PDF时扁平化所有字段(包括签名字段)
|
||||
const form = pdfDoc.getForm();
|
||||
form.flatten();
|
||||
|
||||
// 5. 保存 PDF
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
|
|
@ -138,6 +230,12 @@ export class PdfGeneratorService {
|
|||
const form = pdfDoc.getForm();
|
||||
const [year, month, day] = data.signingDate.split('-');
|
||||
|
||||
// 计算金额:优先使用传入的金额,否则根据棵数计算
|
||||
const totalAmount = data.totalAmount ?? data.treeCount * PRICE_PER_TREE_CNY;
|
||||
const totalAmountChinese = numberToChineseAmount(totalAmount);
|
||||
// 绿积分金额:优先使用传入的金额,否则根据棵数计算(使用绿积分单价)
|
||||
const greenPointsAmount = data.greenPointsAmount ?? data.treeCount * PRICE_PER_TREE_GREEN_POINTS;
|
||||
|
||||
// 填充各个字段
|
||||
const fieldMappings: Array<{ name: string; value: string }> = [
|
||||
{ name: FORM_FIELDS.CONTRACT_NO, value: data.contractNo },
|
||||
|
|
@ -145,6 +243,9 @@ export class PdfGeneratorService {
|
|||
{ name: FORM_FIELDS.USER_ID_CARD, value: data.userIdCard || '未认证' },
|
||||
{ name: FORM_FIELDS.USER_PHONE, value: data.userPhone || '未认证' },
|
||||
{ name: FORM_FIELDS.TREE_COUNT, value: data.treeCount.toString() },
|
||||
{ name: FORM_FIELDS.TOTAL_AMOUNT, value: totalAmount.toFixed(2) },
|
||||
{ name: FORM_FIELDS.TOTAL_AMOUNT_CHINESE, value: totalAmountChinese },
|
||||
{ name: FORM_FIELDS.GREEN_POINTS_AMOUNT, value: greenPointsAmount.toFixed(2) },
|
||||
{ name: FORM_FIELDS.SIGN_YEAR, value: year },
|
||||
{ name: FORM_FIELDS.SIGN_MONTH, value: month },
|
||||
{ name: FORM_FIELDS.SIGN_DAY, value: day },
|
||||
|
|
@ -156,7 +257,8 @@ export class PdfGeneratorService {
|
|||
field.setText(value);
|
||||
field.updateAppearances(font);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Form field '${name}' not found, skipping`);
|
||||
// 所有字段都是必须的,缺少任何字段都应该报错
|
||||
throw new Error(`PDF模板缺少必需的表单字段: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -164,92 +266,6 @@ export class PdfGeneratorService {
|
|||
// 签名字段需要保留,待签名嵌入后再统一扁平化
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用坐标定位填充 PDF(后备方式)
|
||||
* 坐标需要根据实际 PDF 布局调整
|
||||
*/
|
||||
private fillByCoordinates(
|
||||
pdfDoc: PDFDocument,
|
||||
data: ContractPdfData,
|
||||
customFont: PDFFont,
|
||||
): void {
|
||||
const pages = pdfDoc.getPages();
|
||||
const page1 = pages[0]; // 第1页:协议编号
|
||||
const page2 = pages[1]; // 第2页:乙方信息
|
||||
const page3 = pages[2]; // 第3页:种植期限、认种数量
|
||||
const page6 = pages[5]; // 第6页:签名区域
|
||||
|
||||
const fontSize = 12;
|
||||
const textColor = rgb(0, 0, 0);
|
||||
|
||||
// 填充第1页 - 协议编号(使用合同编号)
|
||||
page1.drawText(data.contractNo, {
|
||||
x: 390,
|
||||
y: 95,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
|
||||
// 填充第2页 - 乙方信息
|
||||
page2.drawText(data.userRealName || '未认证', {
|
||||
x: 155,
|
||||
y: 583,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
|
||||
page2.drawText(data.userIdCard || '未认证', {
|
||||
x: 130,
|
||||
y: 557,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
|
||||
page2.drawText(data.userPhone || '未认证', {
|
||||
x: 130,
|
||||
y: 531,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
|
||||
// 填充第3页 - 认种数量
|
||||
page3.drawText(data.treeCount.toString(), {
|
||||
x: 138,
|
||||
y: 477,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
|
||||
// 填充第6页 - 签约日期
|
||||
const [year, month, day] = data.signingDate.split('-');
|
||||
page6.drawText(year, {
|
||||
x: 290,
|
||||
y: 103,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
page6.drawText(month, {
|
||||
x: 345,
|
||||
y: 103,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
page6.drawText(day, {
|
||||
x: 385,
|
||||
y: 103,
|
||||
size: fontSize,
|
||||
font: customFont,
|
||||
color: textColor,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在已生成的 PDF 上嵌入签名
|
||||
* @param pdfBuffer 原始 PDF Buffer
|
||||
|
|
@ -367,13 +383,11 @@ export class PdfGeneratorService {
|
|||
const customFont = await pdfDoc.embedFont(fontBytes);
|
||||
|
||||
// 4. 填充表单字段(不扁平化)
|
||||
if (this.hasFormFields(pdfDoc)) {
|
||||
this.logger.log('Using form fields mode');
|
||||
await this.fillFormFields(pdfDoc, data, customFont);
|
||||
} else {
|
||||
this.logger.log('Using coordinate mode (no form fields found)');
|
||||
this.fillByCoordinates(pdfDoc, data, customFont);
|
||||
if (!this.hasFormFields(pdfDoc)) {
|
||||
throw new Error('PDF模板缺少表单字段,请使用带表单字段的模板');
|
||||
}
|
||||
this.logger.log('Using form fields mode');
|
||||
await this.fillFormFields(pdfDoc, data, customFont);
|
||||
|
||||
// 5. 如果有签名,嵌入签名图片到 signature 按钮字段
|
||||
if (signature) {
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue