Jobs
Location: dg_smart_pos_api/modules/Jobs. A generic, configurable job/work-order engine, deliberately not specific to any trade: a JobType (e.g. “Phone Repair”, “Custom Fabrication”, “Batch Bake”) defines a flavor (service/fabrication/production, label-only, see below) and a tree of custom properties, and a Job against that type accumulates tasks, materials, labour, expenses, and revenue charges before being invoiced into a real Sales Sale. This is a completely separate system from the Repair module’s own RepairJobCard models; the two do not share code, but JobInvoicingService::invoice()‘s own docblock says it structurally mirrors RepairPartService::collect()/sellParts() and Repair’s “Collect” action (one click folds a status transition and settlement into a single action) as “the only working precedent in this codebase for this.” JobLabour.employee_id belongs to HR’s Employee model directly, Job.customer_id belongs to SaleCustomers’s SaleCustomer, and App\Services\Analytics\ServiceRevenueAnalyticsRepository combines Jobs’ and Repair’s labour/service revenue as two separate, parallel sources into one P&L view, rather than the two modules sharing a revenue table.
Jobs is enabled in config/modules.php’s enabled array, and its migrations are correctly tenant-scoped (listed in ModulesServiceProvider::$tenantOnlyMigrationModules), so the schema exists on every tenant DB even though the API surface doesn’t currently respond.
Data model
Section titled “Data model”Job- the work order itself:job_number(JOB-{store_id}-{00001}, see below),job_type_id,customer_id,source_type/source_id(where it came from),title/description,status,received_by/assigned_to,estimated_amount,invoiced_sale_id,output_stock_id/output_quantity, and the lifecycle timestampsreceived_at/promised_at/completed_at/invoiced_at/closed_at. Carries computed, not stored,estimatedCost(),actualCost(),revenue(),profit(),margin(),paidTotal(),balance().JobType/JobTypeProperty- a type has aflavorand adefault_output_tracks_stockflag; properties form a tree (parent_property_id) ofkind(boolean/text/select/number) fields, where only abooleanproperty may have follow-up children (a “Yes/No” question that reveals more fields).JobPropertyValue- the answers for one job’s property tree.JobTask- a checklist item on the job (status:pending/in_progress/done/skipped,assigned_to,due_at).JobMaterial- a stock/product-variant line with a lifecycle of its own:estimated_quantity/estimated_unit_costat planning time,actual_quantity/actual cost onceconsumed, plusreturnToStock/writeOffas terminal actions. ASTATUS_PLANNEDline can be deleted outright; a consumed one cannot.JobLabour-employee_id(HR’sEmployee) or a free-textworker_name,entry_type(hours/fixed), estimated vs. actual hours/rate/amount.JobExpense- a simple actual-only cost line (category,amount,incurred_at); unlike materials/labour it has no estimated counterpart.JobCharge- a revenue line (description,stock_id/product_variant_id,charge_type,quantity,unit_price,discount,tax). The moment any charge row exists for a job, charges become the authoritative revenue source overestimated_amount.JobPayment- a deposit/part-payment taken before final invoicing (cash/mpesa/bank/total_payment), separate from theSalepayment created atJobInvoiceController::store.JobStatusHistory- one row per status transition, written byJobStatusService.JobCounter- one row per store,last_number, row-locked (lockForUpdate) inside a transaction byJobNumberService::next()to serializejob_numbergeneration; backstopped by aunique(store_id, job_number)index.JobAttachment- an uploaded file (kind), served throughJobAttachmentService::url().
Status machine
Section titled “Status machine”JobStatus (draft -> requested -> quoted/approved -> in_progress -> on_hold/completed -> invoiced -> closed, plus cancelled reachable from most non-terminal states) is one shared graph across every flavor; the enum’s own docblock says flavor must never branch it. requested -> in_progress is allowed directly so a simple job can skip formal quote/approval. JobController::transition validates the target against JobStatus::canTransitionTo() via JobStatusService, which also writes the JobStatusHistory row.
Creating and updating a job
Section titled “Creating and updating a job”JobController::store is a fast-create path: an existing customer_id or a customer array (name/phone/email) to create one inline, optional job_type_id, property_values answering the type’s property tree, and an optional initial_payment recorded in the same call. update refuses to touch a job whose status is terminal (isTerminal(), i.e. closed/cancelled).
Materials: plan, then consume, return, or write off
Section titled “Materials: plan, then consume, return, or write off”JobMaterialController::store (-> JobMaterialService::addPlanned) only records the planned line; nothing is deducted from stock yet. consume records the actual quantity taken (optionally overriding unit cost); returnToStock and writeOff are the two ways a planned-but-unused or consumed-but-unusable line gets closed out. destroy only works while the line is still STATUS_PLANNED.
Invoicing: turning a job into a real sale
Section titled “Invoicing: turning a job into a real sale”JobInvoiceController::store -> JobInvoicingService::invoice(). Locks the Job row, rejects a job that’s already been invoiced (invoiced_sale_id set) or isn’t in in_progress/on_hold/completed, auto-transitions it to completed first if it isn’t already (so that hop still lands in JobStatusHistory even though the caller only took one action), then invoices from job_charges if any exist, or a single line derived from estimated_amount otherwise. Tendered cash+mpesa cannot exceed the outstanding balance unless waive_balance is set, and only a caller with an admin/super-admin role can waive it. This goes through SalesService, the same service Sales itself uses to create a Sale.
Nested-resource store scoping
Section titled “Nested-resource store scoping”JobMaterialController, JobTaskController, JobLabourController, JobExpenseController, JobChargeController, JobAttachmentController, and JobPaymentController all use the ScopesToStore trait: they resolve the parent Job scoped to {store} first, then confirm the nested resource (task/material/labour/…) actually belongs to that job before acting on it, specifically because a route-model-bound nested id alone doesn’t prove it belongs to the store in the URL.
Route surface
Section titled “Route surface”Defined in modules/Jobs/Routes/jobs.php under auth:sanctum + system.config:enable_jobs_module, prefix jobs/stores/{store} - see the caution above: this file is not currently required anywhere, so none of these routes actually resolve.
| Method | Path | Controller method |
|---|---|---|
| GET | job-types |
JobTypeController::index |
| POST | job-types |
JobTypeController::store (store:edit-settings) |
| PUT | job-types/{jobType} |
JobTypeController::update (store:edit-settings) |
| DELETE | job-types/{jobType} |
JobTypeController::destroy (store:edit-settings) |
| GET | job-types/{jobType}/properties |
JobPropertyController::index |
| POST | job-types/{jobType}/properties |
JobPropertyController::store (store:edit-settings) |
| PUT | properties/{property} |
JobPropertyController::update (store:edit-settings) |
| DELETE | properties/{property} |
JobPropertyController::destroy (store:edit-settings) |
| GET | jobs |
JobController::index |
| GET | jobs/stats |
JobController::stats |
| POST | jobs |
JobController::store |
| GET | jobs/{job} |
JobController::show |
| PUT | jobs/{job} |
JobController::update |
| POST | jobs/{job}/status |
JobController::transition |
| POST | jobs/{job}/tasks |
JobTaskController::store |
| PUT | tasks/{task} |
JobTaskController::update |
| DELETE | tasks/{task} |
JobTaskController::destroy |
| POST | jobs/{job}/materials |
JobMaterialController::store |
| POST | materials/{material}/consume |
JobMaterialController::consume |
| POST | materials/{material}/return |
JobMaterialController::returnToStock |
| POST | materials/{material}/write-off |
JobMaterialController::writeOff |
| DELETE | materials/{material} |
JobMaterialController::destroy |
| POST | jobs/{job}/labour |
JobLabourController::store |
| PUT | labour/{labour} |
JobLabourController::update |
| DELETE | labour/{labour} |
JobLabourController::destroy |
| POST | jobs/{job}/expenses |
JobExpenseController::store |
| DELETE | expenses/{expense} |
JobExpenseController::destroy |
| POST | jobs/{job}/charges |
JobChargeController::store |
| PUT | charges/{charge} |
JobChargeController::update |
| DELETE | charges/{charge} |
JobChargeController::destroy |
| POST | jobs/{job}/attachments |
JobAttachmentController::store |
| DELETE | attachments/{attachment} |
JobAttachmentController::destroy |
| POST | jobs/{job}/payments |
JobPaymentController::store |
| DELETE | payments/{payment} |
JobPaymentController::destroy (also requires sales:delete) |
| POST | jobs/{job}/invoice |
JobInvoiceController::store |
| GET | jobs-report/profitability |
JobController::profitabilityReport |
Related
Section titled “Related”- Repair - separate
RepairJobCardschema, no shared code, butJobInvoicingServicedeliberately mirrors Repair’s collect/invoice pattern, andServiceRevenueAnalyticsRepositoryreports the two side by side - HR -
JobLabour.employee_id - SaleCustomers -
Job.customer_id - Sales - invoicing a job creates a real
Salevia the sameSalesService - Inventory -
JobMaterial/JobChargereference stock and product variants - Pdfs - the
job_quotationexport kind readsJobdirectly