feat: 支持合同专用章
This commit is contained in:
BIN
packages/server/assets/contracts/party-b-seal.jpg
Normal file
BIN
packages/server/assets/contracts/party-b-seal.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
7
packages/server/src/contract/contract-seal.ts
Normal file
7
packages/server/src/contract/contract-seal.ts
Normal 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')}`
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user