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:
hailin 2026-01-03 20:21:37 -08:00
parent 17b9c09381
commit 9c17140b33
4 changed files with 118 additions and 104 deletions

View File

@ -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) {