hotfix-add subgroup customer

This commit is contained in:
phaichayon
2026-06-18 08:58:01 +07:00
parent 04a886ea26
commit 0650cf6297
38 changed files with 8674 additions and 687 deletions

326
plans/hotfix-1-customer.md Normal file
View File

@@ -0,0 +1,326 @@
# Hotfix: Add Customer Sub Group to Customer Form
## Goal
เพิ่ม field `Customer Sub Group` ในหน้า Add / Edit Customer
โดยใช้ master option category:
```txt
crm_customer_sub_group
```
และต้อง filter ตาม `Customer Group` ที่เลือก
Relationship:
```txt
crm_customer_group
↓ parent
crm_customer_sub_group
```
---
## Must Read
```txt
src/features/crm/customers/**
src/features/foundation/master-options/**
src/app/api/foundation/master-options/route.ts
src/db/schema.ts
src/db/seeds/foundation.seed.ts
```
---
## Scope 1: Data Model Check
ตรวจสอบว่า customer schema มี field สำหรับ sub group แล้วหรือยัง
Expected field:
```txt
customerSubGroup
```
หรือถ้าใช้ naming เดิม:
```txt
custSubGroup
```
Rules:
* ถ้ามี field อยู่แล้ว ให้ reuse
* ถ้ายังไม่มี ให้เพิ่ม field ใหม่แบบไม่กระทบข้อมูลเดิม
* field เป็น optional
* เก็บเป็น master option code/value ตาม pattern เดิมของ customerGroup
---
## Scope 2: Master Option Category
เพิ่ม/ยืนยัน category:
```txt
crm_customer_sub_group
```
Sub group ต้องมี parent relationship กับ group:
```txt
crm_customer_group.id
crm_customer_sub_group.parentId
```
Rules:
* parentId ต้องชี้ไป customer group option
* sub group dropdown ต้อง filter ด้วย parentId
* ถ้ายังไม่ได้เลือก customer group ให้ disable sub group dropdown
---
## Scope 3: Seed Data
อัปเดต:
```txt
src/db/seeds/foundation.seed.ts
```
เพิ่ม seed category:
```txt
crm_customer_group
crm_customer_sub_group
```
ตัวอย่าง:
```txt
crm_customer_group:
- industrial
- construction
- government
- dealer
- other
crm_customer_sub_group:
- factory
- warehouse
- logistics
- contractor
- consultant
- state_enterprise
- government_agency
- reseller
- other
```
Relationship ตัวอย่าง:
```txt
industrial
- factory
- warehouse
- logistics
construction
- contractor
- consultant
government
- state_enterprise
- government_agency
dealer
- reseller
other
- other
```
Rules:
* seed idempotent
* no hardcoded organizationId
* parent option must be created before child options
* child options must set parentId
* do not duplicate options if seed runs again
---
## Scope 4: Customer Schema / Zod
Update:
```txt
src/features/crm/customers/schemas/customer.schema.ts
```
Add:
```ts
customerSubGroup: z.string().optional().nullable()
```
หรือใช้ field name ที่มีอยู่จริง เช่น:
```ts
custSubGroup: z.string().optional().nullable()
```
Rules:
* Create Customer ต้องรับ field นี้ได้
* Edit Customer ต้อง load ค่าเดิมได้
* ถ้าเปลี่ยน customerGroup แล้ว customerSubGroup ไม่อยู่ใต้ parent ใหม่ ให้ clear ค่า customerSubGroup
---
## Scope 5: API / Server Service
Update:
```txt
src/features/crm/customers/server/service.ts
src/app/api/crm/customers/route.ts
src/app/api/crm/customers/[id]/route.ts
src/features/crm/customers/api/types.ts
```
Rules:
* create customer save customerSubGroup
* update customer save customerSubGroup
* list/detail return customerSubGroup
* validate sub group parent matches selected group ถ้าทำได้ง่าย
* if invalid parent-child relation, return user-safe error
---
## Scope 6: Customer Form UI
Update:
```txt
src/features/crm/customers/components/customer-form-sheet.tsx
```
Add field:
```txt
Customer Group
Customer Sub Group
```
Behavior:
* Customer Group dropdown uses `crm_customer_group`
* Customer Sub Group dropdown uses `crm_customer_sub_group`
* Customer Sub Group filtered by selected group option `parentId`
* If no Customer Group selected:
* disable Customer Sub Group
* placeholder: "Select customer group first"
* If Customer Group changes:
* reset Customer Sub Group
* If edit mode:
* load existing Customer Group
* load filtered Customer Sub Group
* selected value must display correctly
---
## Scope 7: Customer List / Detail
Update if relevant:
```txt
src/features/crm/customers/components/customer-columns.tsx
src/features/crm/customers/components/customer-detail.tsx
```
Display:
```txt
Customer Group
Customer Sub Group
```
If label lookup is available, show label instead of raw code.
---
## Scope 8: Query Freshness
After create/update:
* invalidate customer list
* invalidate customer detail
* form closes after success
* table/detail show new customer sub group immediately
Follow existing CRUD freshness rule.
---
## Scope 9: Regression Check
Verify:
* create customer with group + sub group
* create customer without sub group
* edit customer group changes and sub group resets
* edit customer retains valid sub group
* invalid sub group under another group is rejected or cleared
* customer list/detail displays value
---
## Explicit Non-Scope
Do NOT implement:
```txt
Customer group management UI
Inline create master option
Large RBAC rewrite
Customer import/export
Dashboard changes
Report changes
```
---
## Output
After completion, summarize:
1. Files Added
2. Files Modified
3. Schema Field Used/Added
4. Master Options Seeded
5. Form Behavior Added
6. Parent/Child Filtering Behavior
7. Validation Added
8. Verification Result
9. Remaining Risks
---
## Definition of Done
* Add/Edit Customer has Customer Sub Group field
* Customer Sub Group uses `crm_customer_sub_group`
* Sub Group filters by selected Customer Group parent
* Changing Customer Group resets invalid Sub Group
* Create customer saves Sub Group
* Edit customer saves Sub Group
* Detail/list can show Sub Group
* Mutation refreshes customer table/detail

