task-h.1 complate
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -87,6 +87,7 @@ web_modules/
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
public/generated/
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
|
||||
24
docs/adr/0009-approved-document-storage-lifecycle.md
Normal file
24
docs/adr/0009-approved-document-storage-lifecycle.md
Normal file
@@ -0,0 +1,24 @@
|
||||
Status:
|
||||
accepted
|
||||
|
||||
Context:
|
||||
Task H introduced server-side PDF generation for quotation preview, download, and approved-document persistence.
|
||||
Task H.1 validated the full path with a real quotation fixture and confirmed that approved artifacts currently persist to local disk under `public/generated/...`.
|
||||
|
||||
Decision:
|
||||
- Keep approved quotation PDFs on local filesystem storage for development and MVP use.
|
||||
- Persist the generated file path on the quotation row via `approved_pdf_url`.
|
||||
- Persist `approved_snapshot` and `approved_template_version_id` alongside the stored file path so the approved artifact can be traced back to the data and template used at generation time.
|
||||
- Treat this storage model as acceptable only for local/dev and early MVP deployment, not as the final production storage architecture.
|
||||
|
||||
Consequences:
|
||||
- Preview/download/generate-approved flows can ship now without blocking on object storage integration.
|
||||
- Approved PDF persistence works with the current dashboard and static file serving model.
|
||||
- Local public storage is simple, but it does not provide immutability guarantees, signed access control, retention policy, or durable storage semantics expected for long-lived production artifacts.
|
||||
|
||||
Future:
|
||||
- introduce an object-storage abstraction shared by generated documents and manual attachments
|
||||
- support signed URLs or another private delivery strategy where required
|
||||
- define immutable approved artifact rules and whether regeneration is allowed
|
||||
- add retention/cleanup policy for superseded or regenerated artifacts
|
||||
- move approved-PDF generation to an async job when post-approval auto-generation is introduced
|
||||
@@ -2,234 +2,137 @@
|
||||
|
||||
## After Task D
|
||||
|
||||
### 1. Database FK
|
||||
|
||||
Customer / Contact / Enquiry ยัง enforce relationship ด้วย application logic
|
||||
### Database foreign keys
|
||||
Customer, contact, and enquiry relationships are still enforced mainly in application logic.
|
||||
|
||||
Future:
|
||||
- add FK constraints after the CRM schema settles
|
||||
- review cascade behavior before enabling hard relational enforcement
|
||||
|
||||
* เพิ่ม FK หลัง schema stable
|
||||
|
||||
### 2. Date Input
|
||||
|
||||
Form ยังใช้ text input แบบ YYYY-MM-DD
|
||||
### Date input UX
|
||||
Several forms still use plain `YYYY-MM-DD` text/date inputs.
|
||||
|
||||
Future:
|
||||
- move to a shared date picker pattern
|
||||
- standardize timezone handling for CRM forms
|
||||
|
||||
* เปลี่ยนเป็น Date Picker กลางของระบบ
|
||||
|
||||
### 3. Customer Related Enquiries Pagination
|
||||
|
||||
หน้า customer detail แสดง related enquiries แล้ว แต่ยังไม่มี pagination
|
||||
### Related enquiry pagination
|
||||
Customer detail can show related enquiries, but that view still has no dedicated pagination strategy.
|
||||
|
||||
Future:
|
||||
- add paging once real data volume grows
|
||||
|
||||
* เพิ่มเมื่อข้อมูลเริ่มเยอะ
|
||||
|
||||
### 4. Follow-up Activity
|
||||
|
||||
Activity timeline ยังพึ่ง audit payload
|
||||
### Follow-up activity timeline
|
||||
Activity history still relies heavily on audit payloads instead of a curated domain timeline.
|
||||
|
||||
Future:
|
||||
|
||||
* ทำ combined activity view/service
|
||||
|
||||
---
|
||||
- build a combined activity view/service for CRM interactions
|
||||
|
||||
## After Task E
|
||||
|
||||
### 5. Mixed Tax Rollup
|
||||
|
||||
Quotation item รองรับ taxRate ต่อรายการแล้ว
|
||||
|
||||
Current:
|
||||
|
||||
* quotation summary ยังใช้ header tax calculation
|
||||
### Mixed tax rollup
|
||||
Quotation items support per-line tax input, but summary calculation still assumes a header-centric tax model.
|
||||
|
||||
Future:
|
||||
- support mixed-tax aggregation from actual item rows
|
||||
- align document totals and reporting with per-line tax logic
|
||||
|
||||
* รองรับ mixed tax per line
|
||||
* aggregate tax จาก item จริง
|
||||
|
||||
Priority:
|
||||
|
||||
* Medium
|
||||
|
||||
### 6. Attachment Storage
|
||||
|
||||
Attachment ยังเป็น metadata-only
|
||||
|
||||
Current:
|
||||
|
||||
* เก็บเฉพาะ metadata
|
||||
### Attachment storage abstraction
|
||||
Quotation attachments started as metadata-only and approved PDFs now write to local disk, but there is still no real storage abstraction.
|
||||
|
||||
Future:
|
||||
- introduce a provider abstraction
|
||||
- support upload/download lifecycle consistently across manual attachments and generated artifacts
|
||||
- define migration strategy for existing local/public paths
|
||||
|
||||
* storage provider abstraction
|
||||
* upload service
|
||||
* download endpoint
|
||||
* permission check
|
||||
|
||||
Priority:
|
||||
|
||||
* High
|
||||
|
||||
### 7. Revision Snapshot
|
||||
|
||||
Revision copy:
|
||||
|
||||
* header
|
||||
* items
|
||||
* customers
|
||||
* topics
|
||||
|
||||
Current:
|
||||
|
||||
* ไม่ copy follow-ups
|
||||
* ไม่ copy attachments
|
||||
### Revision snapshot coverage
|
||||
Revision copy currently focuses on quotation header, items, customers, and topics.
|
||||
|
||||
Future:
|
||||
- decide whether follow-ups and attachments belong in revision snapshots
|
||||
- define immutable vs mutable child-data behavior across revisions
|
||||
|
||||
* กำหนด revision snapshot strategy ให้ชัดเจน
|
||||
|
||||
Priority:
|
||||
|
||||
* Medium
|
||||
|
||||
### 8. CRM Foreign Keys
|
||||
|
||||
Customer
|
||||
Contact
|
||||
Enquiry
|
||||
Quotation
|
||||
|
||||
Current:
|
||||
|
||||
* ใช้ organization validation ใน application layer
|
||||
### CRM foreign keys
|
||||
Core CRM tables still depend on organization-scoped validation in service code.
|
||||
|
||||
Future:
|
||||
|
||||
* เพิ่ม FK เมื่อ schema stable
|
||||
* review cascade strategy
|
||||
|
||||
Priority:
|
||||
|
||||
* High
|
||||
|
||||
### 9. Approval Workflow Pending
|
||||
|
||||
Quotation stabilization in Task E.1 only prepares:
|
||||
|
||||
* approval-ready statuses
|
||||
* revision guard
|
||||
* placeholder submit-for-approval action
|
||||
|
||||
Current:
|
||||
|
||||
* no approval table
|
||||
* no approval requester/approver history
|
||||
* no multi-step approval rule
|
||||
* no approval notification
|
||||
|
||||
Future:
|
||||
|
||||
* introduce approval aggregate in Task F
|
||||
* separate workflow state from quotation commercial state where needed
|
||||
* define approver resolution by branch / amount / role
|
||||
|
||||
Priority:
|
||||
|
||||
* High
|
||||
- add FK constraints after schema churn slows down
|
||||
- review delete and soft-delete interactions first
|
||||
|
||||
## After Task F
|
||||
|
||||
### Approval Permission Sync
|
||||
Existing memberships may not automatically receive new approval permissions.
|
||||
### Permission sync for existing memberships
|
||||
New permissions are defined in code, but old membership rows may still hold stale permission arrays.
|
||||
|
||||
Future:
|
||||
- add permission sync script
|
||||
- admin UI for role/permission management
|
||||
- add a permission sync/backfill command
|
||||
- provide admin tooling for role and permission maintenance
|
||||
|
||||
### Approval Workflow Config UI
|
||||
Approval workflow is seeded but not configurable from UI.
|
||||
### Approval workflow configuration UI
|
||||
Approval workflows are seeded and usable, but still not configurable from the dashboard.
|
||||
|
||||
Future:
|
||||
- workflow setup screen
|
||||
- step editor
|
||||
- approver role mapping
|
||||
- build workflow setup screens
|
||||
- add step editing and role mapping management
|
||||
|
||||
### Approval Notification
|
||||
Approval workflow has no email/line notification yet.
|
||||
### Approval notifications
|
||||
Approval flow has no queue-backed notification channel yet.
|
||||
|
||||
Future:
|
||||
- notification queue
|
||||
- approval reminder
|
||||
- add notification jobs
|
||||
- support reminders and escalation policy
|
||||
|
||||
## After Task G
|
||||
|
||||
### Document Mapping Editor
|
||||
Template mappings are seeded and readable, but not yet manageable from UI.
|
||||
### Document mapping editor
|
||||
Template mappings are seeded and readable, but not manageable from UI.
|
||||
|
||||
Future:
|
||||
- mapping CRUD UI
|
||||
- table column editor
|
||||
- publish/draft template workflow
|
||||
- add mapping CRUD for admins
|
||||
- support table column editing
|
||||
- add draft/publish template workflow
|
||||
|
||||
### pdfme Render Parity
|
||||
Document preview is application-native, not pixel-perfect `pdfme` rendering.
|
||||
### Product-specific template resolution
|
||||
Quotation document template resolution still defaults to a coarse product selection strategy.
|
||||
|
||||
Future:
|
||||
- server-side pdfme render pipeline
|
||||
- preview image generation
|
||||
- schema validation against live template versions
|
||||
- define clearer rules for crane, dock door, solar, and service-specific templates
|
||||
|
||||
### Approved PDF Persistence
|
||||
Approved snapshot preparation exists, but no binary file generation or storage write-back yet.
|
||||
## After Task H / H.1
|
||||
|
||||
### Font compatibility fallback
|
||||
PDF generation now uses vendored Cordia TTF files from `public/fonts`, which fixed the TTC runtime issue, but generator-side fallback normalization still remains as a safety net.
|
||||
|
||||
Future:
|
||||
- approved PDF generator
|
||||
- storage abstraction
|
||||
- immutable approved artifact URL on quotation
|
||||
- keep validating Thai text metrics against designer output
|
||||
- remove fallback normalization only after render parity is proven stable
|
||||
|
||||
## After Task H
|
||||
|
||||
### Font Render Parity
|
||||
`pdfme` generation currently falls back from `cordia/cordiaBold` to standard fonts when TTC-backed runtime rendering is not reliable.
|
||||
### Local filesystem storage
|
||||
Approved PDFs currently persist under `public/generated/quotations/<organizationId>/`.
|
||||
|
||||
Future:
|
||||
- package or vendor the exact Cordia font assets in a generator-friendly format
|
||||
- remove fallback normalization
|
||||
- verify Thai text metrics against the original designer output
|
||||
- replace local public storage for production with object storage
|
||||
- add signed/private delivery where required
|
||||
- define backup and cleanup expectations
|
||||
|
||||
### Local PDF Storage
|
||||
Approved PDFs currently persist to local `public/generated/...` paths.
|
||||
### Approved artifact immutability
|
||||
Approved PDF persistence exists, but the lifecycle is not immutable by policy yet.
|
||||
|
||||
Future:
|
||||
- object storage provider abstraction
|
||||
- signed/private delivery strategy
|
||||
- retention and regeneration policy
|
||||
- define whether regeneration is allowed after approval
|
||||
- version approved artifacts explicitly if regeneration must remain possible
|
||||
- document which snapshot fields are contractual records
|
||||
|
||||
### Post-Approval Automation
|
||||
### Post-approval automation
|
||||
Approved PDF generation is still manual by design.
|
||||
|
||||
Future:
|
||||
- optional auto-generate hook after final approval
|
||||
- retry-safe job queue for PDF generation
|
||||
- add a non-blocking auto-generation hook after final approval
|
||||
- move heavy generation/retry behavior to a background job when needed
|
||||
|
||||
## After Task G
|
||||
### PDF pipeline test coverage
|
||||
Task H.1 added dev fixture and HTTP verification scripts, but there is still no CI-backed E2E coverage for the PDF pipeline.
|
||||
|
||||
### Template Mapping Editor
|
||||
Mapping CRUD UI ยังไม่มี
|
||||
Future: ทำ mapping editor สำหรับ admin
|
||||
|
||||
### Canvas-Accurate PDF Preview
|
||||
Preview ปัจจุบันเป็น native app preview ไม่ใช่ pdfme canvas rendering
|
||||
Future: เพิ่ม pdfme renderer preview
|
||||
|
||||
### Product-Specific Template Rule
|
||||
ตอนนี้ใช้ productType = default
|
||||
Future: define rule สำหรับ crane/dockdoor/solarcell
|
||||
|
||||
### PDF Binary Generation
|
||||
ยังไม่มี generate/download/store PDF
|
||||
Future: Task H
|
||||
Future:
|
||||
- promote fixture verification into automated test coverage
|
||||
- add regression checks for template mappings, storage persistence, and permission enforcement
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"studio": "npx drizzle-kit studio",
|
||||
"seed:super-admin": "node scripts/seed-super-admin.js",
|
||||
"seed:foundation": "node --experimental-strip-types src/db/seeds/foundation.seed.ts",
|
||||
"seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js",
|
||||
"verify:task-h1": "node scripts/verify-task-h1.js",
|
||||
"setup:db": "npm run migrate && npm run seed:super-admin && npm run seed:foundation"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
251
plans/task-d.1.md
Normal file
251
plans/task-d.1.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# Task D.1: Enquiry Assignment + CRM Role Permission Stabilization
|
||||
|
||||
## Goal
|
||||
|
||||
เพิ่มระบบ Assign Sales ให้ Enquiry และปรับ Role/Permission ให้รองรับ ALLA OS CRM ไม่ใช่เฉพาะ IT-CENTER template
|
||||
|
||||
## Must Read
|
||||
|
||||
```txt
|
||||
docs/implementation/task-a-template-audit.md
|
||||
docs/implementation/task-b-template-audit.md
|
||||
docs/implementation/task-b1-foundation-stabilization.md
|
||||
src/features/crm/enquiries/**
|
||||
src/features/crm/customers/**
|
||||
src/features/foundation/**
|
||||
src/lib/auth/rbac.ts
|
||||
src/db/schema.ts
|
||||
src/db/seeds/foundation.seed.ts
|
||||
```
|
||||
|
||||
## Scope 1: Audit Current Enquiry Assignment
|
||||
|
||||
ตรวจสอบว่า `crm_enquiries` มี field เหล่านี้หรือยัง:
|
||||
|
||||
```txt
|
||||
assignedToUserId
|
||||
assignedAt
|
||||
assignedBy
|
||||
assignmentRemark
|
||||
```
|
||||
|
||||
ตรวจสอบ UI:
|
||||
|
||||
```txt
|
||||
Assign Sales
|
||||
Reassign Sales
|
||||
My Enquiries
|
||||
Assigned Enquiries
|
||||
```
|
||||
|
||||
ตรวจสอบ permission:
|
||||
|
||||
```txt
|
||||
crm.enquiry.assign
|
||||
crm.enquiry.reassign
|
||||
```
|
||||
|
||||
ถ้าไม่มี ให้ implement ตาม scope ถัดไป
|
||||
|
||||
## Scope 2: Schema
|
||||
|
||||
เพิ่ม field ใน `crm_enquiries`:
|
||||
|
||||
```txt
|
||||
assignedToUserId
|
||||
assignedAt
|
||||
assignedBy
|
||||
assignmentRemark
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* assignedToUserId อ้างถึง user ใน organization เดียวกัน
|
||||
* assignedBy คือ current user
|
||||
* assignedAt คือเวลาที่ assign ล่าสุด
|
||||
* assignmentRemark optional
|
||||
* soft delete ไม่เกี่ยวกับ assignment
|
||||
|
||||
## Scope 3: API
|
||||
|
||||
เพิ่ม route:
|
||||
|
||||
```txt
|
||||
POST /api/crm/enquiries/[id]/assign
|
||||
POST /api/crm/enquiries/[id]/reassign
|
||||
```
|
||||
|
||||
Payload:
|
||||
|
||||
```ts
|
||||
{
|
||||
assignedToUserId: string
|
||||
remark?: string
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* ต้อง require organization access
|
||||
* ต้อง validate assignee อยู่ใน organization เดียวกัน
|
||||
* assign/reassign ต้อง audit
|
||||
* assign ครั้งแรกใช้ action = assign
|
||||
* เปลี่ยนคนใช้ action = reassign
|
||||
|
||||
## Scope 4: Permission
|
||||
|
||||
เพิ่ม permission:
|
||||
|
||||
```txt
|
||||
crm.enquiry.assign
|
||||
crm.enquiry.reassign
|
||||
```
|
||||
|
||||
Role recommendation:
|
||||
|
||||
```txt
|
||||
super_admin: all permissions
|
||||
organization_admin: all crm permissions
|
||||
sales_manager: read/create/update/assign/reassign enquiry
|
||||
sales: read/create/update own/assigned enquiry
|
||||
sales_support: read/create/update enquiry
|
||||
viewer: read only
|
||||
```
|
||||
|
||||
อย่า hardcode เฉพาะ IT-CENTER roles
|
||||
|
||||
## Scope 5: UI
|
||||
|
||||
ใน Enquiry Detail เพิ่ม action:
|
||||
|
||||
```txt
|
||||
Assign Sales
|
||||
Reassign Sales
|
||||
```
|
||||
|
||||
แสดงเมื่อ:
|
||||
|
||||
```txt
|
||||
user has crm.enquiry.assign หรือ crm.enquiry.reassign
|
||||
```
|
||||
|
||||
แสดงข้อมูล:
|
||||
|
||||
```txt
|
||||
Assigned Sales
|
||||
Assigned At
|
||||
Assigned By
|
||||
Assignment Remark
|
||||
```
|
||||
|
||||
ใน Enquiry List เพิ่ม column:
|
||||
|
||||
```txt
|
||||
Assigned Sales
|
||||
```
|
||||
|
||||
## Scope 6: Form
|
||||
|
||||
ใช้ Sheet/Dialog pattern
|
||||
|
||||
Field:
|
||||
|
||||
```txt
|
||||
Sales User
|
||||
Remark
|
||||
```
|
||||
|
||||
Sales User dropdown ต้องดึงจาก users/memberships ใน organization ปัจจุบัน
|
||||
|
||||
ห้าม hardcode user
|
||||
|
||||
## Scope 7: Audit
|
||||
|
||||
Audit entity:
|
||||
|
||||
```txt
|
||||
crm_enquiry
|
||||
```
|
||||
|
||||
Actions:
|
||||
|
||||
```txt
|
||||
assign
|
||||
reassign
|
||||
```
|
||||
|
||||
beforeData:
|
||||
|
||||
```txt
|
||||
oldAssignedToUserId
|
||||
```
|
||||
|
||||
afterData:
|
||||
|
||||
```txt
|
||||
newAssignedToUserId
|
||||
remark
|
||||
```
|
||||
|
||||
## Scope 8: Membership/Permission Stabilization
|
||||
|
||||
ตรวจ Workspace Membership UI ว่ายังเป็น IT-CENTER template อยู่หรือไม่
|
||||
|
||||
ถ้ายังเป็น IT-CENTER ให้เพิ่ม CRM permissions เข้าไปก่อน โดยไม่ลบของเดิมทันที
|
||||
|
||||
เพิ่ม permission groups:
|
||||
|
||||
```txt
|
||||
crm.customer.*
|
||||
crm.contact.*
|
||||
crm.enquiry.*
|
||||
crm.quotation.*
|
||||
crm.approval.*
|
||||
crm.document_template.*
|
||||
```
|
||||
|
||||
อย่างน้อยต้องมี:
|
||||
|
||||
```txt
|
||||
crm.enquiry.read
|
||||
crm.enquiry.create
|
||||
crm.enquiry.update
|
||||
crm.enquiry.delete
|
||||
crm.enquiry.assign
|
||||
crm.enquiry.reassign
|
||||
```
|
||||
|
||||
## ห้ามทำ
|
||||
|
||||
```txt
|
||||
Quotation assignment
|
||||
Approval assignment
|
||||
Notification
|
||||
Dashboard KPI
|
||||
Report
|
||||
Major RBAC rewrite
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
สรุป:
|
||||
|
||||
1. Files Added
|
||||
2. Files Modified
|
||||
3. Schema Added
|
||||
4. API Routes Added
|
||||
5. Permissions Added
|
||||
6. UI Added
|
||||
7. Audit Integration
|
||||
8. Remaining Risks
|
||||
|
||||
## Definition of Done
|
||||
|
||||
* Enquiry assign sales ได้
|
||||
* Reassign sales ได้
|
||||
* Enquiry list แสดง assigned sales
|
||||
* Enquiry detail แสดง assigned sales
|
||||
* Permission guard ทำงาน
|
||||
* super_admin เห็นปุ่ม assign
|
||||
* audit log ถูกสร้าง
|
||||
* ไม่กระทบ Customer/Quotation/Approval
|
||||
302
plans/task-h.1.md
Normal file
302
plans/task-h.1.md
Normal file
@@ -0,0 +1,302 @@
|
||||
# Task H.1: PDF Generation Stabilization
|
||||
|
||||
## Goal
|
||||
|
||||
Stabilize Task H PDF generation before starting Task I.
|
||||
|
||||
Task H already added:
|
||||
|
||||
* pdfme server-side generation
|
||||
* preview/download PDF APIs
|
||||
* approved PDF generation
|
||||
* approved snapshot persistence
|
||||
* approved PDF attachment metadata
|
||||
* quotation detail PDF actions
|
||||
|
||||
Task H.1 focuses on validation, hardening, and documentation.
|
||||
|
||||
---
|
||||
|
||||
## Must Read
|
||||
|
||||
```txt
|
||||
docs/implementation/technical-debt.md
|
||||
docs/adr/0008-attachment-storage-strategy.md
|
||||
|
||||
src/features/foundation/pdf-generator/**
|
||||
src/features/foundation/document-template/**
|
||||
src/features/crm/quotations/document/**
|
||||
src/features/crm/quotations/**
|
||||
src/features/foundation/approval/**
|
||||
src/app/api/crm/quotations/[id]/pdf-preview/route.ts
|
||||
src/app/api/crm/quotations/[id]/pdf-download/route.ts
|
||||
src/app/api/crm/quotations/[id]/approved-pdf/route.ts
|
||||
src/db/schema.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scope 1: Smoke Test With Real Quotation
|
||||
|
||||
Create or identify one real quotation record in dev database.
|
||||
|
||||
The record must include:
|
||||
|
||||
```txt
|
||||
customer
|
||||
contact
|
||||
enquiry optional
|
||||
at least one item
|
||||
quotation customer role
|
||||
scope topic
|
||||
exclusion topic
|
||||
payment topic
|
||||
approved status or approval flow completed
|
||||
```
|
||||
|
||||
Then verify:
|
||||
|
||||
```txt
|
||||
Preview PDF
|
||||
Download PDF
|
||||
Generate Approved PDF
|
||||
View Approved PDF
|
||||
```
|
||||
|
||||
If no real record exists, create a minimal seed/dev fixture.
|
||||
|
||||
Rules:
|
||||
|
||||
* dev fixture must be organization-scoped
|
||||
* do not hardcode organizationId
|
||||
* do not pollute production seed
|
||||
* place fixture under dev-only seed or clearly named script
|
||||
|
||||
---
|
||||
|
||||
## Scope 2: PDF Output Validation
|
||||
|
||||
Check generated PDF for:
|
||||
|
||||
```txt
|
||||
file opens correctly
|
||||
page count is reasonable
|
||||
customer block appears
|
||||
quotation code appears
|
||||
item table appears
|
||||
total amount appears
|
||||
scope/exclusion/payment appears
|
||||
approval block appears
|
||||
signature placeholder appears
|
||||
```
|
||||
|
||||
If layout breaks:
|
||||
|
||||
* adjust mapping normalization
|
||||
* adjust template input preparation
|
||||
* do not rewrite template designer
|
||||
* do not hardcode quotation-specific hacks into generic pdf generator
|
||||
|
||||
---
|
||||
|
||||
## Scope 3: Approved PDF API Validation
|
||||
|
||||
Validate:
|
||||
|
||||
```txt
|
||||
GET /api/crm/quotations/[id]/pdf-preview
|
||||
GET /api/crm/quotations/[id]/pdf-download
|
||||
POST /api/crm/quotations/[id]/approved-pdf
|
||||
GET /api/crm/quotations/[id]/approved-pdf
|
||||
```
|
||||
|
||||
Expected behavior:
|
||||
|
||||
* preview returns inline PDF
|
||||
* download returns attachment PDF
|
||||
* approved-pdf POST only works for approved quotation
|
||||
* approved-pdf GET returns stored file if available
|
||||
* non-approved quotation cannot generate approved PDF
|
||||
|
||||
---
|
||||
|
||||
## Scope 4: Persistence Validation
|
||||
|
||||
After approved PDF generation verify:
|
||||
|
||||
```txt
|
||||
quotation.approvedPdfUrl populated
|
||||
quotation.approvedSnapshot populated
|
||||
quotation.approvedTemplateVersionId populated
|
||||
quotation attachment metadata created or updated
|
||||
audit log generated
|
||||
file exists in public/generated/quotations/<organizationId>/
|
||||
```
|
||||
|
||||
If any part is missing, fix only the persistence pipeline.
|
||||
|
||||
---
|
||||
|
||||
## Scope 5: Permission Validation
|
||||
|
||||
Verify permissions:
|
||||
|
||||
```txt
|
||||
crm.quotation.pdf.preview
|
||||
crm.quotation.pdf.download
|
||||
crm.quotation.pdf.generate_approved
|
||||
```
|
||||
|
||||
Check behavior for:
|
||||
|
||||
```txt
|
||||
super_admin
|
||||
admin
|
||||
regular user with permission
|
||||
regular user without permission
|
||||
```
|
||||
|
||||
Do not rewrite RBAC model.
|
||||
|
||||
---
|
||||
|
||||
## Scope 6: Error Handling
|
||||
|
||||
Improve user-safe/server-safe errors for:
|
||||
|
||||
```txt
|
||||
template not found
|
||||
template version not found
|
||||
mapping missing
|
||||
quotation not approved
|
||||
generated file missing
|
||||
pdfme generation error
|
||||
local storage write error
|
||||
permission denied
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* API must return meaningful status codes
|
||||
* UI should show readable toast/message
|
||||
* do not expose stack trace to client
|
||||
|
||||
---
|
||||
|
||||
## Scope 7: Documentation / ADR
|
||||
|
||||
Update:
|
||||
|
||||
```txt
|
||||
docs/implementation/technical-debt.md
|
||||
```
|
||||
|
||||
Confirm or add:
|
||||
|
||||
```txt
|
||||
pdfme font fallback workaround
|
||||
local filesystem storage limitation
|
||||
missing object storage abstraction
|
||||
approved PDF lifecycle not immutable yet
|
||||
no full E2E test in CI yet
|
||||
```
|
||||
|
||||
Create ADR if not exists:
|
||||
|
||||
```txt
|
||||
docs/adr/0009-approved-document-storage-lifecycle.md
|
||||
```
|
||||
|
||||
Include:
|
||||
|
||||
```txt
|
||||
Current:
|
||||
local public/generated storage
|
||||
|
||||
Decision:
|
||||
acceptable for development/MVP only
|
||||
|
||||
Future:
|
||||
object storage abstraction
|
||||
signed URL
|
||||
immutable approved artifacts
|
||||
retention policy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scope 8: Optional Small Fix
|
||||
|
||||
If approval finalization already has a clean hook point, add a non-blocking TODO or optional manual trigger note for:
|
||||
|
||||
```txt
|
||||
auto-generate approved PDF after final approval
|
||||
```
|
||||
|
||||
Do not force automatic generation in Task H.1 if it risks breaking approval flow.
|
||||
|
||||
---
|
||||
|
||||
## ห้ามทำ
|
||||
|
||||
```txt
|
||||
object storage production
|
||||
signed URL service
|
||||
template designer
|
||||
mapping editor
|
||||
email notification
|
||||
report
|
||||
dashboard KPI
|
||||
large refactor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output
|
||||
|
||||
After completion, summarize:
|
||||
|
||||
1. Files Added
|
||||
2. Files Modified
|
||||
3. Smoke Test Record Used
|
||||
4. PDF APIs Verified
|
||||
5. Persistence Verified
|
||||
6. Permission Verified
|
||||
7. Error Handling Improved
|
||||
8. ADR / Technical Debt Updated
|
||||
9. Remaining Risks
|
||||
10. Task I Readiness
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done
|
||||
|
||||
Task H.1 passes when:
|
||||
|
||||
✔ PDF preview works with a real quotation
|
||||
|
||||
✔ PDF download works with a real quotation
|
||||
|
||||
✔ Approved PDF generation works for approved quotation
|
||||
|
||||
✔ Approved PDF GET returns stored PDF
|
||||
|
||||
✔ Non-approved quotation cannot generate approved PDF
|
||||
|
||||
✔ approvedSnapshot is saved
|
||||
|
||||
✔ approvedPdfUrl is saved
|
||||
|
||||
✔ approvedTemplateVersionId is saved
|
||||
|
||||
✔ attachment metadata is created/updated
|
||||
|
||||
✔ audit log exists for approved PDF generation
|
||||
|
||||
✔ permissions are enforced
|
||||
|
||||
✔ errors are user-safe
|
||||
|
||||
✔ storage lifecycle ADR exists
|
||||
|
||||
✔ ready for Task I
|
||||
955
scripts/seed-task-h1-fixture.js
Normal file
955
scripts/seed-task-h1-fixture.js
Normal file
@@ -0,0 +1,955 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { hash } = require('bcryptjs');
|
||||
const postgres = require('postgres');
|
||||
|
||||
const TASK_H1_PASSWORD = 'TaskH1Pass!2026';
|
||||
const FIXTURE_CODES = {
|
||||
customer: 'CUS-TASK-H1',
|
||||
approvedQuotation: 'QT-TASK-H1-APPROVED',
|
||||
draftQuotation: 'QT-TASK-H1-DRAFT'
|
||||
};
|
||||
|
||||
const FIXTURE_USERS = {
|
||||
admin: 'task-h1-admin@local.test',
|
||||
pdfUser: 'task-h1-pdf-user@local.test',
|
||||
viewer: 'task-h1-viewer@local.test'
|
||||
};
|
||||
|
||||
const PDF_PERMISSIONS = [
|
||||
'crm.quotation.read',
|
||||
'crm.quotation.document.preview',
|
||||
'crm.quotation.pdf.preview',
|
||||
'crm.quotation.pdf.download',
|
||||
'crm.quotation.pdf.generate_approved'
|
||||
];
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const separatorIndex = line.indexOf('=');
|
||||
|
||||
if (separatorIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
let value = line.slice(separatorIndex + 1).trim();
|
||||
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
if (!(key in process.env)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadLocalEnv() {
|
||||
loadEnvFile(path.resolve(process.cwd(), '.env.local'));
|
||||
loadEnvFile(path.resolve(process.cwd(), '.env'));
|
||||
}
|
||||
|
||||
function requireEnv(name) {
|
||||
const value = process.env[name]?.trim();
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
async function resolveOptionId(sql, organizationId, category, code) {
|
||||
const [row] = await sql`
|
||||
select id
|
||||
from ms_options
|
||||
where organization_id = ${organizationId}
|
||||
and category = ${category}
|
||||
and code = ${code}
|
||||
and is_active = true
|
||||
and deleted_at is null
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if (!row) {
|
||||
throw new Error(`Missing option ${category}:${code}`);
|
||||
}
|
||||
|
||||
return row.id;
|
||||
}
|
||||
|
||||
async function resolveApprovalWorkflow(sql, organizationId) {
|
||||
const [workflow] = await sql`
|
||||
select id
|
||||
from crm_approval_workflows
|
||||
where organization_id = ${organizationId}
|
||||
and code = 'quotation_standard_approval'
|
||||
and is_active = true
|
||||
and deleted_at is null
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if (!workflow) {
|
||||
throw new Error('Missing quotation_standard_approval workflow');
|
||||
}
|
||||
|
||||
const steps = await sql`
|
||||
select step_number as "stepNumber", role_code as "roleCode", role_name as "roleName"
|
||||
from crm_approval_steps
|
||||
where organization_id = ${organizationId}
|
||||
and workflow_id = ${workflow.id}
|
||||
and deleted_at is null
|
||||
order by step_number asc
|
||||
`;
|
||||
|
||||
if (!steps.length) {
|
||||
throw new Error('Approval workflow has no steps');
|
||||
}
|
||||
|
||||
return {
|
||||
id: workflow.id,
|
||||
steps
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureUser(sql, organization, creatorId, email, name, membership) {
|
||||
const passwordHash = await hash(TASK_H1_PASSWORD, 10);
|
||||
const existing =
|
||||
(
|
||||
await sql`
|
||||
select id
|
||||
from users
|
||||
where email = ${email}
|
||||
limit 1
|
||||
`
|
||||
)[0] ?? null;
|
||||
const userId = existing?.id ?? crypto.randomUUID();
|
||||
|
||||
if (existing) {
|
||||
await sql`
|
||||
update users
|
||||
set
|
||||
name = ${name},
|
||||
password_hash = ${passwordHash},
|
||||
system_role = 'user',
|
||||
active_organization_id = ${organization.id},
|
||||
updated_at = now()
|
||||
where id = ${userId}
|
||||
`;
|
||||
} else {
|
||||
await sql`
|
||||
insert into users (
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
password_hash,
|
||||
system_role,
|
||||
active_organization_id
|
||||
) values (
|
||||
${userId},
|
||||
${name},
|
||||
${email},
|
||||
${passwordHash},
|
||||
'user',
|
||||
${organization.id}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
const membershipRow =
|
||||
(
|
||||
await sql`
|
||||
select id
|
||||
from memberships
|
||||
where user_id = ${userId}
|
||||
and organization_id = ${organization.id}
|
||||
limit 1
|
||||
`
|
||||
)[0] ?? null;
|
||||
|
||||
if (membershipRow) {
|
||||
await sql`
|
||||
update memberships
|
||||
set
|
||||
role = ${membership.role},
|
||||
business_role = ${membership.businessRole},
|
||||
permissions = ${membership.permissions},
|
||||
updated_at = now()
|
||||
where id = ${membershipRow.id}
|
||||
`;
|
||||
} else {
|
||||
await sql`
|
||||
insert into memberships (
|
||||
id,
|
||||
user_id,
|
||||
organization_id,
|
||||
role,
|
||||
business_role,
|
||||
permissions
|
||||
) values (
|
||||
${crypto.randomUUID()},
|
||||
${userId},
|
||||
${organization.id},
|
||||
${membership.role},
|
||||
${membership.businessRole},
|
||||
${membership.permissions}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
email
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureCustomerAndContact(sql, organization, creatorId, optionIds) {
|
||||
const existingCustomer =
|
||||
(
|
||||
await sql`
|
||||
select id
|
||||
from crm_customers
|
||||
where organization_id = ${organization.id}
|
||||
and code = ${FIXTURE_CODES.customer}
|
||||
limit 1
|
||||
`
|
||||
)[0] ?? null;
|
||||
const customerId = existingCustomer?.id ?? crypto.randomUUID();
|
||||
|
||||
if (existingCustomer) {
|
||||
await sql`
|
||||
update crm_customers
|
||||
set
|
||||
branch_id = ${optionIds.branchId},
|
||||
name = 'Task H.1 Demo Customer',
|
||||
abbr = 'TASKH1',
|
||||
tax_id = '0105559001122',
|
||||
customer_type = ${optionIds.customerTypeCompanyId},
|
||||
customer_status = ${optionIds.customerStatusActiveId},
|
||||
address = '99/9 Rama IX Road, Bangkok 10310',
|
||||
phone = '02-000-1000',
|
||||
email = 'demo-customer@local.test',
|
||||
lead_channel = ${optionIds.leadChannelSalesId},
|
||||
customer_group = ${optionIds.customerGroupStandardId},
|
||||
notes = 'Task H.1 smoke-test customer',
|
||||
is_active = true,
|
||||
deleted_at = null,
|
||||
updated_at = now(),
|
||||
updated_by = ${creatorId}
|
||||
where id = ${customerId}
|
||||
`;
|
||||
} else {
|
||||
await sql`
|
||||
insert into crm_customers (
|
||||
id,
|
||||
organization_id,
|
||||
branch_id,
|
||||
code,
|
||||
name,
|
||||
abbr,
|
||||
tax_id,
|
||||
customer_type,
|
||||
customer_status,
|
||||
address,
|
||||
phone,
|
||||
email,
|
||||
lead_channel,
|
||||
customer_group,
|
||||
notes,
|
||||
is_active,
|
||||
created_by,
|
||||
updated_by
|
||||
) values (
|
||||
${customerId},
|
||||
${organization.id},
|
||||
${optionIds.branchId},
|
||||
${FIXTURE_CODES.customer},
|
||||
'Task H.1 Demo Customer',
|
||||
'TASKH1',
|
||||
'0105559001122',
|
||||
${optionIds.customerTypeCompanyId},
|
||||
${optionIds.customerStatusActiveId},
|
||||
'99/9 Rama IX Road, Bangkok 10310',
|
||||
'02-000-1000',
|
||||
'demo-customer@local.test',
|
||||
${optionIds.leadChannelSalesId},
|
||||
${optionIds.customerGroupStandardId},
|
||||
'Task H.1 smoke-test customer',
|
||||
true,
|
||||
${creatorId},
|
||||
${creatorId}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
const existingContact =
|
||||
(
|
||||
await sql`
|
||||
select id
|
||||
from crm_customer_contacts
|
||||
where organization_id = ${organization.id}
|
||||
and customer_id = ${customerId}
|
||||
and email = 'demo-contact@local.test'
|
||||
limit 1
|
||||
`
|
||||
)[0] ?? null;
|
||||
const contactId = existingContact?.id ?? crypto.randomUUID();
|
||||
|
||||
if (existingContact) {
|
||||
await sql`
|
||||
update crm_customer_contacts
|
||||
set
|
||||
name = 'Task H.1 Contact',
|
||||
position = 'Procurement Manager',
|
||||
department = 'Procurement',
|
||||
phone = '02-000-1001',
|
||||
mobile = '081-000-1001',
|
||||
email = 'demo-contact@local.test',
|
||||
is_primary = true,
|
||||
notes = 'Primary smoke-test contact',
|
||||
is_active = true,
|
||||
deleted_at = null,
|
||||
updated_at = now(),
|
||||
updated_by = ${creatorId}
|
||||
where id = ${contactId}
|
||||
`;
|
||||
} else {
|
||||
await sql`
|
||||
insert into crm_customer_contacts (
|
||||
id,
|
||||
organization_id,
|
||||
customer_id,
|
||||
name,
|
||||
position,
|
||||
department,
|
||||
phone,
|
||||
mobile,
|
||||
email,
|
||||
is_primary,
|
||||
notes,
|
||||
is_active,
|
||||
created_by,
|
||||
updated_by
|
||||
) values (
|
||||
${contactId},
|
||||
${organization.id},
|
||||
${customerId},
|
||||
'Task H.1 Contact',
|
||||
'Procurement Manager',
|
||||
'Procurement',
|
||||
'02-000-1001',
|
||||
'081-000-1001',
|
||||
'demo-contact@local.test',
|
||||
true,
|
||||
'Primary smoke-test contact',
|
||||
true,
|
||||
${creatorId},
|
||||
${creatorId}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
return {
|
||||
customerId,
|
||||
contactId
|
||||
};
|
||||
}
|
||||
|
||||
async function upsertQuotation(sql, args) {
|
||||
const {
|
||||
organization,
|
||||
creatorId,
|
||||
workflow,
|
||||
optionIds,
|
||||
customerId,
|
||||
contactId,
|
||||
code,
|
||||
approved
|
||||
} = args;
|
||||
const existing =
|
||||
(
|
||||
await sql`
|
||||
select id
|
||||
from crm_quotations
|
||||
where organization_id = ${organization.id}
|
||||
and code = ${code}
|
||||
limit 1
|
||||
`
|
||||
)[0] ?? null;
|
||||
const quotationId = existing?.id ?? crypto.randomUUID();
|
||||
const statusId = approved ? optionIds.quotationStatusApprovedId : optionIds.quotationStatusDraftId;
|
||||
const approvalNow = approved ? new Date().toISOString() : null;
|
||||
const notes = approved
|
||||
? 'Approved quotation fixture for Task H.1 PDF stabilization.'
|
||||
: 'Draft quotation fixture for Task H.1 negative API validation.';
|
||||
|
||||
if (existing) {
|
||||
await sql`
|
||||
update crm_quotations
|
||||
set
|
||||
branch_id = ${optionIds.branchId},
|
||||
enquiry_id = null,
|
||||
customer_id = ${customerId},
|
||||
contact_id = ${contactId},
|
||||
quotation_date = ${'2026-06-16T00:00:00.000Z'},
|
||||
valid_until = ${'2026-07-16T00:00:00.000Z'},
|
||||
quotation_type = ${optionIds.quotationTypeServiceId},
|
||||
project_name = ${approved ? 'Task H.1 Approved PDF Fixture' : 'Task H.1 Draft PDF Fixture'},
|
||||
project_location = 'Bangkok HQ',
|
||||
attention = 'Task H.1 Contact',
|
||||
reference = ${approved ? 'TASK-H1-APPROVED' : 'TASK-H1-DRAFT'},
|
||||
notes = ${notes},
|
||||
status = ${statusId},
|
||||
revision = 0,
|
||||
parent_quotation_id = null,
|
||||
revision_remark = null,
|
||||
currency = ${optionIds.currencyThbId},
|
||||
exchange_rate = 1,
|
||||
subtotal = 125000,
|
||||
discount = 5000,
|
||||
discount_type = ${optionIds.discountPercentageId},
|
||||
tax_rate = 7,
|
||||
tax_amount = 8400,
|
||||
total_amount = 128400,
|
||||
chance_percent = 90,
|
||||
is_hot_project = true,
|
||||
competitor = 'Task H.1 Competitor',
|
||||
salesman_id = ${creatorId},
|
||||
is_sent = ${approved},
|
||||
sent_at = ${approved ? '2026-06-16T01:00:00.000Z' : null},
|
||||
sent_via = ${optionIds.sentViaEmailId},
|
||||
approved_at = ${approvalNow},
|
||||
approved_pdf_url = null,
|
||||
approved_snapshot = null,
|
||||
approved_template_version_id = null,
|
||||
accepted_at = null,
|
||||
rejected_at = null,
|
||||
rejection_reason = null,
|
||||
is_active = true,
|
||||
deleted_at = null,
|
||||
updated_at = now(),
|
||||
updated_by = ${creatorId}
|
||||
where id = ${quotationId}
|
||||
`;
|
||||
} else {
|
||||
await sql`
|
||||
insert into crm_quotations (
|
||||
id,
|
||||
organization_id,
|
||||
branch_id,
|
||||
code,
|
||||
enquiry_id,
|
||||
customer_id,
|
||||
contact_id,
|
||||
quotation_date,
|
||||
valid_until,
|
||||
quotation_type,
|
||||
project_name,
|
||||
project_location,
|
||||
attention,
|
||||
reference,
|
||||
notes,
|
||||
status,
|
||||
revision,
|
||||
parent_quotation_id,
|
||||
revision_remark,
|
||||
currency,
|
||||
exchange_rate,
|
||||
subtotal,
|
||||
discount,
|
||||
discount_type,
|
||||
tax_rate,
|
||||
tax_amount,
|
||||
total_amount,
|
||||
chance_percent,
|
||||
is_hot_project,
|
||||
competitor,
|
||||
salesman_id,
|
||||
is_sent,
|
||||
sent_at,
|
||||
sent_via,
|
||||
approved_at,
|
||||
approved_pdf_url,
|
||||
approved_snapshot,
|
||||
approved_template_version_id,
|
||||
accepted_at,
|
||||
rejected_at,
|
||||
rejection_reason,
|
||||
is_active,
|
||||
created_by,
|
||||
updated_by
|
||||
) values (
|
||||
${quotationId},
|
||||
${organization.id},
|
||||
${optionIds.branchId},
|
||||
${code},
|
||||
null,
|
||||
${customerId},
|
||||
${contactId},
|
||||
${'2026-06-16T00:00:00.000Z'},
|
||||
${'2026-07-16T00:00:00.000Z'},
|
||||
${optionIds.quotationTypeServiceId},
|
||||
${approved ? 'Task H.1 Approved PDF Fixture' : 'Task H.1 Draft PDF Fixture'},
|
||||
'Bangkok HQ',
|
||||
'Task H.1 Contact',
|
||||
${approved ? 'TASK-H1-APPROVED' : 'TASK-H1-DRAFT'},
|
||||
${notes},
|
||||
${statusId},
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
${optionIds.currencyThbId},
|
||||
1,
|
||||
125000,
|
||||
5000,
|
||||
${optionIds.discountPercentageId},
|
||||
7,
|
||||
8400,
|
||||
128400,
|
||||
90,
|
||||
true,
|
||||
'Task H.1 Competitor',
|
||||
${creatorId},
|
||||
${approved},
|
||||
${approved ? '2026-06-16T01:00:00.000Z' : null},
|
||||
${optionIds.sentViaEmailId},
|
||||
${approvalNow},
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
${creatorId},
|
||||
${creatorId}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
await sql`delete from crm_quotation_attachments where quotation_id = ${quotationId}`;
|
||||
await sql`
|
||||
delete from crm_approval_actions
|
||||
where approval_request_id in (
|
||||
select id
|
||||
from crm_approval_requests
|
||||
where entity_type = 'quotation'
|
||||
and entity_id = ${quotationId}
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
delete from crm_approval_requests
|
||||
where entity_type = 'quotation'
|
||||
and entity_id = ${quotationId}
|
||||
`;
|
||||
await sql`delete from crm_quotation_customers where quotation_id = ${quotationId}`;
|
||||
await sql`
|
||||
delete from crm_quotation_topic_items
|
||||
where topic_id in (
|
||||
select id
|
||||
from crm_quotation_topics
|
||||
where quotation_id = ${quotationId}
|
||||
)
|
||||
`;
|
||||
await sql`delete from crm_quotation_topics where quotation_id = ${quotationId}`;
|
||||
await sql`delete from crm_quotation_items where quotation_id = ${quotationId}`;
|
||||
|
||||
await sql`
|
||||
insert into crm_quotation_items (
|
||||
id,
|
||||
organization_id,
|
||||
quotation_id,
|
||||
item_number,
|
||||
product_type,
|
||||
description,
|
||||
quantity,
|
||||
unit,
|
||||
unit_price,
|
||||
discount,
|
||||
discount_type,
|
||||
tax_rate,
|
||||
total_price,
|
||||
notes,
|
||||
sort_order
|
||||
) values (
|
||||
${crypto.randomUUID()},
|
||||
${organization.id},
|
||||
${quotationId},
|
||||
1,
|
||||
${optionIds.productTypeCraneId},
|
||||
'1 set overhead crane with installation and commissioning',
|
||||
1,
|
||||
${optionIds.unitSetId},
|
||||
125000,
|
||||
5000,
|
||||
${optionIds.discountPercentageId},
|
||||
7,
|
||||
120000,
|
||||
'Smoke-test commercial line',
|
||||
1
|
||||
)
|
||||
`;
|
||||
|
||||
await sql`
|
||||
insert into crm_quotation_customers (
|
||||
id,
|
||||
organization_id,
|
||||
quotation_id,
|
||||
customer_id,
|
||||
role,
|
||||
is_primary
|
||||
) values (
|
||||
${crypto.randomUUID()},
|
||||
${organization.id},
|
||||
${quotationId},
|
||||
${customerId},
|
||||
${optionIds.customerRoleOwnerId},
|
||||
true
|
||||
)
|
||||
`;
|
||||
|
||||
const topics = [
|
||||
{
|
||||
typeId: optionIds.topicTypeScopeId,
|
||||
title: 'Scope of work',
|
||||
items: ['Supply crane equipment', 'Install and test the system']
|
||||
},
|
||||
{
|
||||
typeId: optionIds.topicTypeExclusionId,
|
||||
title: 'Exclusions',
|
||||
items: ['Civil work by customer', 'Power feeder by customer']
|
||||
},
|
||||
{
|
||||
typeId: optionIds.topicTypePaymentId,
|
||||
title: 'Payment terms',
|
||||
items: ['50% upon PO', '40% before delivery', '10% after commissioning']
|
||||
}
|
||||
];
|
||||
|
||||
for (const [topicIndex, topic] of topics.entries()) {
|
||||
const topicId = crypto.randomUUID();
|
||||
|
||||
await sql`
|
||||
insert into crm_quotation_topics (
|
||||
id,
|
||||
organization_id,
|
||||
quotation_id,
|
||||
topic_type,
|
||||
title,
|
||||
sort_order
|
||||
) values (
|
||||
${topicId},
|
||||
${organization.id},
|
||||
${quotationId},
|
||||
${topic.typeId},
|
||||
${topic.title},
|
||||
${topicIndex + 1}
|
||||
)
|
||||
`;
|
||||
|
||||
for (const [itemIndex, content] of topic.items.entries()) {
|
||||
await sql`
|
||||
insert into crm_quotation_topic_items (
|
||||
id,
|
||||
organization_id,
|
||||
topic_id,
|
||||
content,
|
||||
sort_order
|
||||
) values (
|
||||
${crypto.randomUUID()},
|
||||
${organization.id},
|
||||
${topicId},
|
||||
${content},
|
||||
${itemIndex + 1}
|
||||
)
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (approved) {
|
||||
const approvalRequestId = crypto.randomUUID();
|
||||
|
||||
await sql`
|
||||
insert into crm_approval_requests (
|
||||
id,
|
||||
organization_id,
|
||||
workflow_id,
|
||||
entity_type,
|
||||
entity_id,
|
||||
current_step,
|
||||
status,
|
||||
requested_by,
|
||||
requested_at,
|
||||
completed_at
|
||||
) values (
|
||||
${approvalRequestId},
|
||||
${organization.id},
|
||||
${workflow.id},
|
||||
'quotation',
|
||||
${quotationId},
|
||||
${workflow.steps[workflow.steps.length - 1].stepNumber},
|
||||
'approved',
|
||||
${creatorId},
|
||||
${'2026-06-16T00:30:00.000Z'},
|
||||
${'2026-06-16T01:30:00.000Z'}
|
||||
)
|
||||
`;
|
||||
|
||||
await sql`
|
||||
insert into crm_approval_actions (
|
||||
id,
|
||||
organization_id,
|
||||
approval_request_id,
|
||||
step_number,
|
||||
action,
|
||||
remark,
|
||||
acted_by,
|
||||
acted_at
|
||||
) values (
|
||||
${crypto.randomUUID()},
|
||||
${organization.id},
|
||||
${approvalRequestId},
|
||||
0,
|
||||
'submit',
|
||||
'Task H.1 fixture submission',
|
||||
${creatorId},
|
||||
${'2026-06-16T00:30:00.000Z'}
|
||||
)
|
||||
`;
|
||||
|
||||
for (const [index, step] of workflow.steps.entries()) {
|
||||
await sql`
|
||||
insert into crm_approval_actions (
|
||||
id,
|
||||
organization_id,
|
||||
approval_request_id,
|
||||
step_number,
|
||||
action,
|
||||
remark,
|
||||
acted_by,
|
||||
acted_at
|
||||
) values (
|
||||
${crypto.randomUUID()},
|
||||
${organization.id},
|
||||
${approvalRequestId},
|
||||
${step.stepNumber},
|
||||
'approve',
|
||||
${`Task H.1 fixture ${step.roleCode} approval`},
|
||||
${creatorId},
|
||||
${new Date(Date.UTC(2026, 5, 16, 1, index * 10, 0)).toISOString()}
|
||||
)
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
return quotationId;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
loadLocalEnv();
|
||||
|
||||
const sql = postgres(requireEnv('DATABASE_URL'), { prepare: false });
|
||||
|
||||
try {
|
||||
const organization =
|
||||
(
|
||||
await sql`
|
||||
select id, name, slug, created_by as "createdBy"
|
||||
from organizations
|
||||
order by created_at asc
|
||||
limit 1
|
||||
`
|
||||
)[0] ?? null;
|
||||
|
||||
if (!organization) {
|
||||
throw new Error('No organization found for Task H.1 fixture');
|
||||
}
|
||||
|
||||
const creatorId =
|
||||
(
|
||||
await sql`
|
||||
select id
|
||||
from users
|
||||
where system_role = 'super_admin'
|
||||
order by created_at asc
|
||||
limit 1
|
||||
`
|
||||
)[0]?.id ?? organization.createdBy;
|
||||
|
||||
if (!creatorId) {
|
||||
throw new Error('No creator user found for Task H.1 fixture');
|
||||
}
|
||||
|
||||
const optionIds = {
|
||||
branchId: await resolveOptionId(sql, organization.id, 'crm_branch', 'head_office'),
|
||||
customerTypeCompanyId: await resolveOptionId(
|
||||
sql,
|
||||
organization.id,
|
||||
'crm_customer_type',
|
||||
'company'
|
||||
),
|
||||
customerStatusActiveId: await resolveOptionId(
|
||||
sql,
|
||||
organization.id,
|
||||
'crm_customer_status',
|
||||
'active'
|
||||
),
|
||||
leadChannelSalesId: await resolveOptionId(sql, organization.id, 'crm_lead_channel', 'sales'),
|
||||
customerGroupStandardId: await resolveOptionId(
|
||||
sql,
|
||||
organization.id,
|
||||
'crm_customer_group',
|
||||
'standard'
|
||||
),
|
||||
quotationStatusDraftId: await resolveOptionId(
|
||||
sql,
|
||||
organization.id,
|
||||
'crm_quotation_status',
|
||||
'draft'
|
||||
),
|
||||
quotationStatusApprovedId: await resolveOptionId(
|
||||
sql,
|
||||
organization.id,
|
||||
'crm_quotation_status',
|
||||
'approved'
|
||||
),
|
||||
quotationTypeServiceId: await resolveOptionId(
|
||||
sql,
|
||||
organization.id,
|
||||
'crm_quotation_type',
|
||||
'service'
|
||||
),
|
||||
currencyThbId: await resolveOptionId(sql, organization.id, 'crm_currency', 'THB'),
|
||||
discountPercentageId: await resolveOptionId(
|
||||
sql,
|
||||
organization.id,
|
||||
'crm_discount_type',
|
||||
'percentage'
|
||||
),
|
||||
unitSetId: await resolveOptionId(sql, organization.id, 'crm_unit', 'set'),
|
||||
productTypeCraneId: await resolveOptionId(
|
||||
sql,
|
||||
organization.id,
|
||||
'crm_product_type',
|
||||
'crane'
|
||||
),
|
||||
customerRoleOwnerId: await resolveOptionId(
|
||||
sql,
|
||||
organization.id,
|
||||
'crm_quotation_customer_role',
|
||||
'owner'
|
||||
),
|
||||
topicTypeScopeId: await resolveOptionId(
|
||||
sql,
|
||||
organization.id,
|
||||
'crm_quotation_topic_type',
|
||||
'scope'
|
||||
),
|
||||
topicTypeExclusionId: await resolveOptionId(
|
||||
sql,
|
||||
organization.id,
|
||||
'crm_quotation_topic_type',
|
||||
'exclusion'
|
||||
),
|
||||
topicTypePaymentId: await resolveOptionId(
|
||||
sql,
|
||||
organization.id,
|
||||
'crm_quotation_topic_type',
|
||||
'payment'
|
||||
),
|
||||
sentViaEmailId: await resolveOptionId(sql, organization.id, 'crm_sent_via', 'email')
|
||||
};
|
||||
const workflow = await resolveApprovalWorkflow(sql, organization.id);
|
||||
const { customerId, contactId } = await ensureCustomerAndContact(
|
||||
sql,
|
||||
organization,
|
||||
creatorId,
|
||||
optionIds
|
||||
);
|
||||
|
||||
const users = {
|
||||
admin: await ensureUser(sql, organization, creatorId, FIXTURE_USERS.admin, 'Task H.1 Admin', {
|
||||
role: 'admin',
|
||||
businessRole: 'it_admin',
|
||||
permissions: [...PDF_PERMISSIONS, 'users:manage']
|
||||
}),
|
||||
pdfUser: await ensureUser(
|
||||
sql,
|
||||
organization,
|
||||
creatorId,
|
||||
FIXTURE_USERS.pdfUser,
|
||||
'Task H.1 PDF User',
|
||||
{
|
||||
role: 'user',
|
||||
businessRole: 'viewer',
|
||||
permissions: PDF_PERMISSIONS
|
||||
}
|
||||
),
|
||||
viewer: await ensureUser(sql, organization, creatorId, FIXTURE_USERS.viewer, 'Task H.1 Viewer', {
|
||||
role: 'user',
|
||||
businessRole: 'viewer',
|
||||
permissions: ['crm.quotation.read']
|
||||
})
|
||||
};
|
||||
|
||||
const approvedQuotationId = await upsertQuotation(sql, {
|
||||
organization,
|
||||
creatorId,
|
||||
workflow,
|
||||
optionIds,
|
||||
customerId,
|
||||
contactId,
|
||||
code: FIXTURE_CODES.approvedQuotation,
|
||||
approved: true
|
||||
});
|
||||
const draftQuotationId = await upsertQuotation(sql, {
|
||||
organization,
|
||||
creatorId,
|
||||
workflow,
|
||||
optionIds,
|
||||
customerId,
|
||||
contactId,
|
||||
code: FIXTURE_CODES.draftQuotation,
|
||||
approved: false
|
||||
});
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
organizationId: organization.id,
|
||||
approvedQuotationId,
|
||||
approvedQuotationCode: FIXTURE_CODES.approvedQuotation,
|
||||
draftQuotationId,
|
||||
draftQuotationCode: FIXTURE_CODES.draftQuotation,
|
||||
users,
|
||||
password: TASK_H1_PASSWORD
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[seed-task-h1-fixture] Failed:', error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
519
scripts/verify-task-h1.js
Normal file
519
scripts/verify-task-h1.js
Normal file
@@ -0,0 +1,519 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const postgres = require('postgres');
|
||||
|
||||
const FIXTURE_USERS = {
|
||||
superAdmin: process.env.SUPER_ADMIN_EMAIL || 'admin@allaos.com',
|
||||
admin: 'task-h1-admin@local.test',
|
||||
pdfUser: 'task-h1-pdf-user@local.test',
|
||||
viewer: 'task-h1-viewer@local.test'
|
||||
};
|
||||
|
||||
const PASSWORDS = {
|
||||
superAdmin: process.env.SUPER_ADMIN_PASSWORD || 'A3YH2Xflw1@allaos',
|
||||
fixture: 'TaskH1Pass!2026'
|
||||
};
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const separatorIndex = line.indexOf('=');
|
||||
|
||||
if (separatorIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
let value = line.slice(separatorIndex + 1).trim();
|
||||
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
if (!(key in process.env)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadLocalEnv() {
|
||||
loadEnvFile(path.resolve(process.cwd(), '.env.local'));
|
||||
loadEnvFile(path.resolve(process.cwd(), '.env'));
|
||||
}
|
||||
|
||||
function requireEnv(name) {
|
||||
const value = process.env[name]?.trim();
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function extractCookies(response) {
|
||||
const cookieMap = new Map();
|
||||
const setCookies = typeof response.headers.getSetCookie === 'function'
|
||||
? response.headers.getSetCookie()
|
||||
: response.headers.get('set-cookie')
|
||||
? [response.headers.get('set-cookie')]
|
||||
: [];
|
||||
|
||||
for (const rawCookie of setCookies) {
|
||||
const [pair] = rawCookie.split(';', 1);
|
||||
const separatorIndex = pair.indexOf('=');
|
||||
|
||||
if (separatorIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cookieMap.set(pair.slice(0, separatorIndex), pair.slice(separatorIndex + 1));
|
||||
}
|
||||
|
||||
return cookieMap;
|
||||
}
|
||||
|
||||
function mergeCookieMaps(base, next) {
|
||||
const merged = new Map(base);
|
||||
|
||||
for (const [key, value] of next.entries()) {
|
||||
merged.set(key, value);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function toCookieHeader(cookieMap) {
|
||||
return [...cookieMap.entries()].map(([key, value]) => `${key}=${value}`).join('; ');
|
||||
}
|
||||
|
||||
async function signIn(baseUrl, email, password) {
|
||||
const csrfResponse = await fetch(`${baseUrl}/api/auth/csrf`);
|
||||
assert(csrfResponse.ok, `Unable to fetch CSRF token for ${email}`);
|
||||
const csrfPayload = await csrfResponse.json();
|
||||
const cookies = extractCookies(csrfResponse);
|
||||
const body = new URLSearchParams({
|
||||
email,
|
||||
password,
|
||||
csrfToken: csrfPayload.csrfToken,
|
||||
callbackUrl: `${baseUrl}/dashboard`,
|
||||
json: 'true'
|
||||
});
|
||||
const signInResponse = await fetch(`${baseUrl}/api/auth/callback/credentials?json=true`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Cookie: toCookieHeader(cookies)
|
||||
},
|
||||
body: body.toString(),
|
||||
redirect: 'manual'
|
||||
});
|
||||
|
||||
assert(
|
||||
signInResponse.status === 200 || signInResponse.status === 302,
|
||||
`Unexpected sign-in status ${signInResponse.status} for ${email}`
|
||||
);
|
||||
|
||||
const mergedCookies = mergeCookieMaps(cookies, extractCookies(signInResponse));
|
||||
assert(mergedCookies.size > 0, `Sign-in did not return cookies for ${email}`);
|
||||
|
||||
return mergedCookies;
|
||||
}
|
||||
|
||||
async function fetchWithAuth(baseUrl, cookieMap, pathname, init = {}) {
|
||||
const response = await fetch(`${baseUrl}${pathname}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers || {}),
|
||||
Cookie: toCookieHeader(cookieMap)
|
||||
},
|
||||
redirect: 'manual'
|
||||
});
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
const contentType = response.headers.get('content-type');
|
||||
const disposition = response.headers.get('content-disposition');
|
||||
let json = null;
|
||||
|
||||
if (contentType?.includes('application/json')) {
|
||||
json = JSON.parse(bytes.toString('utf8'));
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
contentType,
|
||||
disposition,
|
||||
bytes,
|
||||
json
|
||||
};
|
||||
}
|
||||
|
||||
function estimatePageCount(buffer) {
|
||||
const text = buffer.toString('latin1');
|
||||
|
||||
return {
|
||||
startsWithPdf: text.startsWith('%PDF-'),
|
||||
byteLength: buffer.length
|
||||
};
|
||||
}
|
||||
|
||||
async function queryFixtureState(sql, approvedQuotationId) {
|
||||
const quotation =
|
||||
(
|
||||
await sql`
|
||||
select
|
||||
id,
|
||||
organization_id,
|
||||
code,
|
||||
approved_at,
|
||||
approved_pdf_url,
|
||||
approved_snapshot,
|
||||
approved_template_version_id
|
||||
from crm_quotations
|
||||
where id = ${approvedQuotationId}
|
||||
limit 1
|
||||
`
|
||||
)[0] ?? null;
|
||||
|
||||
assert(quotation, 'Approved quotation fixture not found in DB');
|
||||
|
||||
const [attachment, audit] = await Promise.all([
|
||||
sql`
|
||||
select
|
||||
id,
|
||||
file_name,
|
||||
file_path,
|
||||
file_type,
|
||||
file_size,
|
||||
description
|
||||
from crm_quotation_attachments
|
||||
where quotation_id = ${approvedQuotationId}
|
||||
and deleted_at is null
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`.then((rows) => rows[0] ?? null),
|
||||
sql`
|
||||
select
|
||||
id,
|
||||
entity_type,
|
||||
entity_id,
|
||||
action,
|
||||
created_at
|
||||
from tr_audit_logs
|
||||
where organization_id = ${quotation.organization_id}
|
||||
and entity_type = 'crm_quotation_pdf_generate_approved'
|
||||
and entity_id = ${approvedQuotationId}
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`.then((rows) => rows[0] ?? null)
|
||||
]);
|
||||
|
||||
return {
|
||||
quotation,
|
||||
attachment,
|
||||
audit
|
||||
};
|
||||
}
|
||||
|
||||
async function queryTemplatePageCount(sql, templateVersionId) {
|
||||
const version =
|
||||
(
|
||||
await sql`
|
||||
select schema_json
|
||||
from crm_document_template_versions
|
||||
where id = ${templateVersionId}
|
||||
limit 1
|
||||
`
|
||||
)[0] ?? null;
|
||||
|
||||
const schemaJson =
|
||||
typeof version?.schema_json === 'string'
|
||||
? JSON.parse(version.schema_json)
|
||||
: version?.schema_json ?? null;
|
||||
const schemas = schemaJson?.schemas;
|
||||
|
||||
return Array.isArray(schemas) ? schemas.length : 0;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
loadLocalEnv();
|
||||
|
||||
const baseUrl = process.env.APP_URL || 'http://localhost:3000';
|
||||
const sql = postgres(requireEnv('DATABASE_URL'), { prepare: false });
|
||||
|
||||
try {
|
||||
const approvedQuotation =
|
||||
(
|
||||
await sql`
|
||||
select id, code
|
||||
from crm_quotations
|
||||
where code = 'QT-TASK-H1-APPROVED'
|
||||
limit 1
|
||||
`
|
||||
)[0] ?? null;
|
||||
const draftQuotation =
|
||||
(
|
||||
await sql`
|
||||
select id, code
|
||||
from crm_quotations
|
||||
where code = 'QT-TASK-H1-DRAFT'
|
||||
limit 1
|
||||
`
|
||||
)[0] ?? null;
|
||||
|
||||
assert(approvedQuotation, 'Approved fixture quotation is missing');
|
||||
assert(draftQuotation, 'Draft fixture quotation is missing');
|
||||
|
||||
const superAdminCookies = await signIn(baseUrl, FIXTURE_USERS.superAdmin, PASSWORDS.superAdmin);
|
||||
const adminCookies = await signIn(baseUrl, FIXTURE_USERS.admin, PASSWORDS.fixture);
|
||||
const pdfUserCookies = await signIn(baseUrl, FIXTURE_USERS.pdfUser, PASSWORDS.fixture);
|
||||
const viewerCookies = await signIn(baseUrl, FIXTURE_USERS.viewer, PASSWORDS.fixture);
|
||||
|
||||
const preview = await fetchWithAuth(
|
||||
baseUrl,
|
||||
superAdminCookies,
|
||||
`/api/crm/quotations/${approvedQuotation.id}/pdf-preview`
|
||||
);
|
||||
assert(preview.status === 200, `Preview API failed with ${preview.status}`);
|
||||
assert(preview.contentType?.includes('application/pdf'), 'Preview did not return PDF');
|
||||
assert(preview.disposition?.startsWith('inline;'), 'Preview was not inline');
|
||||
|
||||
const download = await fetchWithAuth(
|
||||
baseUrl,
|
||||
superAdminCookies,
|
||||
`/api/crm/quotations/${approvedQuotation.id}/pdf-download`
|
||||
);
|
||||
assert(download.status === 200, `Download API failed with ${download.status}`);
|
||||
assert(download.contentType?.includes('application/pdf'), 'Download did not return PDF');
|
||||
assert(download.disposition?.startsWith('attachment;'), 'Download was not attachment');
|
||||
|
||||
const generateApproved = await fetchWithAuth(
|
||||
baseUrl,
|
||||
superAdminCookies,
|
||||
`/api/crm/quotations/${approvedQuotation.id}/approved-pdf`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
assert(
|
||||
generateApproved.status === 200,
|
||||
`Approved PDF POST failed with ${generateApproved.status}`
|
||||
);
|
||||
assert(
|
||||
generateApproved.disposition?.startsWith('attachment;'),
|
||||
'Approved PDF POST was not attachment'
|
||||
);
|
||||
|
||||
const approvedGet = await fetchWithAuth(
|
||||
baseUrl,
|
||||
superAdminCookies,
|
||||
`/api/crm/quotations/${approvedQuotation.id}/approved-pdf`
|
||||
);
|
||||
assert(approvedGet.status === 200, `Approved PDF GET failed with ${approvedGet.status}`);
|
||||
assert(approvedGet.disposition?.startsWith('inline;'), 'Approved PDF GET was not inline');
|
||||
|
||||
const nonApprovedGenerate = await fetchWithAuth(
|
||||
baseUrl,
|
||||
superAdminCookies,
|
||||
`/api/crm/quotations/${draftQuotation.id}/approved-pdf`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
assert(
|
||||
nonApprovedGenerate.status === 400,
|
||||
`Draft quotation POST expected 400, got ${nonApprovedGenerate.status}`
|
||||
);
|
||||
|
||||
const adminPreview = await fetchWithAuth(
|
||||
baseUrl,
|
||||
adminCookies,
|
||||
`/api/crm/quotations/${approvedQuotation.id}/pdf-preview`
|
||||
);
|
||||
const pdfUserPreview = await fetchWithAuth(
|
||||
baseUrl,
|
||||
pdfUserCookies,
|
||||
`/api/crm/quotations/${approvedQuotation.id}/pdf-preview`
|
||||
);
|
||||
const viewerPreview = await fetchWithAuth(
|
||||
baseUrl,
|
||||
viewerCookies,
|
||||
`/api/crm/quotations/${approvedQuotation.id}/pdf-preview`
|
||||
);
|
||||
const viewerGenerate = await fetchWithAuth(
|
||||
baseUrl,
|
||||
viewerCookies,
|
||||
`/api/crm/quotations/${approvedQuotation.id}/approved-pdf`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
|
||||
assert(adminPreview.status === 200, `Admin preview expected 200, got ${adminPreview.status}`);
|
||||
assert(
|
||||
pdfUserPreview.status === 200,
|
||||
`Regular permitted user preview expected 200, got ${pdfUserPreview.status}`
|
||||
);
|
||||
assert(
|
||||
viewerPreview.status === 403,
|
||||
`Regular user without preview permission expected 403, got ${viewerPreview.status}`
|
||||
);
|
||||
assert(
|
||||
viewerGenerate.status === 403,
|
||||
`Regular user without generate permission expected 403, got ${viewerGenerate.status}`
|
||||
);
|
||||
|
||||
const persistence = await queryFixtureState(sql, approvedQuotation.id);
|
||||
assert(persistence.quotation.approved_pdf_url, 'approvedPdfUrl was not saved');
|
||||
assert(
|
||||
persistence.quotation.approved_template_version_id,
|
||||
'approvedTemplateVersionId was not saved'
|
||||
);
|
||||
assert(persistence.quotation.approved_snapshot, 'approvedSnapshot was not saved');
|
||||
assert(persistence.attachment, 'Approved PDF attachment metadata was not created');
|
||||
assert(persistence.audit, 'Approved PDF audit log was not created');
|
||||
|
||||
const absoluteStoredPath = path.join(
|
||||
process.cwd(),
|
||||
'public',
|
||||
persistence.quotation.approved_pdf_url.replace(/^\//, '')
|
||||
);
|
||||
assert(fs.existsSync(absoluteStoredPath), 'Stored approved PDF file is missing on disk');
|
||||
|
||||
const pdfShape = estimatePageCount(approvedGet.bytes);
|
||||
const approvedSnapshot = persistence.quotation.approved_snapshot;
|
||||
const templatePageCount = await queryTemplatePageCount(
|
||||
sql,
|
||||
persistence.quotation.approved_template_version_id
|
||||
);
|
||||
|
||||
assert(pdfShape.startsWithPdf, 'Approved PDF response does not start with %PDF header');
|
||||
assert(pdfShape.byteLength > 1000, `Approved PDF size looks too small: ${pdfShape.byteLength}`);
|
||||
assert(templatePageCount >= 1, `Template page count is not reasonable: ${templatePageCount}`);
|
||||
assert(
|
||||
approvedSnapshot.documentData?.customer?.name,
|
||||
'approvedSnapshot is missing customer block data'
|
||||
);
|
||||
assert(
|
||||
approvedSnapshot.documentData?.quotation?.code === approvedQuotation.code,
|
||||
'approvedSnapshot is missing quotation code'
|
||||
);
|
||||
assert(
|
||||
Array.isArray(approvedSnapshot.documentData?.items) &&
|
||||
approvedSnapshot.documentData.items.length >= 1,
|
||||
'approvedSnapshot is missing item table data'
|
||||
);
|
||||
assert(
|
||||
typeof approvedSnapshot.documentData?.quotation?.totalAmount === 'number',
|
||||
'approvedSnapshot is missing total amount'
|
||||
);
|
||||
assert(
|
||||
approvedSnapshot.documentData?.topics?.scope?.length >= 1,
|
||||
'approvedSnapshot is missing scope topic data'
|
||||
);
|
||||
assert(
|
||||
approvedSnapshot.documentData?.topics?.exclusion?.length >= 1,
|
||||
'approvedSnapshot is missing exclusion topic data'
|
||||
);
|
||||
assert(
|
||||
approvedSnapshot.documentData?.topics?.payment?.length >= 1,
|
||||
'approvedSnapshot is missing payment topic data'
|
||||
);
|
||||
assert(
|
||||
approvedSnapshot.documentData?.approval?.approvers?.length >= 1,
|
||||
'approvedSnapshot is missing approval block data'
|
||||
);
|
||||
assert(
|
||||
Object.values(approvedSnapshot.documentData?.signatures ?? {}).some(Boolean),
|
||||
'approvedSnapshot is missing signature placeholder data'
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
smokeTestRecord: {
|
||||
approvedQuotationId: approvedQuotation.id,
|
||||
approvedQuotationCode: approvedQuotation.code,
|
||||
draftQuotationId: draftQuotation.id,
|
||||
draftQuotationCode: draftQuotation.code
|
||||
},
|
||||
apiChecks: {
|
||||
preview: {
|
||||
status: preview.status,
|
||||
disposition: preview.disposition,
|
||||
bytes: preview.bytes.length
|
||||
},
|
||||
download: {
|
||||
status: download.status,
|
||||
disposition: download.disposition,
|
||||
bytes: download.bytes.length
|
||||
},
|
||||
approvedPost: {
|
||||
status: generateApproved.status,
|
||||
disposition: generateApproved.disposition,
|
||||
bytes: generateApproved.bytes.length
|
||||
},
|
||||
approvedGet: {
|
||||
status: approvedGet.status,
|
||||
disposition: approvedGet.disposition,
|
||||
bytes: approvedGet.bytes.length
|
||||
},
|
||||
nonApprovedPost: {
|
||||
status: nonApprovedGenerate.status,
|
||||
message: nonApprovedGenerate.json?.message ?? null
|
||||
}
|
||||
},
|
||||
permissionChecks: {
|
||||
superAdminPreview: preview.status,
|
||||
adminPreview: adminPreview.status,
|
||||
regularUserWithPermissionPreview: pdfUserPreview.status,
|
||||
regularUserWithoutPermissionPreview: viewerPreview.status,
|
||||
regularUserWithoutPermissionGenerate: viewerGenerate.status
|
||||
},
|
||||
persistence: {
|
||||
approvedPdfUrl: persistence.quotation.approved_pdf_url,
|
||||
approvedTemplateVersionId: persistence.quotation.approved_template_version_id,
|
||||
attachmentId: persistence.attachment.id,
|
||||
auditId: persistence.audit.id,
|
||||
storedFileExists: true
|
||||
},
|
||||
outputValidation: {
|
||||
templatePageCount,
|
||||
pdfByteLength: pdfShape.byteLength,
|
||||
startsWithPdfHeader: pdfShape.startsWithPdf,
|
||||
customerName: approvedSnapshot.documentData.customer.name,
|
||||
quotationCode: approvedSnapshot.documentData.quotation.code,
|
||||
itemCount: approvedSnapshot.documentData.items.length,
|
||||
totalAmount: approvedSnapshot.documentData.quotation.totalAmount,
|
||||
scopeTopicCount: approvedSnapshot.documentData.topics.scope.length,
|
||||
exclusionTopicCount: approvedSnapshot.documentData.topics.exclusion.length,
|
||||
paymentTopicCount: approvedSnapshot.documentData.topics.payment.length,
|
||||
approvalStepCount: approvedSnapshot.documentData.approval.approvers.length
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[verify-task-h1] Failed:', error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -30,9 +30,6 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ message: 'Unable to load approved PDF' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -70,9 +67,6 @@ export async function POST(_request: NextRequest, { params }: Params) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ message: 'Unable to generate approved PDF' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,6 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ message: 'Unable to generate quotation PDF' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,6 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ message: 'Unable to generate preview PDF' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ const DOCUMENT_OPTION_CATEGORIES = {
|
||||
discountType: 'crm_discount_type',
|
||||
unit: 'crm_unit',
|
||||
customerRole: 'crm_quotation_customer_role',
|
||||
productType: 'crm_product_type'
|
||||
productType: 'crm_product_type',
|
||||
topicType: 'crm_quotation_topic_type'
|
||||
} as const;
|
||||
|
||||
function toRevisionLabel(revision: number) {
|
||||
@@ -76,6 +77,72 @@ function findOptionCode(
|
||||
return id ? options.find((option) => option.id === id)?.code ?? null : null;
|
||||
}
|
||||
|
||||
function extractSinglePlaceholder(value: string) {
|
||||
const matches = [...value.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]);
|
||||
const uniqueMatches = [...new Set(matches)];
|
||||
|
||||
return uniqueMatches.length === 1 ? uniqueMatches[0] : null;
|
||||
}
|
||||
|
||||
function normalizePdfmeTemplateInput(
|
||||
schemaJson: unknown,
|
||||
templateInput: Record<string, unknown>
|
||||
) {
|
||||
const pages =
|
||||
schemaJson && typeof schemaJson === 'object' && 'schemas' in schemaJson
|
||||
? (schemaJson as { schemas?: unknown }).schemas
|
||||
: null;
|
||||
|
||||
if (!Array.isArray(pages)) {
|
||||
return templateInput;
|
||||
}
|
||||
|
||||
const normalized = { ...templateInput };
|
||||
|
||||
for (const page of pages) {
|
||||
if (!Array.isArray(page)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const field of page) {
|
||||
if (!field || typeof field !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tableField = field as { type?: unknown; content?: unknown };
|
||||
|
||||
if (tableField.type !== 'table' || typeof tableField.content !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const placeholderKey = extractSinglePlaceholder(tableField.content);
|
||||
|
||||
if (!placeholderKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = normalized[placeholderKey];
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lines = value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!lines.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized[placeholderKey] = JSON.stringify(lines.map((line) => [line]));
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function buildQuotationDocumentData(
|
||||
quotationId: string,
|
||||
organizationId: string
|
||||
@@ -95,6 +162,7 @@ export async function buildQuotationDocumentData(
|
||||
unitOptions,
|
||||
customerRoleOptions,
|
||||
productTypeOptions,
|
||||
topicTypeOptions,
|
||||
approvalRequests
|
||||
] = await Promise.all([
|
||||
db
|
||||
@@ -114,6 +182,7 @@ export async function buildQuotationDocumentData(
|
||||
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.unit, { organizationId }),
|
||||
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.customerRole, { organizationId }),
|
||||
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.productType, { organizationId }),
|
||||
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.topicType, { organizationId }),
|
||||
listApprovalRequests(organizationId, { entityType: 'quotation', entityId: quotationId, limit: 20 })
|
||||
]);
|
||||
|
||||
@@ -180,6 +249,7 @@ export async function buildQuotationDocumentData(
|
||||
const relatedCustomerMap = new Map(relatedCustomers.map((item) => [item.id, item]));
|
||||
const statusCode = findOptionCode(statusOptions, quotation.status);
|
||||
const statusLabel = findOptionLabel(statusOptions, quotation.status);
|
||||
const topicCodeById = new Map(topicTypeOptions.map((option) => [option.id, option.code]));
|
||||
const approvalApprovers: QuotationDocumentApprovalStep[] =
|
||||
approvalDetail?.timeline
|
||||
.filter((item) => ['approve', 'reject', 'return'].includes(item.action))
|
||||
@@ -284,16 +354,19 @@ export async function buildQuotationDocumentData(
|
||||
})),
|
||||
topics: {
|
||||
scope: topics
|
||||
.filter((item) => item.topicType === 'scope')
|
||||
.filter((item) => topicCodeById.get(item.topicType) === 'scope')
|
||||
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
|
||||
exclusion: topics
|
||||
.filter((item) => item.topicType === 'exclusion')
|
||||
.filter((item) => topicCodeById.get(item.topicType) === 'exclusion')
|
||||
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
|
||||
payment: topics
|
||||
.filter((item) => item.topicType === 'payment')
|
||||
.filter((item) => topicCodeById.get(item.topicType) === 'payment')
|
||||
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
|
||||
other: topics
|
||||
.filter((item) => !['scope', 'exclusion', 'payment'].includes(item.topicType))
|
||||
.filter((item) => {
|
||||
const topicCode = topicCodeById.get(item.topicType);
|
||||
return !['scope', 'exclusion', 'payment'].includes(topicCode ?? '');
|
||||
})
|
||||
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) }))
|
||||
},
|
||||
approval: {
|
||||
@@ -330,9 +403,14 @@ export async function getQuotationDocumentPreviewData(
|
||||
fileType: 'pdfme'
|
||||
});
|
||||
const mappings = await resolveTemplateMappings(template.version.id, organizationId);
|
||||
const templateInput = mapDocumentDataToTemplateInput(
|
||||
documentData as unknown as Record<string, unknown>,
|
||||
mappings
|
||||
|
||||
if (!mappings.length) {
|
||||
throw new AuthError('Document template mappings are not configured for this template version', 409);
|
||||
}
|
||||
|
||||
const templateInput = normalizePdfmeTemplateInput(
|
||||
template.version.schemaJson,
|
||||
mapDocumentDataToTemplateInput(documentData as unknown as Record<string, unknown>, mappings)
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -350,6 +350,7 @@ async function syncEntityStatusFromApproval(
|
||||
}
|
||||
|
||||
if (approvalStatus === APPROVAL_REQUEST_STATUSES.approved) {
|
||||
// TODO(task-h.1): add an async hook/job to auto-generate approved PDFs after final approval.
|
||||
await syncQuotationStatusFromApproval(entityId, organizationId, userId, 'approved');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -107,13 +107,17 @@ export async function generatePdfFromTemplate(input: GeneratePdfFromTemplateInpu
|
||||
error instanceof Error &&
|
||||
(error.message.includes('Font "') || error.message.includes('fontKitFont.layout'))
|
||||
) {
|
||||
try {
|
||||
pdf = await generate({
|
||||
...generatorInput,
|
||||
template: normalizeTemplateFonts(input.template),
|
||||
options: undefined
|
||||
});
|
||||
} catch {
|
||||
throw new AuthError('Unable to generate PDF from the current template', 500);
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
throw new AuthError('Unable to generate PDF from the current template', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,8 +159,15 @@ async function persistApprovedQuotationArtifact(args: {
|
||||
const absolutePath = path.join(directory, safeFileName);
|
||||
const publicPath = `/generated/quotations/${args.organizationId}/${safeFileName}`;
|
||||
|
||||
try {
|
||||
await writeFile(absolutePath, args.buffer);
|
||||
const fileStats = await stat(absolutePath);
|
||||
} catch {
|
||||
throw new AuthError('Unable to persist approved PDF to local storage', 500);
|
||||
}
|
||||
|
||||
const fileStats = await stat(absolutePath).catch(() => {
|
||||
throw new AuthError('Approved PDF was written but could not be verified on disk', 500);
|
||||
});
|
||||
|
||||
await db
|
||||
.update(crmQuotations)
|
||||
@@ -289,7 +300,9 @@ export async function getStoredApprovedQuotationPdf(
|
||||
|
||||
const relativePath = quotation.approvedPdfUrl.replace(/^\//, '');
|
||||
const absolutePath = path.join(process.cwd(), 'public', relativePath);
|
||||
const buffer = await readFile(absolutePath);
|
||||
const buffer = await readFile(absolutePath).catch(() => {
|
||||
throw new AuthError('Approved PDF file is missing from local storage', 404);
|
||||
});
|
||||
|
||||
return {
|
||||
buffer,
|
||||
|
||||
Reference in New Issue
Block a user