338 lines
9.1 KiB
Markdown
338 lines
9.1 KiB
Markdown
# Business Logic, Rules & Validations — Swish Auto Care GMS
|
||
|
||
---
|
||
|
||
## 1. Job Card Rules
|
||
|
||
### Job Number Generation
|
||
```js
|
||
// Format: SAC-YYYYMMDD-SEQ
|
||
// SEQ = count of jobs created today + 1, zero-padded to 3 digits
|
||
// Example: SAC-20260324-001, SAC-20260324-002
|
||
|
||
async function generateJobNo(db) {
|
||
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||
const dateStr = today.replace(/-/g, ''); // YYYYMMDD
|
||
const [rows] = await db.query(
|
||
'SELECT COUNT(*) as cnt FROM job_cards WHERE DATE(job_date) = ?',
|
||
[today]
|
||
);
|
||
const seq = String(rows[0].cnt + 1).padStart(3, '0');
|
||
return `SAC-${dateStr}-${seq}`;
|
||
}
|
||
```
|
||
|
||
### Status Transitions
|
||
- New job → always starts as `'active'`
|
||
- `active` → `done`: triggered by POST /job-cards/:id/close
|
||
- **No reverse transition.** `done` cannot go back to `active` in demo.
|
||
- Cannot edit `final_amount`, `payment_mode`, `closed_at` once set.
|
||
|
||
### Field Requirements by Job Type
|
||
|
||
| Field | Full Job | Quick Wash |
|
||
|-------|----------|-----------|
|
||
| reg_no | Required | Required |
|
||
| car_name | Required | Required |
|
||
| owner_name | Required | Optional |
|
||
| mobile_no | Required | Optional |
|
||
| service_type | Required | Required |
|
||
| assigned_staff_id | Required | Required |
|
||
| estimated_amount | Optional | Optional |
|
||
| notes | Optional | Not shown |
|
||
|
||
### Closing a Job Card
|
||
```js
|
||
// Validate before closing:
|
||
if (!final_amount || final_amount <= 0) throw 'Final amount required';
|
||
if (!payment_mode) throw 'Payment mode required';
|
||
if (job.status === 'done') throw 'Job already closed';
|
||
|
||
// On close:
|
||
UPDATE job_cards SET
|
||
status = 'done',
|
||
final_amount = ?,
|
||
payment_mode = ?,
|
||
closed_at = NOW()
|
||
WHERE id = ? AND status = 'active'
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Staff Code Generation
|
||
|
||
```js
|
||
async function generateStaffCode(db) {
|
||
const [rows] = await db.query(
|
||
'SELECT staff_code FROM staff ORDER BY id DESC LIMIT 1'
|
||
);
|
||
if (rows.length === 0) return 'SAC-STF-001';
|
||
const last = rows[0].staff_code; // e.g. SAC-STF-004
|
||
const num = parseInt(last.split('-')[2]) + 1;
|
||
return `SAC-STF-${String(num).padStart(3, '0')}`;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Attendance Rules
|
||
|
||
### Bulk Save (Upsert)
|
||
```sql
|
||
INSERT INTO attendance (staff_id, attendance_date, status)
|
||
VALUES (?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE status = VALUES(status), updated_at = NOW();
|
||
```
|
||
|
||
### Monthly Summary Calculation
|
||
```js
|
||
function calculateAttendanceSummary(records) {
|
||
const present = records.filter(r => r.status === 'present').length;
|
||
const absent = records.filter(r => r.status === 'absent').length;
|
||
const half = records.filter(r => r.status === 'half').length;
|
||
// Half day counts as 0.5 for salary purposes
|
||
const effectiveDays = present + (half * 0.5);
|
||
return { present, absent, half, effectiveDays };
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Salary Calculation
|
||
|
||
### Formula
|
||
```
|
||
Calculated Salary = (Monthly Salary / 26) × Effective Days Present
|
||
|
||
Where:
|
||
Effective Days = present_days + (half_days × 0.5)
|
||
26 = standard working days per month denominator (industry standard)
|
||
```
|
||
|
||
```js
|
||
function calculateSalary(monthlySalary, daysPresent, halfDays = 0) {
|
||
const effectiveDays = daysPresent + (halfDays * 0.5);
|
||
const dailyRate = monthlySalary / 26;
|
||
return Math.round(dailyRate * effectiveDays * 100) / 100; // 2 decimal places
|
||
}
|
||
```
|
||
|
||
### Admin Override
|
||
- `calculated_salary` is always stored (what the formula says)
|
||
- `paid_amount` is what admin actually pays (can be different)
|
||
- Both fields stored in staff_payments — never lose the audit trail
|
||
|
||
### Payment Types
|
||
- `salary` — regular monthly salary
|
||
- `advance` — advance against next month's salary
|
||
- `bonus` — extra payment (festival, performance)
|
||
- `deduction` — penalty or loan recovery (stored as negative in accounts)
|
||
|
||
For `deduction` type: store `paid_amount` as positive number, flag as deduction type. In accounts, treat as income-reducing or expense depending on business interpretation. For simplicity in demo: deduction payments are NOT shown in accounts expense — they are simply logged records.
|
||
|
||
---
|
||
|
||
## 5. Parts & Stock Rules
|
||
|
||
### Part Code Generation
|
||
```js
|
||
async function generatePartCode(db) {
|
||
const [rows] = await db.query(
|
||
'SELECT part_code FROM parts ORDER BY id DESC LIMIT 1'
|
||
);
|
||
if (rows.length === 0) return 'SAC-PRT-001';
|
||
const last = rows[0].part_code;
|
||
const num = parseInt(last.split('-')[2]) + 1;
|
||
return `SAC-PRT-${String(num).padStart(3, '0')}`;
|
||
}
|
||
```
|
||
|
||
### Stock Deduction (Atomic Operation)
|
||
```js
|
||
// Must check and deduct atomically to prevent overselling
|
||
const [result] = await db.query(
|
||
'UPDATE parts SET stock_qty = stock_qty - ? WHERE id = ? AND stock_qty >= ?',
|
||
[quantity, part_id, quantity]
|
||
);
|
||
|
||
if (result.affectedRows === 0) {
|
||
throw new Error('Insufficient stock');
|
||
}
|
||
// Only insert sale record after successful stock deduction
|
||
```
|
||
|
||
### Low Stock Alert
|
||
```js
|
||
// is_low flag returned in GET /api/parts:
|
||
const is_low = part.stock_qty <= part.low_stock_alert;
|
||
```
|
||
|
||
### Price Snapshot
|
||
- When a sale is recorded, store `unit_price` from the sale moment (not always current selling_price)
|
||
- This allows price changes without affecting historical records
|
||
|
||
---
|
||
|
||
## 6. Cash Ledger Rules
|
||
|
||
- `description` field is **mandatory** — reject if empty/whitespace
|
||
- `amount` must be > 0
|
||
- `entry_type` must be 'in' or 'out'
|
||
- `entry_date` defaults to NOW() but admin can edit (for backdating if they forgot)
|
||
|
||
---
|
||
|
||
## 7. Accounts Module — How It Aggregates
|
||
|
||
The accounts view pulls from 4 sources:
|
||
|
||
```sql
|
||
-- INCOME
|
||
SELECT
|
||
SUM(CASE WHEN status='done' THEN final_amount ELSE 0 END) as job_revenue
|
||
FROM job_cards
|
||
WHERE DATE(closed_at) BETWEEN ? AND ?;
|
||
|
||
SELECT SUM(total_amount) as parts_revenue
|
||
FROM parts_sales
|
||
WHERE DATE(sale_date) BETWEEN ? AND ?;
|
||
|
||
SELECT SUM(amount) as cash_in_other
|
||
FROM cash_ledger
|
||
WHERE entry_type = 'in' AND DATE(entry_date) BETWEEN ? AND ?;
|
||
|
||
-- EXPENSE
|
||
SELECT SUM(paid_amount) as staff_payments
|
||
FROM staff_payments
|
||
WHERE payment_date BETWEEN ? AND ? AND payment_type != 'deduction';
|
||
|
||
SELECT SUM(amount) as cash_out_other
|
||
FROM cash_ledger
|
||
WHERE entry_type = 'out' AND DATE(entry_date) BETWEEN ? AND ?;
|
||
```
|
||
|
||
---
|
||
|
||
## 8. Daily Sales Report — Excel Structure
|
||
|
||
The report Excel file should have these sheets (or sections within one sheet):
|
||
|
||
**Sheet 1: Daily Summary**
|
||
```
|
||
Row 1: SWISH AUTO CARE — DAILY SALES REPORT
|
||
Row 2: Date: 24/03/2026
|
||
Row 3: (blank)
|
||
Row 4: Total Income | ₹38,000
|
||
Row 5: Total Expense | ₹15,800
|
||
Row 6: Net Balance | ₹22,200
|
||
```
|
||
|
||
**Sheet 2: Job Revenue**
|
||
Columns: Job No | Reg No | Car | Service | Staff | Amount | Payment Mode | Time
|
||
|
||
**Sheet 3: Parts Sales**
|
||
Columns: Part | Qty | Unit Price | Total | Customer | Payment | Time
|
||
|
||
**Sheet 4: Cash In**
|
||
Columns: Category | Description | Amount | Time
|
||
|
||
**Sheet 5: Cash Out**
|
||
Columns: Category | Description | Amount | Time
|
||
|
||
**Sheet 6: Staff Payments**
|
||
Columns: Staff | Type | Mode | Amount | Notes
|
||
|
||
---
|
||
|
||
## 9. Excel Export — SheetJS Pattern
|
||
|
||
```js
|
||
const XLSX = require('xlsx');
|
||
|
||
function createExcelResponse(res, filename, sheetsData) {
|
||
const wb = XLSX.utils.book_new();
|
||
|
||
sheetsData.forEach(({ sheetName, data }) => {
|
||
const ws = XLSX.utils.json_to_sheet(data);
|
||
XLSX.utils.book_append_sheet(wb, ws, sheetName);
|
||
});
|
||
|
||
const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||
|
||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||
res.send(buffer);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 10. JWT Auth Pattern
|
||
|
||
```js
|
||
// backend/src/middleware/auth.js
|
||
const jwt = require('jsonwebtoken');
|
||
|
||
module.exports = (req, res, next) => {
|
||
const authHeader = req.headers.authorization;
|
||
if (!authHeader?.startsWith('Bearer ')) {
|
||
return res.status(401).json({ error: 'No token provided' });
|
||
}
|
||
const token = authHeader.split(' ')[1];
|
||
try {
|
||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||
req.user = decoded;
|
||
next();
|
||
} catch {
|
||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||
}
|
||
};
|
||
|
||
// On login, sign:
|
||
const token = jwt.sign(
|
||
{ id: user.id, username: user.username },
|
||
process.env.JWT_SECRET,
|
||
{ expiresIn: '8h' }
|
||
);
|
||
```
|
||
|
||
---
|
||
|
||
## 11. Input Validations (Frontend + Backend)
|
||
|
||
| Field | Rule |
|
||
|-------|------|
|
||
| Mobile No. | 10 digits, numeric only |
|
||
| Amount fields | Positive number, max 2 decimal places |
|
||
| Quantity | Positive integer |
|
||
| Date fields | Valid date, not future for attendance |
|
||
| Reg No | Uppercase, alphanumeric, trim whitespace |
|
||
| Description (cash) | Non-empty after trim, min 3 chars |
|
||
| Payment mode | Must be one of: cash, upi, card |
|
||
| Username/password | Non-empty, min 3 chars |
|
||
|
||
---
|
||
|
||
## 12. Date & Currency Formatting (Frontend)
|
||
|
||
```ts
|
||
// Currency: Always show ₹ with comma formatting
|
||
function formatCurrency(amount: number): string {
|
||
return `₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 0 })}`;
|
||
}
|
||
// ₹28,500 (not ₹28500)
|
||
|
||
// Date: DD/MM/YYYY
|
||
function formatDate(dateStr: string): string {
|
||
const d = new Date(dateStr);
|
||
return d.toLocaleDateString('en-IN', { day:'2-digit', month:'2-digit', year:'numeric' });
|
||
}
|
||
// 24/03/2026
|
||
|
||
// DateTime: DD/MM/YYYY HH:MM
|
||
function formatDateTime(dateStr: string): string {
|
||
const d = new Date(dateStr);
|
||
return d.toLocaleDateString('en-IN', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
|
||
}
|
||
```
|