337
plans/task-h.2.1.md Normal file
View File

@@ -0,0 +1,337 @@
# Task H.2.1: Reseed PDFME Template Mappings for Dynamic Topic Engine
## Goal
ปรับ seed data ของ PDFME template mappings ให้สอดคล้องกับ Task H.2
หลัง H.2 ระบบใช้ Dynamic Topic Engine แล้ว ดังนั้น `topic`, `data_topic`, และ dynamic topic input keys ไม่ควรถูกจัดการผ่าน `crm_document_template_mappings` แบบ static อีกต่อไป
---
# Background
Task H.2 เพิ่ม:
* PDFME transform utilities
* Dynamic Topic Engine
* Product topic mapping
* `pdfme` section ใน quotation document data
* runtime topic inputs
* server-side schema cloning สำหรับ `topic` และ `data_topic`
ดังนั้น seed mappings ต้องถูก cleanup ให้ตรงกับ model ใหม่
---
# Must Read
```txt
docs/implementation/task-h2-pdfme-mapping-transform-hotfix.md
docs/implementation/pdfme-runtime-audit.md
docs/implementation/pdfme-legacy-audit.md
src/db/seeds/foundation.seed.ts
src/features/foundation/document-template/**
src/features/crm/quotations/document/server/**
src/pdfme_template/**
```
---
# Scope H2.1.1: Inspect Current Template Placeholders
Extract placeholders from current default templates:
```txt
src/pdfme_template/ALLA_template_pdfme_fainal3.json
src/pdfme_template/ONVALLA_template_pdfme_fainal3.json
```
Expected static placeholders:
```txt
customer_name
customer_addr
customer_tel
customer_email
customer_att
project_name
site_location
quotation_date
quotation_code
quotation_price
currency
exclusion_data
```
Dynamic topic template schemas:
```txt
topic
data_topic
```
Signature placeholders, if present:
```txt
app1
app1_position
app2
app2_position
app3
app3_position
```
---
# Scope H2.1.2: Static Mapping Seed
Update `foundation.seed.ts` so default pdfme mapping contains only static runtime placeholders:
```txt
customer_name -> customer.name
customer_addr -> customer.address
customer_tel -> customer.phone
customer_email -> customer.email
customer_att -> quotation.attention
project_name -> quotation.projectName
site_location -> quotation.projectLocation
quotation_date -> pdfme.quotation_date
quotation_code -> quotation.code
quotation_price -> pdfme.quotation_price
currency -> quotation.currency
exclusion_data -> pdfme.exclusion_data
```
Recommended metadata:
```txt
quotation_date:
dataType = scalar
formatMask = null
sourcePath = pdfme.quotation_date
quotation_price:
dataType = scalar
formatMask = null
sourcePath = pdfme.quotation_price
exclusion_data:
dataType = table
sourcePath = pdfme.exclusion_data
```
Reason:
* date/currency are already formatted in `pdfme` section from H.2
* do not format twice in mapping resolver
---
# Scope H2.1.3: Retire Invalid Static Topic Mappings
Ensure seed removes or deactivates mappings for:
```txt
topic
data_topic
item_topic
data_topic_*
topic_*
item_topic_*
```
Rules:
* `topic` and `data_topic` are schema templates, not data placeholders
* `item_topic` is legacy content placeholder inside `data_topic`, not a DB mapping
* dynamic keys such as `topic_1_0` and `item_topic_1_0` are generated at runtime
* do not store generated dynamic keys in DB
Implementation guidance:
If mappings exist from old seed:
```txt
soft delete / delete / skip
```
according to current template mapping service convention.
---
# Scope H2.1.4: Signature Mapping Decision
If current template contains signature placeholders:
```txt
app1
app1_position
app2
app2_position
app3
app3_position
```
For this task:
Option A recommended:
Seed them with safe placeholder values:
```txt
app1 -> signatures.preparedBy.name
app1_position -> signatures.preparedBy.position
app2 -> signatures.approvedBy.name
app2_position -> signatures.approvedBy.position
app3 -> signatures.authorizedBy.name
app3_position -> signatures.authorizedBy.position
```
But only if `documentData.signatures` exists.
If not available yet:
* do not break PDF
* use defaultValue = "-"
* document as pending Task H.3
Rules:
* no approval signature business logic in this task
* no new approval mapping strategy in this task
---
# Scope H2.1.5: Seed Idempotency
Seed must be idempotent:
* run repeatedly without duplicate mappings
* update existing wrong sourcePath
* update existing wrong dataType
* remove/retire invalid mappings
* no hardcoded organizationId
* apply for every organization
* apply for both ALLA and ONVALLA templates if seeded
---
# Scope H2.1.6: Runtime Verification
Extend or run audit script:
```txt
scripts/audit-pdfme-runtime.ts
```
Expected result:
Static template placeholders:
```txt
customer_name mappingExists = true
customer_addr mappingExists = true
customer_tel mappingExists = true
customer_email mappingExists = true
customer_att mappingExists = true
project_name mappingExists = true
site_location mappingExists = true
quotation_date mappingExists = true
quotation_code mappingExists = true
quotation_price mappingExists = true
currency mappingExists = true
exclusion_data mappingExists = true
```
Dynamic schemas:
```txt
topic mappingExists = false, dynamicTemplate = true
data_topic mappingExists = false, dynamicTemplate = true
item_topic mappingExists = false, legacyContentToken = true
```
PDFME input should contain runtime keys:
```txt
topic_*
item_topic_*
```
but these must come from Dynamic Topic Engine, not DB mappings.
---
# Scope H2.1.7: Documentation
Update:
```txt
docs/implementation/task-h2-pdfme-mapping-transform-hotfix.md
```
Add section:
```txt
Seed Mapping Strategy After Dynamic Topic Engine
```
Also update:
```txt
docs/implementation/technical-debt.md
```
Add note:
```txt
Signature placeholders app1/app2/app3 still need Task H.3 strategy if not fully mapped.
```
---
# Explicit Non-Scope
Do NOT implement:
```txt
Approval signature strategy
Visual template designer
Dynamic topic editor
Changing PDF layout
Pixel-perfect comparison
Report Center
Dashboard changes
```
---
# Output
After completion, summarize:
1. Files Modified
2. Static Mappings Seeded
3. Invalid Topic Mappings Retired
4. Signature Placeholder Handling
5. Runtime Audit Result
6. Remaining Risks
7. Task H.3 Readiness
---
# Definition of Done
Task H.2.1 passes when:
* static PDFME mappings are complete
* `exclusion_data` maps to `pdfme.exclusion_data`
* `quotation_price` maps to `pdfme.quotation_price`
* `quotation_date` maps to `pdfme.quotation_date`
* `topic` is not mapped as static DB mapping
* `data_topic` is not mapped as static DB mapping
* `item_topic` is not mapped as static DB mapping
* dynamic topic keys are generated at runtime only
* seed is idempotent
* runtime audit recognizes topic/data_topic as dynamic templates

