14 KiB
OperationModule — SteelERP
Manufacturing & Trading ERP. Laravel 12 / PHP 8.2. SQLite. Four modules: Purchase, Inventory, Production, Sales.
Quick Start
composer install && npm install
cp .env.example .env && php artisan key:generate
php artisan migrate --seed
npm run dev # Vite dev server (keep running)
php artisan serve # http://localhost:8000
Tech Stack
| Framework | Laravel 12, PHP 8.2 |
| Frontend | Tailwind CSS v3 (JIT), Alpine.js v3, Vite 7 |
| Database | SQLite — database/database.sqlite |
| Auth | Laravel Breeze (email+password, verified) |
| RBAC | spatie/laravel-permission v6 — middleware: role, permission, role_or_permission |
| barryvdh/laravel-dompdf v3 | |
| Excel | phpoffice/phpspreadsheet v5 |
| Dev tools | Pint, Pail, Sail, PHPUnit 11 |
Roles (seeded): Admin, Accounts, Store Manager, Production Manager, Sales Manager
Controllers — app/Http/Controllers/
Controller.php
DashboardController.php
ProfileController.php
Auth/
AuthenticatedSessionController.php
ConfirmablePasswordController.php
EmailVerificationNotificationController.php
EmailVerificationPromptController.php
NewPasswordController.php
PasswordController.php
PasswordResetLinkController.php
RegisteredUserController.php
VerifyEmailController.php
Purchase/
SupplierController.php import, downloadTemplate, exportPdf + CRUD
PurchaseRequestController.php + approve, reject, print
PurchaseOrderController.php
GoodsReceiptNoteController.php + confirm
SupplierInvoiceController.php
SupplierPaymentController.php
Inventory/
ItemController.php import, downloadTemplate, exportPdf + CRUD
WarehouseController.php
StockMovementController.php
StockReportController.php summary, movement, lowStock, valuation
Production/
ProductionOrderController.php + start, complete
BillOfMaterialController.php
MaterialIssueController.php
ProductionOutputController.php
Sales/
CustomerController.php
SalesOrderController.php + confirm
DeliveryNoteController.php + dispatch
SalesInvoiceController.php
PaymentReceiptController.php
Models — app/Models/
User.php
Supplier.php SupplierInvoice.php SupplierPayment.php
PurchaseRequest.php PurchaseRequestItem.php
PurchaseOrder.php PurchaseOrderItem.php
GoodsReceiptNote.php GrnItem.php
Item.php Warehouse.php StockLevel.php StockMovement.php
ProductionOrder.php BillOfMaterial.php MaterialIssue.php
ProductionOutput.php ProductionCost.php
Customer.php SalesOrder.php SalesOrderItem.php
DeliveryNote.php DeliveryNoteItem.php
SalesInvoice.php PaymentReceipt.php
Services — app/Services/
| File | Purpose |
|---|---|
SupplierImportService.php |
Excel import — detects MRF vs template format, skips duplicates |
ItemImportService.php |
Excel import — detects Forkoll vs template format, skips duplicates |
Artisan Commands — app/Console/Commands/
php artisan suppliers:import "path/to/file.xlsx" [--dry-run]
php artisan suppliers:template [--output=path] # → storage/app/suppliers_template.xlsx
php artisan items:template [--output=path] # → storage/app/items_template.xlsx
Files: ImportSuppliers.php, GenerateSupplierTemplate.php, GenerateItemTemplate.php
Routes — routes/web.php
All protected by ['auth', 'verified']. Prefix groups:
Purchase — prefix('purchase')->name('purchase.')
GET/POST suppliers purchase.suppliers.*
POST suppliers/import purchase.suppliers.import ← must be BEFORE resource
GET suppliers/template purchase.suppliers.template ← must be BEFORE resource
GET suppliers/export-pdf purchase.suppliers.export-pdf ← must be BEFORE resource
GET/POST requests purchase.requests.*
PATCH requests/{id}/approve purchase.requests.approve
PATCH requests/{id}/reject purchase.requests.reject
GET requests/{id}/print purchase.requests.print
GET/POST orders purchase.orders.*
GET/POST grns purchase.grns.*
PATCH grns/{id}/confirm purchase.grns.confirm
GET/POST invoices purchase.invoices.*
GET/POST payments purchase.payments.*
Inventory — prefix('inventory')->name('inventory.')
GET/POST items inventory.items.*
POST items/import inventory.items.import ← must be BEFORE resource
GET items/template inventory.items.template ← must be BEFORE resource
GET items/export-pdf inventory.items.export-pdf ← must be BEFORE resource
GET/POST warehouses inventory.warehouses.*
GET/POST movements inventory.movements.*
GET reports/summary inventory.reports.summary
GET reports/movement inventory.reports.movement
GET reports/low-stock inventory.reports.low-stock
GET reports/valuation inventory.reports.valuation
Production — prefix('production')->name('production.')
GET/POST orders production.orders.*
PATCH orders/{id}/start production.orders.start
PATCH orders/{id}/complete production.orders.complete
GET/POST bom production.bom.*
GET/POST material-issues production.material-issues.*
GET/POST outputs production.outputs.*
Sales — prefix('sales')->name('sales.')
GET/POST customers sales.customers.*
GET/POST orders sales.orders.*
PATCH orders/{id}/confirm sales.orders.confirm
GET/POST delivery-notes sales.delivery-notes.*
PATCH delivery-notes/{id}/dispatch sales.delivery-notes.dispatch
GET/POST invoices sales.invoices.*
GET/POST payments sales.payments.*
Views — resources/views/
dashboard.blade.php
welcome.blade.php
layouts/
app.blade.php main layout (sidebar + topbar)
navigation.blade.php
guest.blade.php
components/ (Breeze defaults: modal, dropdown, buttons, inputs, etc.)
auth/ (login, register, forgot-password, reset-password, verify-email, confirm-password)
profile/edit.blade.php + partials/
purchase/
suppliers/ index, create, edit, pdf
requests/ index, create, edit, show
orders/ index, create, edit, show
grns/ index, create, show
invoices/ index, create, edit
payments/ index, create
inventory/
items/ index, create, edit, pdf
warehouses/ index, create, edit
movements/ index, create
reports/ summary, movement, low-stock, valuation
production/
orders/ index, create, edit, show
bom/ index, create, edit
material-issues/ index
outputs/ index
sales/
customers/ index, create, edit
orders/ index, create, edit, show
delivery-notes/ index, create, edit
invoices/ index, create, edit
payments/ index, create
Database — database/
Driver: SQLite — database/database.sqlite
Migrations (29 total)
users, cache, jobs (Laravel defaults)
permission_tables (Spatie)
suppliers, items, warehouses, stock_levels, stock_movements
purchase_orders, purchase_order_items, goods_receipt_notes, purchase_requests
grn_items, supplier_invoices, supplier_payments
production_orders, bill_of_materials, material_issues
production_outputs, production_costs
customers, sales_orders, sales_order_items, delivery_notes
delivery_note_items, sales_invoices, payment_receipts
purchase_request_items
Seeders
database/seeders/DatabaseSeeder.php — creates roles (Admin, Accounts, Store Manager, Production Manager, Sales Manager)
MCP Server — .claude/mcp-server.py
Read-only SQLite access. 16 tools:
list_tables describe_table(table)
run_query(sql) — SELECT/WITH only, max 200 rows
get_suppliers(active_only) get_purchase_orders(status)
get_purchase_requests(status) get_supplier_invoices(status)
get_items(category,active) get_stock_levels(warehouse_id,low_stock_only)
get_warehouses()
get_production_orders(status) get_bill_of_materials(product_id)
get_customers(active_only) get_sales_orders(status)
get_sales_invoices(status) get_dashboard_summary()
Activation: run claude mcp add operation-module-db python -- .claude/mcp-server.py once, then restart Claude Code. Use these tools before reading PHP files or running artisan for data questions.
Critical Gotchas
1. Tailwind JIT — use inline styles for modals/dynamic classes
Vite JIT only compiles classes found in scanned source files at build time. Classes like max-w-lg, max-w-md, arbitrary colors used only in a modal or added dynamically will not appear in the compiled CSS. Always use style="..." inline for modal widths, custom colors, and one-off layout values.
2. PhpSpreadsheet v5 — getCellByColumnAndRow() removed
Use Coordinate::stringFromColumnIndex($col) . $row then $sheet->getCell($coord) instead:
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
$val = $sheet->getCell(Coordinate::stringFromColumnIndex($col) . $row)->getValue();
3. Custom routes BEFORE Route::resource()
Route::resource('suppliers', ...) registers {supplier} wildcard that captures import, template, export-pdf. Custom routes MUST be declared first:
Route::post('suppliers/import', ...); // first
Route::get('suppliers/template', ...); // first
Route::get('suppliers/export-pdf', ...); // first
Route::resource('suppliers', ...); // last
4. MRFMI supplier import — contact fields are always empty
The MRFMI price-comparison Excel file only contains supplier names in row 4 as column headers. There is no email, phone, or contact data anywhere in the file. This is expected — not a bug.
5. Forkoll import — "DESCRIPTION" header row
The Forkoll inventory file has a header row (col A = SI.NO, col B = DESCRIPTION). ItemImportService skips col B values in $headerWords = ['description', 'item name', 'item_name', 'material'] to avoid importing headers as items.
6. Search bars — always client-side, never page-refreshing
All search/filter inputs MUST filter records instantly using JavaScript. No ?search= URL params, no form submissions, no debounce + fetch, no page reloads of any kind.
The pattern to follow (used on the Suppliers index):
var searchInput = document.getElementById('my-search');
var rows = document.querySelectorAll('.data-row');
var countEl = document.getElementById('search-count');
var total = rows.length;
searchInput.addEventListener('input', function() {
var q = this.value.trim().toLowerCase();
var visible = 0;
rows.forEach(function(row) {
var match = !q || row.textContent.toLowerCase().indexOf(q) !== -1;
row.classList.toggle('hidden-by-search', !match);
if (match) visible++;
});
countEl.textContent = q ? (visible + ' of ' + total) : total;
});
- All records are loaded on page load — the server query returns everything, JS does the filtering
- Add
.hidden-by-search { display:none; }to the page<style>block - Show a live count ("12 of 47 suppliers") next to the search input
- Show a "No results" message when
visible === 0 && q !== '' - Never add
?search=query params or submit a form for search
7. Never use alert(), confirm(), or prompt() — use toasts and modals
These native browser dialogs are ugly, block the thread, and cannot be styled. They are banned across the entire project.
- Use
showToast('msg', 'type')for notifications instead ofalert() - Use a custom modal (like the delete confirmation modal) instead of
confirm() - Use a styled modal with an
<input>instead ofprompt()
8. Route parameter names must never be {request}
Laravel injects Illuminate\Http\Request by type-hint into $request. If a route parameter is also named {request}, implicit model binding silently fails — the model parameter receives null/empty, causing NOT NULL violations or UrlGenerationException when generating URLs from that model.
Rule: Always name route parameters after the model, matching the controller variable name exactly:
// WRONG — {request} clashes with Request $request injection
Route::post('requests/{request}/sign', [PurchaseSignatureController::class, 'store']);
// store(Request $request, PurchaseRequest $purchaseRequest) — $purchaseRequest gets null
// CORRECT
Route::post('requests/{purchaseRequest}/sign', [PurchaseSignatureController::class, 'store']);
// store(Request $request, PurchaseRequest $purchaseRequest) — binding works
Route::resource also generates {request} for a resource named requests. Always override it:
// WRONG
Route::resource('requests', PurchaseRequestController::class);
// Generates {request} — clashes with $request injection in every method
// CORRECT
Route::resource('requests', PurchaseRequestController::class)->parameters(['requests' => 'purchaseRequest']);
// Generates {purchaseRequest} — matches $purchaseRequest in controller methods
Use {purchaseRequest}, {purchaseOrder}, {supplier}, etc. — never {request}.
9. Status notifications — always use toasts, never inline banners
All success/error/info/warning messages MUST be displayed as toasts, not as <div> banners inside the page content. The global toast system is wired into layouts/app.blade.php and fires automatically from Laravel session flash keys (success, error, info, warning). In controllers, use:
return redirect()->route('...')->with('success', 'Done.');
return redirect()->route('...')->with('error', 'Something failed.');
To trigger a toast from JavaScript (e.g. after an in-page action), call:
showToast('Message text', 'success'); // types: success | error | info | warn
Never add inline @if(session('success')) banner divs to individual views — the layout handles all of them. The toast appears bottom-right, auto-dismisses after 4 s, has a shrinking progress bar, and can be clicked or ×-closed early.