39294-vm/04_BUSINESS_LOGIC.md
2026-03-24 15:28:07 +00:00

9.1 KiB
Raw Permalink Blame History

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'
  • activedone: 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

// 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_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

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_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:

-- 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' });
}