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

View File

@@ -24,17 +24,19 @@
<text class="contract-doc__commitment">{{ document.commitment }}</text>
<view v-if="signature" class="contract-doc__signatures">
<view v-if="signature || document.partyBSeal" class="contract-doc__signatures">
<view class="contract-doc__sign">
<text class="contract-doc__sign-label">甲方(签名)</text>
<image class="contract-doc__sign-image" :src="signature.memberSignature" mode="aspectFit" />
<text class="contract-doc__sign-meta">{{ signature.signerName }}</text>
<image v-if="signature" class="contract-doc__sign-image" :src="signature.memberSignature" mode="aspectFit" />
<text v-else class="contract-doc__sign-meta">待会员签署</text>
<text v-if="signature" class="contract-doc__sign-meta">{{ signature.signerName }}</text>
<text class="contract-doc__sign-meta">{{ signedAtText }}</text>
</view>
<view class="contract-doc__sign">
<text class="contract-doc__sign-label">乙方(签章)</text>
<image v-if="signature.partyBSignature" class="contract-doc__sign-image" :src="signature.partyBSignature" mode="aspectFit" />
<text class="contract-doc__sign-meta">{{ signature.partyBName }}</text>
<image v-if="signature?.partyBSignature" class="contract-doc__sign-image" :src="signature.partyBSignature" mode="aspectFit" />
<image v-if="document.partyBSeal" class="contract-doc__seal-image" :src="document.partyBSeal" mode="aspectFit" />
<text class="contract-doc__sign-meta">{{ partyBName }}</text>
<text class="contract-doc__sign-meta">{{ signedAtText }}</text>
</view>
</view>
@@ -59,6 +61,8 @@ const groups = computed(() => [
])
const signedAtText = computed(() => props.signature ? `签署于 ${formatDateTimeFull(props.signature.signedAt)}` : '')
const partyBName = computed(() => props.signature?.partyBName
|| props.document.partyB.find(row => row.label === '经营者名称')?.value || '')
</script>
<style lang="scss" scoped>
@@ -79,6 +83,7 @@ const signedAtText = computed(() => props.signature ? `签署于 ${formatDateTim
&__sign { flex: 1; min-width: 0; padding: 20rpx; border-radius: 16rpx; background: #faf6f1; }
&__sign-label { display: block; font-size: 23rpx; color: #8b7f74; }
&__sign-image { display: block; width: 100%; height: 140rpx; margin: 10rpx 0; }
&__seal-image { display: block; width: 100%; height: 220rpx; margin: 10rpx 0; }
&__sign-meta { display: block; font-size: 21rpx; line-height: 1.7; color: #8b7f74; overflow-wrap: anywhere; }
&__digest { display: block; margin-top: 20rpx; font-size: 19rpx; line-height: 1.6; color: #b0a498; word-break: break-all; }
}

View File

@@ -73,8 +73,13 @@
</view>
<view class="panel">
<view class="panel-heading"><text class="section-number">03</text><text class="section-title">经营者签名</text></view>
<text class="hint">签名会作为乙方签章出现在每份合同中,签署时自动加载。建议由经营者本人书写。</text>
<view class="panel-heading"><text class="section-number">03</text><text class="section-title">乙方合同专用章</text></view>
<text class="hint">已默认加载场馆合同专用章,签署、查看及导出合同均会展示。合同章随签署内容保存,已签合同不受后续修改影响。</text>
<view class="seal-preview">
<image v-if="config?.sealImage" :src="config.sealImage" mode="aspectFit" class="seal-image" />
</view>
<text class="field-label">经营者签名(选填)</text>
<text class="hint">可由经营者本人书写,与合同章一同展示。</text>
<template v-if="signing">
<SignaturePad ref="pad" :height="360" placeholder="请经营者本人手写签名" @change="onSignatureChange" />
<view class="sign-actions">
@@ -273,6 +278,8 @@ button[disabled] { opacity: .42; }
.segment .selected { background: #fffdf8; color: #4f6243; }
.signature-preview { height: 200rpx; border-radius: 16rpx; background: #f5f4ec; display: flex; align-items: center; justify-content: center; margin-bottom: 20rpx; }
.signature-image { width: 100%; height: 180rpx; }
.seal-preview { display: flex; justify-content: center; margin-bottom: 24rpx; background: #fff; border-radius: 16rpx; }
.seal-image { width: 300rpx; height: 300rpx; }
.add-button { width: 100%; height: 84rpx; border: 1rpx dashed #bdc6ab; background: #f3f5eb; color: #74835f; }
.sign-actions { display: flex; gap: 16rpx; margin-top: 20rpx; }
.ghost-button { flex: 1; height: 80rpx; background: #eef0e6; color: #6d7a5d; }

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
}

View File

@@ -30,6 +30,8 @@ export interface ContractDocument {
readonly cardRows: readonly ContractRow[]
readonly sections: readonly ContractSection[]
readonly commitment: string
/** 乙方合同章图片,随正文及摘要冻结;旧合同可能没有。 */
readonly partyBSeal?: string
}
export interface ContractSignatureInfo {
@@ -106,6 +108,7 @@ export interface ContractPartyConfig {
readonly standardLessonPrice: number
readonly standardLessonPriceConfigured: boolean
readonly signatureImage: string | null
readonly sealImage: string
readonly signatureUpdatedAt: string | null
readonly missingFields: readonly string[]
}