feat: 支持合同专用章

This commit is contained in:
richarjiang
2026-10-29 08:27:55 +08:00
parent 96c0244831
commit 9afdb60824
10 changed files with 97 additions and 17 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -3,6 +3,7 @@ import { Decimal } from '@prisma/client/runtime/library'
import { MembershipContractSource, MembershipContractStatus } from '@mp-pilates/shared'
import { ContractService, decodeSignaturePng } from '../contract.service'
import { buildContractDocument, hashContractDocument } from '../contract-template'
import { DEFAULT_CONTRACT_SEAL } from '../contract-seal'
const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(800, 1)])
const PNG_DATA_URI = `data:image/png;base64,${PNG.toString('base64')}`
@@ -249,7 +250,7 @@ describe('ContractService.sign', () => {
source: MembershipContractSource.PURCHASE,
terms: terms as never,
partyA: { realName: '张三', phone: '13800000000', openid: 'openid-123456789' },
partyB: { ...party, premisesType: 'LEASED', leaseEndDate: null },
partyB: { ...party, premisesType: 'LEASED', leaseEndDate: null, sealImage: DEFAULT_CONTRACT_SEAL },
cancelHoursLimit: 2,
}))
const dto = { contentSha256: expectedHash, signatureImage: PNG_DATA_URI, agreedTerms: true, confirmedHealth: true }
@@ -269,7 +270,7 @@ describe('ContractService.sign', () => {
it('rejects signing until party B profile is complete', async () => {
const { service, prisma } = buildService()
prisma.contractParty.findFirst.mockResolvedValue({ ...party, signatureImage: null })
prisma.contractParty.findFirst.mockResolvedValue({ ...party, businessName: '' })
prisma.membershipContract.findUnique.mockResolvedValue(contract)
await expect(service.sign('user-1', 'contract-1', dto, meta)).rejects.toBeInstanceOf(BadRequestException)
})
@@ -286,11 +287,52 @@ describe('ContractService.sign', () => {
contentSha256: expectedHash,
signerIp: '1.2.3.4',
partyBSignatureImage: PNG,
document: expect.objectContaining({ partyBSeal: DEFAULT_CONTRACT_SEAL }),
})
expect(update.data.signatureSha256).toMatch(/^[a-f0-9]{64}$/)
expect(prisma.contractEvent.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'SIGNED' }) }))
})
it('shows the default seal before signing and allows signing without a handwritten party B signature', async () => {
const { service, prisma } = buildService()
prisma.contractParty.findFirst.mockResolvedValue({ ...party, signatureImage: null, signatureSha256: null })
prisma.membershipContract.findUnique.mockResolvedValue(contract)
prisma.membershipContract.updateMany.mockResolvedValue({ count: 1 })
const detail = await service.getDetail(contract.id, { id: 'user-1', isAdmin: false })
expect(detail.signable).toBe(true)
expect(detail.document.partyBSeal).toBe(DEFAULT_CONTRACT_SEAL)
const config = await service.getPartyConfig()
expect(config.sealImage).toBe(DEFAULT_CONTRACT_SEAL)
expect(config.missingFields).toEqual([])
await service.sign('user-1', contract.id, { ...dto, contentSha256: detail.contentSha256 }, meta)
expect(prisma.membershipContract.updateMany.mock.calls[0][0].data).toMatchObject({
partyBSignatureImage: null,
document: expect.objectContaining({ partyBSeal: DEFAULT_CONTRACT_SEAL }),
})
})
it('uses only the frozen document and seal for signed detail and PDF, including old contracts without a seal', async () => {
const { service, prisma, pdf } = buildService()
const document = buildContractDocument({
contractNo: contract.contractNo, source: MembershipContractSource.PURCHASE, terms: terms as never,
partyA: { realName: '张三', phone: '13800000000', openid: contract.user.openid },
partyB: { ...party, premisesType: 'LEASED', leaseEndDate: null, sealImage: DEFAULT_CONTRACT_SEAL },
cancelHoursLimit: 2,
})
for (const seal of [DEFAULT_CONTRACT_SEAL, undefined]) {
const snapshot = { ...document, partyBSeal: seal }
prisma.membershipContract.findUnique.mockResolvedValue({
...contract, status: 'SIGNED', document: snapshot, signatureImage: PNG,
partyBSignatureImage: PNG, partyBName: '签署时的场馆', signedAt: new Date(), contentSha256: 'frozen-hash',
})
const detail = await service.getDetail(contract.id, { id: 'user-1', isAdmin: false })
expect(detail.document.partyBSeal).toBe(seal)
await service.exportPdf(contract.id, { id: 'user-1', isAdmin: false }, meta)
expect(pdf.render).toHaveBeenLastCalledWith(expect.objectContaining({ document: snapshot, partyBName: '签署时的场馆' }))
}
expect(prisma.contractParty.findFirst).not.toHaveBeenCalled()
})
it('reports a concurrent signature as a conflict', async () => {
const { service, prisma } = buildService()
prisma.membershipContract.findUnique.mockResolvedValue(contract)

View File

@@ -128,11 +128,16 @@ export class ContractPdfService {
}
private signatures(doc: PDFKit.PDFDocument, input: ContractPdfInput, width: number) {
const blockHeight = 150
if (doc.y + blockHeight > doc.page.height - doc.page.margins.bottom) doc.addPage()
doc.moveDown(1.2)
const top = doc.y
const column = width / 2
const seal = input.document.partyBSeal
const mediaHeight = (input.partyBSignature ? 70 : 0) + (seal ? 124 : 0)
const nameOffset = Math.max(94, 18 + mediaHeight + 6)
doc.fontSize(9)
const nameHeight = doc.heightOfString(input.partyBName, { width: column - 12 })
const blockHeight = nameOffset + nameHeight + 30
doc.moveDown(1.2)
if (doc.y + blockHeight > doc.page.height - doc.page.margins.bottom) doc.addPage()
const top = doc.y
const signedAt = formatChinaDateTime(input.signedAt)
doc.fontSize(10).fillColor(TEXT_COLOR)
@@ -146,9 +151,10 @@ export class ContractPdfService {
doc.fontSize(10).fillColor(TEXT_COLOR)
doc.text('乙方(签章):', right, top)
if (input.partyBSignature) doc.image(input.partyBSignature, right, top + 18, { fit: [column - 24, 70] })
if (seal) doc.image(seal, right, top + 18 + (input.partyBSignature ? 70 : 0), { fit: [column - 24, 118], align: 'center' })
doc.fontSize(9).fillColor(MUTED_COLOR)
doc.text(input.partyBName, right, top + 94, { width: column })
doc.text(`签署时间:${signedAt}`, right, top + 110)
doc.text(input.partyBName, right, top + nameOffset, { width: column - 12 })
doc.text(`签署时间:${signedAt}`, right, top + nameOffset + nameHeight + 6)
doc.x = PAGE_MARGIN
doc.y = top + blockHeight
}

View File

@@ -0,0 +1,7 @@
import { readFileSync } from 'fs'
import { join } from 'path'
/** 场馆提供的合同专用章;签署后只读取合同内的快照。 */
export const DEFAULT_CONTRACT_SEAL = `data:image/jpeg;base64,${readFileSync(
join(__dirname, '..', '..', 'assets', 'contracts', 'party-b-seal.jpg'),
).toString('base64')}`

View File

@@ -53,6 +53,7 @@ export interface ContractPartyBInput {
premisesType: ContractPremisesType
leaseEndDate: string | null
standardLessonPrice: number
sealImage?: string
}
export interface ContractRenderInput {
@@ -397,6 +398,7 @@ export function buildContractDocument(input: ContractRenderInput): ContractDocum
cardRows: buildCardRows(input),
sections: buildSections(input),
commitment: '甲方承诺:本人确认所填写的姓名、联系方式真实有效,已如实告知身体健康状况,已仔细阅读并理解本合同全部条款(特别是加粗条款),自愿签署本合同。',
...(input.partyB.sealImage ? { partyBSeal: input.partyB.sealImage } : {}),
}
}

View File

@@ -29,6 +29,7 @@ import {
import { PrismaService } from '../prisma/prisma.service'
import { StudioService } from '../studio/studio.service'
import { ContractPdfService } from './contract-pdf.service'
import { DEFAULT_CONTRACT_SEAL } from './contract-seal'
import {
CONTRACT_TEMPLATE_VERSION,
buildContractDocument,
@@ -487,6 +488,7 @@ export class ContractService {
standardLessonPrice: input.standardLessonPrice,
standardLessonPriceConfigured: party.standardLessonPrice > 0,
signatureImage: toDataUri(party.signatureImage),
sealImage: DEFAULT_CONTRACT_SEAL,
signatureUpdatedAt: party.signatureUpdatedAt?.toISOString() ?? null,
missingFields: this.missingFields(party, input),
}
@@ -610,6 +612,7 @@ export class ContractService {
premisesType: party.premisesType === 'OWNED' ? 'OWNED' : 'LEASED',
leaseEndDate: party.leaseEndDate?.toISOString() ?? null,
standardLessonPrice,
sealImage: DEFAULT_CONTRACT_SEAL,
}
}
@@ -621,7 +624,6 @@ export class ContractService {
if (!party.phone.trim()) missing.push('联系电话')
if (!party.address.trim()) missing.push('经营场所')
if (input.standardLessonPrice <= 0) missing.push('单节标准课价')
if (!party.signatureImage) missing.push('经营者签名')
return missing
}