feat: 支持合同专用章
This commit is contained in:
@@ -33,9 +33,15 @@
|
||||
1. 应用迁移 `20261028090000_membership_contracts`(仅新增表与 `orders.contract_accepted_at` 列),生成 Prisma Client,再发布 shared/server。
|
||||
2. 新增依赖 `pdfkit`;中文字体随服务端发布在 `packages/server/assets/fonts/NotoSansSC-Regular.otf`(SIL OFL)。可用环境变量 `CONTRACT_PDF_FONT_PATH` 指向其他字体。
|
||||
3. 小程序后台「开发管理 - 服务器域名」需将 API 域名加入 **downloadFile 合法域名**,否则无法导出 PDF。
|
||||
4. 发布前由馆主在「管理中心 - 合同签署设置」填写经营者名称、统一社会信用代码、经营者、联系电话、经营场所,并手写经营者签名。未完善时学员可查看但不能签署。
|
||||
4. 发布前由馆主在「管理中心 - 合同签署设置」填写经营者名称、统一社会信用代码、经营者、联系电话、经营场所。默认合同专用章见下方,经营者手写签名选填。未完善基本信息时学员可查看但不能签署。
|
||||
5. 发布新版小程序。旧版小程序购卡不会勾选同意,但仍会生成待签合同,并在新版个人中心提示签署。
|
||||
|
||||
### 默认乙方合同章
|
||||
|
||||
- 场馆提供的原图保存在 `packages/server/assets/contracts/party-b-seal.jpg`,发布服务端时须连同 `assets/` 一起部署(与 PDF 字体一致)。无需新增环境变量或数据库迁移。
|
||||
- 合同设置显示默认章;购买前范本、待签合同及签署阅读页在乙方签章区展示,经营者手写签名仍可选填并同时展示。
|
||||
- 签署时合同章以 data URI 写入合同正文 `document.partyBSeal`,纳入 SHA-256 校验并冻结;详情和 PDF 只使用该快照。替换默认图片后,仅影响未签合同,已签旧合同不追补印章。
|
||||
|
||||
## 回退
|
||||
|
||||
- 服务端回退到上一版本即可,新增表保留不影响旧代码;不要删除 `membership_contracts`、`contract_events`,它们是签署凭证。
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user