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
userstable). Agents create, comment on, assign, escalate, and resolve cases. Assignment is many-to-many throughcase_ticket_assignments(one row per user per case, with acategorydiscriminator ofassignmentvsescalation). 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_customerstable). 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). Everyopencase older than a timeout (in hours) is flipped tooverdue. Scheduled inapp/Console/Kernel.php:691,748.tickets:escalate-overdue→EscalateOverdueTicketscommand →EscalateTicketJob(app/Console/Commands/EscalateOverdueTickets.php,app/Jobs/EscalateTicketJob.php). For each active category that has Level-3 assignees, anyopencase whose age exceeds the category'scase_resolution_time_minutes(falling back to the global settingDEFAULT_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 — ifALLOW_SMS_ESCALATION_NOTIFICATIONis 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 addclosed(2025_08_29_094649) andescalated(2025_09_22_173534,2026_06_29_124820). The report UI additionally surfaceswaitingandrejectedlabels (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 —
GenerateCaseTicketscommand (tickets:generate) turns four field scenarios into cases with pre-assigned sequential case numbers viaCaseNumberService: unmet_customers, met_with_no_orders, closed_shops, price_conflicts (app/Console/Commands/GenerateCaseTickets.php). Each dispatchesCreateCaseTicketJob. - Customer mobile app —
CaseTicketService::createCustomerAppTicket(CaseTicketService.php:1401) creates a case under theroute_customer_appcategory, 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). SeeCaseTicketService.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_NOTIFICATIONdecides whether escalation SMS fire.DEFAULT_CASE_TICKET_RESOLUTION_TIME_MINUTESsets the global SLA fallback when a category has none. - Geomapping branches. If a customer's branch has
activate_geomapping == 1, particular-changes route through thephase_two_route_customer_editsapproval pipeline instead of updating the customer directly (CaseTicketService.php:556). - Category-specific behaviour. The
route_customer_appcategory 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 isactive(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 unlinkedCrmManagementDashboardController(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\CaseTicketControllerserves the customer mobile app (create ticket, list, conversation) — samecase_ticketstable, 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— fullcases.route group (+4839‑4844management dashboard).app/Models/CaseTicket.php:1‑106— model, relations, status colour,case_numberboot 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 (TIMESTAMPDIFFcase 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 matrixgetUsers+ 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; modelsapp/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.