14 KiB
14 KiB
Phase 6: Models (TypeScript) - Checklist
✅ Completed Tasks
Customer Model Refactor
- Analyze existing customer model structure
- Update customer model with new schema fields
- Add Contact model types
- Add Contact sharing visibility fields
Quotation Model Refactor
- Analyze existing quotation model structure
- Update quotation model with multi-currency fields
- Add QuotationItem model types
- Add QuotationCustomer model types
- Add revision tracking fields
- Add new status flow enums
📁 Created Files
-
src/modules/customers/model.refactored.ts(149 lines)- Updated CustomerModel with new fields
- Added ContactModel with visibility controls
- Exported TypeScript types
-
src/modules/quotations/model.refactored.ts(277 lines)- Updated QuotationModel with multi-currency
- Added QuotationItemModel
- Added QuotationCustomerModel
- Exported TypeScript types
🔑 Key Changes in Models
Customer Model Updates
OLD:
Customer: t.Object({
id: t.String(),
branch: t.String(), // String branch code
name: t.String(),
email: t.String({ format: "email" }),
phone: t.String(),
company: t.String(),
address: t.String(),
status: t.Union([...]), // "active", "inactive", "pending"
createdAt: t.String({ format: "date-time" }),
updatedAt: t.String({ format: "date-time" }),
})
NEW:
Customer: t.Object({
id: t.String(),
branchId: t.String(), // UUID of branch
name: t.String(),
email: t.String({ format: "email" }),
phone: t.String(),
company: t.String(),
address: t.String(),
customerStatus: t.Union([...]), // "active", "inactive", "pending"
customerType: t.Optional(t.String()),
taxId: t.Optional(t.String()),
crmCustomerCode: t.String(), // Auto-generated CRM code
erpCustomerCode: t.Nullable(t.String()), // Manual ERP code
isActive: t.Boolean(),
createdBy: t.String(),
updatedBy: t.String(),
createdAt: t.String({ format: "date-time" }),
updatedAt: t.String({ format: "date-time" }),
deletedAt: t.Nullable(t.String({ format: "date-time" })),
})
New Contact Model
ContactModel: {
Contact: t.Object({
id: t.String(),
customerId: t.String(),
name: t.String(),
position: t.Nullable(t.String()),
phone: t.Nullable(t.String()),
mobile: t.Nullable(t.String()),
email: t.Nullable(t.String()),
isPrimary: t.Nullable(t.Boolean()),
isPublic: t.Boolean(), // Visibility control
notes: t.Nullable(t.String()),
branchId: t.String(),
createdBy: t.String(),
createdAt: t.String({ format: "date-time" }),
updatedAt: t.String({ format: "date-time" }),
}),
CreateContact: t.Object({
name: t.String(),
position: t.Optional(t.String()),
phone: t.Optional(t.String()),
mobile: t.Optional(t.String()),
email: t.Optional(t.String()),
isPrimary: t.Optional(t.Boolean()),
notes: t.Optional(t.String()),
}),
UpdateContact: t.Object({
name: t.Optional(t.String()),
position: t.Optional(t.String()),
phone: t.Optional(t.String()),
mobile: t.Optional(t.String()),
email: t.Optional(t.String()),
isPrimary: t.Optional(t.Boolean()),
isPublic: t.Optional(t.Boolean()), // Can update visibility
notes: t.Optional(t.String()),
}),
}
Quotation Model Updates
OLD:
Quotation: t.Object({
id: t.String(),
quotationNumber: t.String(),
branch: t.String(), // String branch code
customerId: t.String(),
customerName: t.String(),
date: t.String({ format: "date-time" }),
validUntil: t.String({ format: "date-time" }),
subtotal: t.Number(),
taxRate: t.Number(),
taxAmount: t.Number(),
totalAmount: t.Number(),
status: t.Union([
t.Literal("draft"),
t.Literal("sent"),
t.Literal("accepted"),
t.Literal("rejected"),
t.Literal("expired"),
]),
notes: t.Optional(t.String()),
createdAt: t.String({ format: "date-time" }),
updatedAt: t.String({ format: "date-time" }),
});
NEW:
Quotation: t.Object({
id: t.String(),
code: t.String(),
branchId: t.String(), // UUID of branch
customerId: t.String(),
quotationDate: t.String({ format: "date-time" }),
validUntil: t.String({ format: "date-time" }),
// Multi-Currency Fields
currencyCode: t.String(), // THB, USD, EUR, JPY, CNY
exchangeRate: t.Number(), // Exchange rate at creation
baseCurrencyAmount: t.Nullable(t.String()), // THB equivalent
// Monetary Values (as strings for precision)
subtotal: t.String(),
discount: t.String(),
taxRate: t.Number(),
taxAmount: t.String(),
totalAmount: t.String(),
// Status Flow
status: t.Union([
t.Literal("new_job_draft"),
t.Literal("new_job_sent"),
t.Literal("follow_up"),
t.Literal("closed_lost"),
t.Literal("awarded"),
t.Literal("cancelled"),
]),
// Revision Tracking
revisionNo: t.Nullable(t.Number()),
parentQuotationId: t.Nullable(t.String()),
notes: t.Optional(t.String()),
createdBy: t.String(),
updatedBy: t.String(),
createdAt: t.String({ format: "date-time" }),
updatedAt: t.String({ format: "date-time" }),
});
New Quotation Item Model
QuotationItemModel: {
QuotationItem: t.Object({
id: t.String(),
quotationId: t.String(),
itemNumber: t.String(),
productType: t.String(),
description: t.String(),
quantity: t.String(),
unit: t.String(),
unitPrice: t.String(),
discount: t.String(),
discountType: t.Union([t.Literal("amount"), t.Literal("percentage")]),
taxRate: t.Number(),
totalPrice: t.String(),
notes: t.Nullable(t.String()),
createdAt: t.String({ format: "date-time" }),
updatedAt: t.String({ format: "date-time" }),
}),
CreateQuotationItem: t.Object({
itemNumber: t.String(),
productType: t.String(),
description: t.String(),
quantity: t.String(),
unit: t.String(),
unitPrice: t.String(),
discount: t.String(),
discountType: t.Union([t.Literal("amount"), t.Literal("percentage")]),
taxRate: t.Number(),
totalPrice: t.String(),
notes: t.Optional(t.String()),
}),
UpdateQuotationItem: t.Object({
itemNumber: t.Optional(t.String()),
productType: t.Optional(t.String()),
description: t.Optional(t.String()),
quantity: t.Optional(t.String()),
unit: t.Optional(t.String()),
unitPrice: t.Optional(t.String()),
discount: t.Optional(t.String()),
discountType: t.Optional(t.Union([t.Literal("amount"), t.Literal("percentage")])),
taxRate: t.Optional(t.Number()),
totalPrice: t.Optional(t.String()),
notes: t.Optional(t.String()),
}),
}
New Quotation Customer Model
QuotationCustomerModel: {
QuotationCustomer: t.Object({
id: t.String(),
quotationId: t.String(),
customerId: t.String(),
role: t.String(),
isPrimary: t.Nullable(t.Boolean()),
createdAt: t.String({ format: "date-time" }),
}),
CreateQuotationCustomer: t.Object({
customerId: t.String(),
role: t.String(),
isPrimary: t.Optional(t.Boolean()),
}),
QuotationCustomerList: t.Object({
success: t.Boolean(),
data: t.Array(...),
count: t.Number(),
message: t.Optional(t.String()),
}),
}
📊 Field Changes Summary
Customer Field Changes
| Old Field | New Field | Type Change | Notes |
|---|---|---|---|
branch |
branchId |
String → String (UUID) | Changed from code to UUID |
status |
customerStatus |
No change | Renamed for clarity |
| N/A | customerType |
N/A | New optional field |
| N/A | taxId |
N/A | New optional field |
| N/A | crmCustomerCode |
N/A | Auto-generated CRM code |
| N/A | erpCustomerCode |
N/A | Nullable ERP code |
| N/A | isActive |
N/A | Boolean flag |
| N/A | createdBy |
N/A | User who created |
| N/A | updatedBy |
N/A | User who last updated |
| N/A | deletedAt |
N/A | Soft delete timestamp |
Quotation Field Changes
| Old Field | New Field | Type Change | Notes |
|---|---|---|---|
quotationNumber |
code |
No change | Renamed for consistency |
branch |
branchId |
String → String (UUID) | Changed from code to UUID |
customerName |
N/A | Removed | Get from customer table |
date |
quotationDate |
No change | Renamed for clarity |
subtotal |
subtotal |
Number → String | Precision handling |
taxAmount |
taxAmount |
Number → String | Precision handling |
totalAmount |
totalAmount |
Number → String | Precision handling |
| N/A | currencyCode |
N/A | Multi-currency support |
| N/A | exchangeRate |
N/A | Exchange rate at creation |
| N/A | baseCurrencyAmount |
N/A | THB equivalent |
| N/A | discount |
N/A | Discount amount |
| N/A | revisionNo |
N/A | Revision tracking |
| N/A | parentQuotationId |
N/A | Parent quotation for revisions |
| N/A | createdBy |
N/A | User who created |
| N/A | updatedBy |
N/A | User who last updated |
Status Flow Changes
Old Status:
draftsentacceptedrejectedexpired
New Status:
new_job_draft- Initial draftnew_job_sent- Sent to customer (locked)follow_up- Follow-up stageclosed_lost- Lostawarded- Woncancelled- Cancelled
🧪 TypeScript Types
Customer Types
type Customer = {
id: string;
branchId: string;
name: string;
email: string;
phone: string;
company: string;
address: string;
customerStatus: "active" | "inactive" | "pending";
customerType?: string;
taxId?: string;
crmCustomerCode: string;
erpCustomerCode: string | null;
isActive: boolean;
createdBy: string;
updatedBy: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
};
type Contact = {
id: string;
customerId: string;
name: string;
position: string | null;
phone: string | null;
mobile: string | null;
email: string | null;
isPrimary: boolean | null;
isPublic: boolean;
notes: string | null;
branchId: string;
createdBy: string;
createdAt: string;
updatedAt: string;
};
Quotation Types
type Quotation = {
id: string;
code: string;
branchId: string;
customerId: string;
quotationDate: string;
validUntil: string;
currencyCode: "THB" | "USD" | "EUR" | "JPY" | "CNY";
exchangeRate: number;
baseCurrencyAmount: string | null;
subtotal: string;
discount: string;
taxRate: number;
taxAmount: string;
totalAmount: string;
status:
| "new_job_draft"
| "new_job_sent"
| "follow_up"
| "closed_lost"
| "awarded"
| "cancelled";
revisionNo: number | null;
parentQuotationId: string | null;
notes?: string;
createdBy: string;
updatedBy: string;
createdAt: string;
updatedAt: string;
};
type QuotationItem = {
id: string;
quotationId: string;
itemNumber: string;
productType: string;
description: string;
quantity: string;
unit: string;
unitPrice: string;
discount: string;
discountType: "amount" | "percentage";
taxRate: number;
totalPrice: string;
notes: string | null;
createdAt: string;
updatedAt: string;
};
📋 Integration with Controllers
Using Models in Controllers
import { CustomerModel, ContactModel } from "./model.refactored";
// In controller
.post(
"/",
async ({ body, currentBranchId, userId }) => {
try {
const customer = await service.createCustomer(
{ currentBranchId, userId },
body as CreateCustomer,
);
return {
success: true,
data: customer,
message: "Customer created successfully",
};
} catch (error) {
return {
success: false,
error: "Failed to create customer",
details: error instanceof Error ? error.message : "Unknown error",
};
}
},
{
body: CustomerModel.CreateCustomer, // Elysia validation
response: t.Union([...]),
},
)
🎯 Success Criteria
Phase 6 is complete when:
- Customer model updated with new schema fields
- Contact model added with visibility controls
- Quotation model updated with multi-currency
- QuotationItem model added
- QuotationCustomer model added
- All TypeScript types exported
- Status flow updated
- Monetary fields use strings for precision
📝 Notes
Precision Handling
- All monetary values are now strings to avoid floating-point precision issues
- Use decimal.js or similar library for calculations in service layer
Multi-Currency
- Currency codes are limited to: THB, USD, EUR, JPY, CNY
- Exchange rate is captured at creation time (immutable)
- Base currency (THB) amount is calculated and stored for reporting
Contact Visibility
isPublicflag controls contact visibility- Default is
false(private) - Only creator can update
isPublicfield
Status Flow
- New status flow supports sales pipeline stages
- Sent quotations are locked (require revision to edit)
- Revisions are tracked via
revisionNoandparentQuotationId
🔄 Next Steps
Phase 7: Testing
- Write unit tests for models
- Test validation schemas
- Test type safety
- Integration testing with controllers
- Manual API testing
Migration
- Update existing model files to use refactored versions
- Update controller imports
- Test backward compatibility
Phase 6 Status: ✅ COMPLETE
Next Phase: Phase 7 - Testing