621
plans/task-h.2.md Normal file
View File

@@ -0,0 +1,621 @@
# Task H.2: PDFME Dynamic Topic Engine + Mapping Hotfix
## Background
Current issue:
```txt
PDFME quotation แสดงข้อมูลไม่ครบ
Mapping field ไม่ถูกต้อง
Topic จาก crm_quotation_topics / crm_quotation_topic_items ไม่แสดงครบ
```
จาก Legacy Audit และ Runtime Audit พบว่า:
1. Current template มี placeholder บางตัวแต่ mapping ไม่ครบ
2. `quotation_price` ยังส่งเป็น raw number
3. `quotation_date` ยังเสี่ยงเป็น raw date
4. `exclusion_data` ต้องเป็น `string[][]`
5. `topic` และ `data_topic` ไม่ใช่ mapping ปกติ
6. `topic` และ `data_topic` เป็น schema template สำหรับ dynamic topic rendering
---
# Must Read
```txt
docs/implementation/pdfme-legacy-audit.md
docs/implementation/pdfme-runtime-audit.md
docs/implementation/technical-debt.md
src/features/foundation/pdf-generator/**
src/features/foundation/document-template/**
src/features/crm/quotations/document/**
src/features/crm/quotations/**
src/db/schema.ts
src/db/seeds/foundation.seed.ts
src/pdfme_template/**
```
Legacy reference files:
```txt
pdf-topic.type.ts
pdf-topic-input.ts
layout-engine.ts
build-topic-schema.ts
pdf-topic-engine.ts
quotation-preview.tsx
```
Important legacy behavior:
* `topic` displays `crm_quotation_topics.topicType`
* `data_topic` displays rows from `crm_quotation_topic_items.content`
* `topic` and `data_topic` are cloned dynamically
* Runtime schema names become:
* `topic_0_0`
* `item_topic_0_0`
* `topic_0_1`
* `item_topic_0_1`
---
# Goal
Port the legacy PDFME topic rendering behavior into ALLA OS CRM vNext server-side PDF pipeline.
Fix current mapping issues without introducing a visual template designer.
---
# Scope H2.1: PDFME Transform Utilities
Create:
```txt
src/features/crm/quotations/document/server/pdfme-transforms.ts
```
Implement:
```ts
formatPdfDate(dateString: string | Date | null | undefined, language?: "th" | "en"): string
formatPdfCurrency(
amount: number | string | null | undefined,
currencyCode?: string
): string
formatTopicItems(
topic?: { items?: Array<{ content?: string | null; sortOrder?: number | null }> }
): string[][]
normalizePdfmeTable(
value: unknown,
columns?: Array<{ sourceField: string; formatMask?: string | null }>
): string[][]
```
Rules:
* empty scalar fallback = `-`
* empty topic fallback = `[["-"]]`
* all pdfme table inputs must be `string[][]`
* currency supports at least:
* THB
* USD
* EUR
* `date_th` uses Buddhist calendar
* `date_en` uses Gregorian calendar
* never return `undefined` to pdfme
---
# Scope H2.2: Dynamic Topic Types
Create:
```txt
src/features/crm/quotations/document/server/pdf-topic.type.ts
```
Types:
```ts
export type PdfTopicItem = {
id: string | number;
content: string;
sortOrder?: number | null;
};
export type PdfTopic = {
id?: string | number;
topicType: string;
sortOrder?: number | null;
items?: PdfTopicItem[];
};
export type PdfTopicEngineOptions = {
targetPageIndex?: number;
pageStartY?: number;
pageBottomY?: number;
topicSpacing?: number;
keepTogetherNames?: string[];
topicTemplateName?: string;
topicDataTemplateName?: string;
rowHeight?: number;
keepTogetherMinSpace?: number;
};
```
---
# Scope H2.3: PDFME Topic Engine
Create:
```txt
src/features/crm/quotations/document/server/pdf-topic-engine.ts
```
Port behavior from legacy:
```txt
buildTopicSchemas()
```
Function:
```ts
buildPdfTopicTemplate(
template: Template,
topics: PdfTopic[],
options?: PdfTopicEngineOptions
): {
template: Template;
topicInputs: Record<string, string[][]>;
}
```
Required behavior:
1. Read target page from `template.schemas[targetPageIndex]`
2. Locate schema named `topic`
3. Locate schema named `data_topic`
4. Treat both as template schemas
5. Remove original `topic` and `data_topic` from static schemas
6. Sort topics by `sortOrder`
7. Clone `topic` for each topic
8. Clone `data_topic` for each topic
9. Rename clones to stable dynamic keys:
```txt
topic_<pageIndex>_<topicIndex>
item_topic_<pageIndex>_<topicIndex>
```
10. Set topic label input:
```ts
topic_<pageIndex>_<topicIndex> = [[topic.topicType]]
```
11. Set topic items input:
```ts
item_topic_<pageIndex>_<topicIndex> = [
[item.content],
[item.content]
]
```
12. Calculate table height from row count
13. Paginate when topic block exceeds `pageBottomY`
14. Move signature/closing group together
15. Rebuild `template.schemas` with generated pages
16. Return final template + topicInputs
Default options:
```ts
{
targetPageIndex: 1,
pageStartY: 35,
pageBottomY: 250,
topicSpacing: 10,
keepTogetherNames: [
"Please_do_not",
"yours_faithfuly",
"app1",
"app1_position",
"app2",
"app2_position",
"app3",
"app3_position"
],
topicTemplateName: "topic",
topicDataTemplateName: "data_topic",
rowHeight: 7,
keepTogetherMinSpace: 60
}
```
Rules:
* if template has no `topic` or `data_topic`, do not crash PDF generation
* return original template and empty topicInputs with warning
* topic items empty => `[["-"]]`
* keep together block must not overlap topic data
* no client-only PDFME UI code
---
# Scope H2.4: Product Type Topic Mapping
Create:
```txt
src/features/crm/quotations/document/server/topic-mapping.ts
```
Implement:
```ts
export const QUOTATION_TOPIC_TYPE_MAPPING = {
crane: {
scope: "scope_of_work",
exclusion: "exclusion_from_scope_of_supply",
payment: "terms_of_payment_upon_progress_of_each_item",
delivery: "delivery_date",
warranty: "warranty"
},
dockdoor: {
scope: "dock_scope_of_work",
exclusion: "exclusion_of_supply_installation",
payment: "payment_conditions",
delivery: "delivery_date",
warranty: "warranty"
},
solarcell: {
scope: "solarcell_scope_of_work",
exclusion: "solarcell_exclusion_of_supply_installation",
payment: "solarcell_payment_conditions",
delivery: "solarcell_delivery",
warranty: "solarcell_warranty"
},
default: {
scope: "scope_of_work",
exclusion: "exclusion_from_scope_of_supply",
payment: "payment_conditions",
delivery: "delivery",
warranty: "warranty"
}
} as const;
```
Add helper:
```ts
getQuotationTopicMapping(quotationType?: string | null)
```
Rules:
* use `quotation.quotationType` or current schema equivalent
* fallback to `default`
* do not hardcode crane only
---
# Scope H2.5: Document Data Builder Update
Update:
```txt
src/features/crm/quotations/document/server/service.ts
```
`buildQuotationDocumentData()` must return:
```ts
{
company,
branch,
customer,
contact,
quotation,
items,
quotationCustomers,
topics,
approval,
signatures,
pdfme
}
```
Add `pdfme` section:
```ts
pdfme: {
quotation_date: string;
quotation_price: string;
exclusion_data: string[][];
topic_inputs?: Record<string, string[][]>;
}
```
Rules:
* keep normalized document data for app preview
* add pdfme-ready compatibility fields
* do not remove existing fields
* topics must include data from:
* crm_quotation_topics
* crm_quotation_topic_items
* topics must be sorted by `sortOrder`
* topic items must be sorted by `sortOrder`
* `exclusion_data` must use product type mapping
* if no exclusion topic exists, return `[["-"]]`
---
# Scope H2.6: Template Input Mapping Update
Update:
```txt
src/features/foundation/document-template/server/service.ts
```
Enhance:
```ts
mapDocumentDataToTemplateInput()
```
Must support:
1. `formatMask = currency_THB`
2. `formatMask = currency_USD`
3. `formatMask = currency_EUR`
4. `formatMask = date_th`
5. `formatMask = date_en`
6. table values already shaped as `string[][]`
7. object array to `string[][]` conversion only when table columns exist
8. fallback value when sourcePath resolves `null` or `undefined`
Important:
If source path points to:
```txt
pdfme.exclusion_data
```
or any `string[][]`
preserve table shape.
Do not convert to string.
---
# Scope H2.7: PDF Generator Integration
Update:
```txt
src/features/foundation/pdf-generator/server/service.ts
```
Before calling pdfme `generate()`:
1. Build documentData
2. Resolve template
3. Resolve mappings
4. Build base templateInput
5. Run dynamic topic engine:
```ts
const { template: topicTemplate, topicInputs } =
buildPdfTopicTemplate(template, documentData.topics);
```
6. Merge inputs:
```ts
const inputs = [
{
...templateInput,
...topicInputs
}
]
```
7. Generate PDF with final template
Rules:
* dynamic topic engine must run for both preview and approved PDF
* if template has no topic/data_topic schemas, continue with base template
* server-only
* no @pdfme/ui usage
---
# Scope H2.8: Template Seed Mapping Fix
Update:
```txt
src/db/seeds/foundation.seed.ts
```
Ensure default pdfme mappings include:
```txt
customer_name -> customer.name
customer_addr -> customer.address
customer_tel -> customer.phone
customer_email -> customer.email
customer_att -> quotation.attention
project_name -> quotation.projectName
site_location -> quotation.projectLocation
quotation_date -> pdfme.quotation_date
quotation_code -> quotation.code
quotation_price -> pdfme.quotation_price
currency -> quotation.currency
exclusion_data -> pdfme.exclusion_data
```
Important:
Do NOT add `topic` and `data_topic` as normal mappings.
They are dynamic schema templates.
Rules:
* idempotent
* update existing sourcePath if wrong
* add missing mapping
* no hardcoded organizationId
---
# Scope H2.9: Current Template Compatibility
Current template contains:
* page 1: `exclusion_data`
* page 2: `topic`
* page 2: `data_topic`
* page 2: signature/closing block
This task must support exactly that.
Rules:
* `exclusion_data` is static mapped table on page 1
* `topic` / `data_topic` are dynamic templates on page 2
* original `topic` / `data_topic` should not remain as placeholder rows if dynamic topics exist
* signature block should remain after dynamic topics
* long topics should add pages if needed
---
# Scope H2.10: Verification
Use or extend existing scripts:
```txt
npm run seed:task-h1-fixture
npm run verify:task-h1
```
Add verification for:
```txt
quotation_price formatted
quotation_date formatted
exclusion_data table is non-empty or "-"
topic dynamic keys exist
topic inputs exist
PDF generation succeeds
PDF does not contain unresolved {exclusion_data}
PDF does not contain unresolved {quotation_price}
PDF does not contain unresolved {item_topic}
```
Optional debug output:
```txt
docs/implementation/pdfme-h2-verification.md
```
Include:
```txt
template placeholder list
generated dynamic topic keys
templateInput sample
topicInputs sample
```
---
# Scope H2.11: Documentation
Update:
```txt
docs/implementation/technical-debt.md
```
Add or confirm:
```txt
Payment/warranty/delivery are rendered through dynamic topic section, not fixed static placeholders
Dynamic extra topics now supported through topic/data_topic engine
Approval signature fields still require dedicated mapping strategy if real approver names/positions are needed
Pixel-perfect legacy comparison not implemented
```
---
# Explicit Non-Scope
Do NOT implement:
```txt
Visual template designer
@pdfme/ui editor
Client-side PDF generation
Full pixel-perfect visual regression
Approval signature real mapping
Changing template design manually unless required
Replacing storage/artifact lifecycle
Report Center
Dashboard changes
```
---
# Output
After completion, summarize:
1. Files Added
2. Files Modified
3. Transform Utilities Added
4. Dynamic Topic Engine Added
5. Product Topic Mapping Added
6. Template Mapping Fixed
7. PDF Generator Integration
8. Verification Result
9. Remaining PDFME Gaps
10. Task K Readiness
---
# Definition of Done
Task H.2 passes when:
* `quotation_price` renders as formatted currency
* `quotation_date` renders as formatted date
* `exclusion_data` maps and renders as `string[][]`
* `topic` and `data_topic` are treated as dynamic templates
* `crm_quotation_topics.topicType` renders as dynamic topic label
* `crm_quotation_topic_items.content` renders as dynamic topic rows
* multiple topics render correctly
* long topic list paginates
* signature/closing block does not overlap topics
* PDF generation succeeds for preview PDF
* PDF generation succeeds for approved PDF
* unresolved placeholders do not appear in generated PDF
* no template designer added