Skip to content

Chapter 28 — CRM

Bizwiz Guide · Book 7 (Platform & Admin) · Static analysis only. All line references are to the bizwiz/ Laravel repository.


1. Purpose

The CRM module is Bizwiz's customer case-management system. A "case" here is a support/complaint record about a customer (a route customer — the shops and kiosks the distributor delivers to), raised through any of several channels: a support agent creating one by hand, the customer's own mobile app, a WhatsApp conversation, the supplier portal, or the ERP itself auto-generating them from field-sales anomalies (a shop was closed, a customer was unmet, an order wasn't taken, a price conflict was reported). Each case moves through a lifecycle — open, in progress, resolved, escalated, overdue — is assigned to one or more staff members, accumulates a threaded conversation of comments and file uploads, and is subject to an SLA "resolution-time" clock that flips it to overdue and eventually escalates it up an assignment ladder if nobody closes it in time.

The module also doubles as a lightweight customer-service workbench: from inside a case an agent can update the customer's particulars (name, phone, credit terms, geolocation), approve/reject an offsite-shift request, or process a delivery-failure report — each of which resolves the case and fires an SMS back to the field.


The CRM is navigated from resources/views/admin/includes/sidebar_includes/crm.blade.php (76 lines), gated on the permission root crm___view. Its seven leaves are:

Nav item Route name Controller
Dashboard cases.index CaseTicketController@index
Support Cases cases.table CaseTicketController@table
Categories cases.categories.index CaseCategoryController@index
Sub Categories cases.case-sub-categories.index CaseSubCategoryController@index
Supplier Tickets cases.supplier-tickets.index SupplierCaseTicketingController@index
Supplier Tickets Config cases.supplier-tickets.config SupplierCaseTicketingController@config
Reports cases.reports.index CRMMainReportController@index

All routes live in one Route::prefix('cases')->name('cases.') group at routes/web.php:4837‑4929. There is also a separate "CRM Management Dashboard" (CrmManagementDashboardController, routes cases.crm-dashboard etc. at routes/web.php:4839‑4844) — a chairman/executive read-only overview keyed off the same case_tickets data; it is not linked from this sidebar but shares the module.

Despite the customer-facing intent, note the naming trap: the underlying model is App\Models\CaseTicket and the table is case_tickets. The word "ticket" appears everywhere in the CRM code, which is why disambiguation from the Help Desk (§5) matters.


2. Users & roles

Access is permission-driven. Any user whose role is role_id == 1 (super-admin) sees everything; everyone else needs the specific permission for each leaf. The permission keys, read straight from the sidebar @if guards, are:

Permission Grants
crm___view The CRM menu itself
cases-dashboard___view Dashboard
cases___view Support Cases list
cases-categories___view Categories
cases-sub-categories___view Sub Categories
supplier-tickets___view Supplier Tickets + its Config
crm-reports___view Reports (incl. overdue-resolution-time)

Finer-grained action permissions are checked inside controllers via the can(...) helper — e.g. Supplier Tickets uses can('view'|'edit'|'assign'|'update-config', 'supplier-tickets') (SupplierCaseTicketingController.php:26,98,131,305,349).


Two populations of "users" interact with a case:

  • Staff (the users table). Agents create, comment on, assign, escalate, and resolve cases. Assignment is many-to-many through case_ticket_assignments (one row per user per case, with a category discriminator of assignment vs escalation). Who can be auto-assigned is configured per category/sub-category via the three-level escalation matrix (§3). Assignees receive an SMS on assignment (CaseTicketService::sendAssignmentSms, CaseTicketService.php:660).
  • Customers (the wa_route_customers table). The case subject. They never log into the admin, but they participate through inbound comments arriving from the customer mobile app or WhatsApp, and receive outbound replies and status SMS/WhatsApp templates.

Suppliers are a third party specific to Supplier Tickets — see §3 and §5.


3. Processes

3.1 Case lifecycle & SLA clock

A case is born open (default in the migration, case_tickets.case_status enum). Assigning the first agent auto-moves it to in_progress (CaseTicketService.php:436,997‑1005,1004). Terminal states are resolved, closed, and rejected. Two scheduled jobs drive the SLA/overdue machinery:

  • tickets:generate-overdue → GenerateOverdueTicketsJob (app/Jobs/GenerateOverdueTicketsJob.php). Every open case older than a timeout (in hours) is flipped to overdue. Scheduled in app/Console/Kernel.php:691,748.
  • tickets:escalate-overdue → EscalateOverdueTickets command → EscalateTicketJob (app/Console/Commands/EscalateOverdueTickets.php, app/Jobs/EscalateTicketJob.php). For each active category that has Level-3 assignees, any open case whose age exceeds the category's case_resolution_time_minutes (falling back to the global setting DEFAULT_CASE_TICKET_RESOLUTION_TIME_MINUTES, seeded to 360 min / 6 h) is escalated: status → escalated, priority bumped to the category default, Level-3 users assigned (category='escalation'), a system comment logged, and — if ALLOW_SMS_ESCALATION_NOTIFICATION is on — an SMS sent (EscalateTicketJob.php:40‑77).

The SLA clock is measured from created_at, not from last activity. The "overdue resolution time report" (§4) reports the same clock as TIMESTAMPDIFF(HOUR, created_at, NOW()) (ResolutionTimeOverdueCaseTicketsController.php:175‑176).

stateDiagram-v2
    [*] --> open: case created (agent / app / WhatsApp / system)
    open --> in_progress: first agent assigned\n(or first outbound reply)
    open --> overdue: tickets:generate-overdue\n(age > timeout hrs)
    open --> escalated: tickets:escalate-overdue\n(age > category resolution_minutes)
    in_progress --> resolved: agent resolves /\ncustomer update /\noffsite/delivery processed
    in_progress --> waiting_for_feedback: awaiting customer
    in_progress --> escalated: SLA breach
    escalated --> resolved
    overdue --> in_progress: agent picks up
    resolved --> open: bulk reopen
    resolved --> [*]
    open --> rejected: change request rejected
    rejected --> [*]

Enum note: the base migration defines statuses open, in_progress, on_hold, resolved, waiting_for_feedback, overdue; later migrations add closed (2025_08_29_094649) and escalated (2025_09_22_173534, 2026_06_29_124820). The report UI additionally surfaces waiting and rejected labels (ResolutionTimeOverdueCaseTicketsController.php:31‑41).

3.2 The three-level escalation matrix (assignment taxonomy)

Categories (case_ticket_categories) and sub-categories (case_ticket_sub_categories) each carry a Level 1 / Level 2 / Level 3 assignment configuration: an assignment_type (user or role) plus a JSON list of user- or role-IDs (levelN_assignments), a default_case_priority, a case_resolution_time_minutes, and a route_filtering boolean. CaseTicketCategory::getUsers($level, $branchId, $routeId) resolves the JSON list into concrete users, optionally filtering by route when route_filtering is on (CaseTicketCategory.php:89‑163). Sub-categories inherit the parent category's levels when their own are empty (CaseTicketSubCategory.php:65‑96), so a sub-category can override or defer to its category.

flowchart TD
    A[New case, category X] --> B{Sub-category has\nescalation config?}
    B -- yes --> C[getUsers Level 1\n from sub-category]
    B -- no --> D[getUsers Level 1\n from category]
    C --> E[Auto-assign Level 1 users\n category='assignment']
    D --> E
    E --> F{Age > resolution_minutes\n and no resolution?}
    F -- yes --> G[EscalateTicketJob:\n status=escalated,\n assign Level 3\n category='escalation',\n SMS if enabled]
    F -- no --> H[Agent works case → resolved]

3.3 Case creation channels

The channel column distinguishes origins: system (auto-generated field-sales cases), whatsapp, customer_app, supplier_portal, and hand-created web cases.

  • System / field-sales — GenerateCaseTickets command (tickets:generate) turns four field scenarios into cases with pre-assigned sequential case numbers via CaseNumberService: unmet_customers, met_with_no_orders, closed_shops, price_conflicts (app/Console/Commands/GenerateCaseTickets.php). Each dispatches CreateCaseTicketJob.
  • Customer mobile app — CaseTicketService::createCustomerAppTicket (CaseTicketService.php:1401) creates a case under the route_customer_app category, seeds an inbound comment, auto-assigns Level-1 users, and SMSes the customer that their ticket was received. Comments flow both ways (addCustomerAppTicketComment, getCustomerAppTicketConversation).
  • WhatsApp — inbound/outbound comments are exchanged as WhatsApp session messages or, once the 24-h (≈23.5 h, WHATSAPP_SESSION_GAP_SECONDS = 84600) session window closes, as Meta approved templates (issue_continuity_update, ..._with_image, ..._with_document). See CaseTicketService.php:773‑934.
  • Supplier portal — see §5.3.

Every state change, assignment, comment, upload, customer edit, and SMS attempt is written back as a case_ticket_comments row (many internal=true system entries), producing an auditable case history. Observers (app/Observers/Ticketing/CaseTicket*Observer.php) hook these events.


4. Tables touched & key data

The CRM owns a family of case_ticket_* tables, all introduced from August 2025 onward.

Table Migration Key columns
case_tickets 2025_08_11_150157 (+ ~20 alters) title, case_number (unique, CaseNumberService), case_category_id, case_sub_category_id, case_priority enum, case_status enum, user_id (creator), customer_id→wa_route_customers, supplier_id→wa_suppliers, branch_id, route_id, channel, client_name, phone_number, conversation_id, reference_id/reference_type (polymorphic source), insights/tags/changes JSON, is_anonymous, salesman_shift_id, WhatsApp cols, soft-deletes
case_ticket_categories 2025_08_11_150229 (+ escalation/extra alters) name, slug, description, active, route_filtering, level1/2/3_assignment_type + _assignments (JSON), default_case_priority, case_resolution_time_minutes, actions (JSON)
case_ticket_sub_categories 2026_06_05_101023 (+ resolution/assignment alter 2026_08_13_180736) case_ticket_category_id, name, slug, priority, active, actions, inherited level config, default_case_priority, default_case_resolution_time, case_resolution_time_minutes
case_ticket_assignments 2025_08_11_150250 (+ category col, unique constraint) case_id, user_id, category (assignment|escalation), soft-deletes
case_ticket_comments 2025_08_11_150220 (+ several alters) case_id, user_id, supplier_id, comment, internal, direction (inbound|outbound), status (received…), conversation_id, comment_timestamp, WhatsApp fields
case_ticket_uploads 2025_08_11_150306 (+ mime) case_id, comment_id, upload_url, upload_type, mime_type
case_category_actions 2025_09_25_091229 per-category action definitions
case_comment_forwards 2026_05_25_030638 comment forwarding audit
supplier_ticket_notification_settings 2026_06_11_144231 (+ receivers 2026_06_18) admin_notify_user_id, admin_notify_user_id_2, notify_supplier_on_reply, notify_supplier_on_status_change

Related settings rows seeded for CRM behaviour: DEFAULT_CASE_TICKET_RESOLUTION_TIME_MINUTES (360), ALLOW_SMS_ESCALATION_NOTIFICATION (0), ALLOW_CHANGE_OF_PARTICULARS_VIA_CRM, ALLOW_REQUEST_OFFSITE_SHIFT_VIA_CRM, ALLOW_REPORT_DELIVERY_FAILURE_VIA_CRM (migrations 2025_09_22_173905, 2025_10_24_*), plus a case_ticket_settings blob (2025_08_16_101712).

Foreign tables read/written by CRM logic: wa_route_customers (customer, and updated by updateCustomerFromCase), wa_suppliers, users, routes, restaurants (branches), delivery_centres, phase_two_route_customer_edits, offsite_shift_requests, delivery_failures, salesman_shifts, user_logs.


The overdue-resolution-time report (ResolutionTimeOverdueCaseTicketsController) is the analytical heart of §3.1. Its query (buildQuery, lines 145‑211) left-joins customer/route/branch/centre plus two sub-queries for last assignment time and last-assigned user, then computes case_age_hours = TIMESTAMPDIFF(HOUR, created_at, NOW()) and last_assigned_hours = TIMESTAMPDIFF(HOUR, last_assigned_at, NOW()). It is filterable by date, branch, route, centre, category, status, priority, and exports to Excel/PDF. The parent Reports index is CRMMainReportController (permission crm-reports___view), seeded as a CRM report module in 2026_06_24_000001_seed_crm_module_reports.php.


5. Interactions with other modules

5.1 CRM cases vs Help-Desk tickets — the key disambiguation

They are two entirely separate systems on two separate tables. This is the single most important clarification in the chapter.

CRM (Ch28) Help Desk (Ch29)
Table case_tickets (+ case_ticket_*) tickets (+ ticket_categories, ticket_responses, ticket_assignees, ticket_statuses, support_teams)
Model App\Models\CaseTicket App\Model\Ticket
First migration 2025_08_11_150157_create_case_tickets_table 2024_07_19_103809_create_tickets_table
Subject External customers (wa_route_customers), suppliers Internal IT/support requests among staff
Assignment cfg case_ticket_categories level-1/2/3 matrix support_teams + ticket_categories (under System Admin, Ch27)

There is no foreign key between case_tickets and tickets, no shared category table, and no code path that reads one from the other. The CRM's confusing use of the word "ticket" (in CaseTicket, supplier-tickets, the tickets:generate-overdue command signatures) is purely nominal — those commands operate on case_tickets. The Support Team / Ticket Category configuration that lives under System Administration (Ch27) belongs to the Help Desk, not the CRM; the CRM's equivalent "who handles this" config is the per-category escalation matrix (§3.2), configured under CRM → Categories / Sub Categories.

5.2 System Administration (Ch27)

The CRM leans on core admin data: restaurants (branches), routes, delivery_centres, users, and roles (the escalation matrix can assign by role_id). Global behaviour toggles live in the shared settings table (§4). But — restating §5.1 — the "Support Teams" screen under System Admin is Help-Desk infrastructure, unrelated to CRM assignment.

5.3 Supplier Portal (Book 3, Ch14)

Supplier Tickets are CRM cases where channel = 'supplier_portal' and supplier_id points at a wa_suppliers row. They are created via SupplierCaseTicketingController@store (SupplierCaseTicketingController.php:46), which validates a supplier_code against wa_suppliers, resolves the sub-category → category, and calls the shared CaseTicketService::createTicket. So supplier tickets are the same case_tickets table — just a different channel and a populated supplier_id. Supplier participation in the conversation is captured by case_ticket_comments.supplier_id (a comment authored by the supplier has user_id=null, supplier_id=X; a staff reply is the reverse — SupplierCaseTicketingController.php:235‑251).

The Supplier Tickets Config screen (@config/@configUpdate, lines 334‑385) edits the single supplier_ticket_notification_settings row: up to two admin users to notify on a new supplier ticket (admin_notify_user_id, admin_notify_user_id_2), and two booleans — notify_supplier_on_reply and notify_supplier_on_status_change. This is the bridge to the Supplier Portal: when staff reply or change status, these flags decide whether the supplier is pinged.

5.4 Communication Centre (Ch30)

CRM is a heavy consumer of the SMS/WhatsApp stack: - Assignment SMS to staff — CaseAssignmentSmsJob / sendAssignmentSms, scenario SmsScenarios::CASE_TICKET_ASSIGNMENT (CaseTicketService.php:646‑695). - Escalation SMS — gated by ALLOW_SMS_ESCALATION_NOTIFICATION (EscalateTicketJob.php:80). - Customer SMS on new app ticket — SmsScenarios::NOTIFY_ROUTE_CUSTOMER_ON_NEW_TICKET via InfoSkySmsService (CaseTicketService.php:1646). - Processing SMS — CaseProcessingSmsJob fires when an offsite-shift request or delivery-failure is approved/rejected from a case (CaseTicketService.php:1306). - WhatsApp — full inbound/outbound conversation via Meta Cloud templates and session messaging (CaseTicketService.php:773‑934).

5.5 Field sales / Order-taking

The CRM both consumes field data (auto-generating cases from salesman_shift_customers / salesman_shift_issues, §3.3) and writes back to it: resolving a case can approve an offsite_shift_requests row (flipping a salesman_shifts.shift_type to offsite and logging a user_logs entry) or a delivery_failures row (CaseTicketService.php:1215‑1325). Updating customer particulars from a case writes wa_route_customers and, when geomapping is active, queues a phase_two_route_customer_edits approval (CaseTicketService.php:508‑612).


6. Alternatives & variants

  • Tenant toggles. Three per-tenant settings decide which in-case customer actions are exposed: ALLOW_CHANGE_OF_PARTICULARS_VIA_CRM, ALLOW_REQUEST_OFFSITE_SHIFT_VIA_CRM, ALLOW_REPORT_DELIVERY_FAILURE_VIA_CRM. ALLOW_SMS_ESCALATION_NOTIFICATION decides whether escalation SMS fire. DEFAULT_CASE_TICKET_RESOLUTION_TIME_MINUTES sets the global SLA fallback when a category has none.
  • Geomapping branches. If a customer's branch has activate_geomapping == 1, particular-changes route through the phase_two_route_customer_edits approval pipeline instead of updating the customer directly (CaseTicketService.php:556).
  • Category-specific behaviour. The route_customer_app category triggers an automatic customer SMS on creation (CaseTicketService.php:318). Auto-generation only fires for the four hard-coded field categories, and only if that category exists and is active (GenerateCaseTickets.php:23‑31).
  • WhatsApp session window. Behaviour bifurcates at the ~24-hour mark: inside the window, free-text session messages; outside, constrained approved templates with attachment/length limits (422 validation, CaseTicketService.php:773‑811).
  • Two dashboards. The sidebar "Dashboard" (cases.index) is the operational agent view; the unlinked CrmManagementDashboardController (crm-dashboard, crm-summary-datatable, crm-customer-conversation) is an executive/chairman rollup over the same data.
  • API surface. A parallel App\Http\Controllers\Api\CaseTicketController serves the customer mobile app (create ticket, list, conversation) — same case_tickets table, different entry point.

7. Open questions

Code-proven facts (high confidence): - CRM cases (case_tickets) and Help-Desk tickets (tickets) are distinct tables/models with no linkage (§5.1) — verified by migrations 2025_08_11_150157 vs 2024_07_19_103809 and by grep across models. - SLA clock is measured from created_at; overdue at a timeout-hours threshold, escalation at per-category case_resolution_time_minutes (fallback 360). Verified in the two jobs and the report query. - Supplier tickets are the same case_tickets table with channel='supplier_portal' and supplier_id set; config screen edits supplier_ticket_notification_settings. - Assignment is via case_ticket_assignments with category ∈ {assignment, escalation}; a 3-level user/role escalation matrix on categories/sub-categories.

Inference / not fully traced (flagged): - The exact timeout-hours passed to tickets:generate-overdue at each scheduled run is set in app/Console/Kernel.php (option --timeout-hours); I read the command signature and schedule lines but did not extract the precise scheduled value/frequency — inference that it differs from the escalation clock. - The case_category_actions table and category actions JSON drive some per-category action buttons in the UI; I confirmed their existence but did not fully read the blade/JS that renders them — not traced. - SupplierTicketNotificationSetting is read/written by the config screen, but the exact code path that sends the supplier notification on reply/status-change (consuming those booleans) was not opened — inference it lives in an observer or the WhatsApp/SMS service. - The CaseCategoryController create/edit blades (the escalation-matrix editor UI) were not read; matrix semantics are inferred from the model's getUsers logic. - is_anonymous cases and the insights/tags JSON (AI insights, insights_generated_at) suggest an AI enrichment step not examined here.


8. Source references

  • resources/views/admin/includes/sidebar_includes/crm.blade.php:1‑76 — nav, permissions.
  • routes/web.php:4837‑4929 — full cases. route group (+ 4839‑4844 management dashboard).
  • app/Models/CaseTicket.php:1‑106 — model, relations, status colour, case_number boot hook.
  • app/Services/CaseTicketService.php — lifecycle, assignment, comments, customer update, offsite/delivery processing, WhatsApp, customer-app (:280, :418, :508, :646, :992, :1215, :1401, :1646).
  • app/Http/Controllers/Admin/ResolutionTimeOverdueCaseTicketsController.php:145‑211 — SLA report query (TIMESTAMPDIFF case age).
  • app/Http/Controllers/Admin/SupplierCaseTicketingController.php:46,235,334,347 — supplier ticket store/comment/config.
  • app/Console/Commands/GenerateCaseTickets.php — field-sales auto-generation (4 categories).
  • app/Console/Commands/EscalateOverdueTickets.php + app/Jobs/EscalateTicketJob.php — escalation clock & Level-3 assignment.
  • app/Jobs/GenerateOverdueTicketsJob.php — open→overdue flip.
  • app/Models/CaseTicketCategory.php:89‑251 / app/Models/CaseTicketSubCategory.php:65‑96 — escalation matrix getUsers + inheritance.
  • Migrations: 2025_08_11_150157/150220/150229/150250/150306, 2025_09_02_162147, 2025_09_22_165015, 2025_09_22_173905, 2025_10_24_*, 2026_06_11_144231, 2026_08_13_180736, 2026_06_24_000001 (CRM tables, escalation fields, settings).
  • Help-Desk contrast: 2024_07_19_103809_create_tickets_table, 2024_07_26_165424_create_ticket_categories_table, 2024_07_27_134525_create_support_teams_table; models app/Model/Ticket.php, SupportTeam.php, TicketCategory.php.

CRM in Bizwiz is therefore a customer-centric, SLA-governed case desk that shares only a naming convention — never a table — with the internal Help Desk.