9.1 KiB
Business Logic, Rules & Validations — Swish Auto Care GMS
1. Job Card Rules
Job Number Generation
// 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.
donecannot go back toactivein demo. - Cannot edit
final_amount,payment_mode,closed_atonce 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
// 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
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)
INSERT INTO attendance (staff_id, attendance_date, status)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE status = VALUES(status), updated_at = NOW();
Monthly Summary Calculation
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)
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_salaryis always stored (what the formula says)paid_amountis what admin actually pays (can be different)- Both fields stored in staff_payments — never lose the audit trail
Payment Types
salary— regular monthly salaryadvance— advance against next month's salarybonus— 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
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)
// 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
// 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_pricefrom the sale moment (not always current selling_price) - This allows price changes without affecting historical records
6. Cash Ledger Rules
descriptionfield is mandatory — reject if empty/whitespaceamountmust be > 0entry_typemust be 'in' or 'out'entry_datedefaults to NOW() but admin can edit (for backdating if they forgot)
7. Accounts Module — How It Aggregates
The accounts view pulls from 4 sources:
-- 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
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
// 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)
// 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' });
}