Builds locations, items, per-location stock, and a stock-movement audit
ledger (receive/withdraw/adjust) on top of the existing hand-rolled MVC
framework, gated behind the existing user login. Low-stock withdrawals/
adjustments queue an email alert. Adds a custom "positive_integer"
validation rule and a generic SMTP email driver (alongside the existing
Postmark one) to demonstrate the framework's documented extension points.
Removes the Product/Order rocket-shop demo and renumbers the surviving
migrations (users/profiles/jobs) since nothing had been deployed from the
single "Initial commit" yet.
Also fixes local dev environment issues hit while building this: Windows
reserves the original 8081/8081 Docker port mapping and 9090 in its dynamic
TCP exclusion range (now 9092/9091, documented in the readme), and
docker/start-container.sh had CRLF line endings from a Windows checkout that
broke its shebang inside the container (.gitattributes now forces *.sh to
LF).
Adds AGENTS.md/CLAUDE.md documenting environment gotchas and two real
framework bugs found along the way (Route::matches() not checking HTTP
method for parameterised paths, and required validation rejecting "0" due
to PHP's empty("0") === true) so they aren't re-discovered from scratch.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
master
| @@ -8,6 +8,18 @@ DB_DATABASE=pro-php-mvc | |||
| DB_USERNAME=root | |||
| DB_PASSWORD= | |||
| EMAIL_TOKEN= | |||
| EMAIL_DRIVER=smtp | |||
| EMAIL_FROM_NAME= | |||
| EMAIL_FROM_EMAIL= | |||
| # smtp driver (default) - e.g. an internal relay/smart host | |||
| EMAIL_SMTP_HOST= | |||
| EMAIL_SMTP_PORT=25 | |||
| EMAIL_SMTP_ENCRYPTION= | |||
| EMAIL_SMTP_USERNAME= | |||
| EMAIL_SMTP_PASSWORD= | |||
| # postmark driver - only needed if EMAIL_DRIVER=postmark | |||
| EMAIL_TOKEN= | |||
| WAREHOUSE_ALERT_EMAIL= | |||
| @@ -0,0 +1,3 @@ | |||
| * text=auto eol=lf | |||
| *.sh text eol=lf | |||
| @@ -0,0 +1,154 @@ | |||
| # Agent notes for this repo | |||
| > Kept in sync with `CLAUDE.md` (same content, duplicated on purpose so both | |||
| > Claude Code and other agent tooling pick it up automatically — update both | |||
| > files together). | |||
| Operational gotchas discovered while building the warehouse app on top of the | |||
| hand-rolled framework in `framework/`. Read this before re-deriving any of it | |||
| from scratch — most of it was expensive to find the first time. See | |||
| `docs/framework.md` for how the framework itself works (routing, models, | |||
| views, etc.) — this file is about the *environment* and *bugs*, not the | |||
| framework's intended API. | |||
| ## No local PHP/composer — everything runs through Docker | |||
| There is no PHP or Composer on the host. Use: | |||
| - `docker compose run --rm app <cmd>` for one-off commands (migrations, tests, scratch scripts) | |||
| - `docker compose exec app <cmd>` against the running stack (`docker compose up -d`) | |||
| `.env` is required and gitignored — copy it from `.env.example` before first run. | |||
| `composer install` needs `--ignore-platform-reqs`. The committed `composer.lock` | |||
| predates the container's PHP 8.2 image: `phpspec/prophecy` requires PHP `<8.1`, | |||
| and `ext-zip` (needed by `dbrekelmans/bdi`/`php-webdriver`) isn't installed. | |||
| This is a pre-existing gap, not something to "fix" by upgrading deps unless | |||
| asked. | |||
| ## Ports are a Windows-specific minefield | |||
| Windows reserves TCP ports dynamically for Hyper-V/WSL2 NAT. A reserved port | |||
| fails to bind with `ports are not available: ... forbidden by its access | |||
| permissions` — this looks like "something else is using it" but isn't; it's | |||
| an OS-level exclusion, not a process conflict. Check before picking a port: | |||
| ``` | |||
| netsh interface ipv4 show excludedportrange protocol=tcp | |||
| ``` | |||
| `8081`, `9090`, and others have been seen reserved on this machine already. | |||
| Current mapping in `docker-compose.yml`: app → host `9092` (container `80`), | |||
| phpLiteAdmin → host `9091` (container `8081`). The **container-side** ports | |||
| must not change (`80` = nginx, `8081` = phpliteadmin's nginx vhost) — only | |||
| remap the host side, and check the exclusion list first. | |||
| ## `docker/start-container.sh` must stay LF | |||
| A Windows checkout previously gave it CRLF line endings, which breaks its | |||
| shebang inside the Linux container (`start-container: not found` in | |||
| `docker logs pro-php-mvc-app`, crash-looping). `.gitattributes` now forces | |||
| `*.sh` to LF on checkout. If this error reappears, run `file | |||
| docker/start-container.sh` to confirm, then `sed -i 's/\r$//' | |||
| docker/start-container.sh`. | |||
| ## Stale root-owned cache files break the real app | |||
| `docker compose run` (one-off containers) executes as root. The real `app` | |||
| service's php-fpm workers run as `www-data`. If you compile views or write | |||
| files via `docker compose run` and then the running app can't overwrite them | |||
| (`Permission denied` in `AdvancedEngine::render()`, `storage/framework/views/`), | |||
| it's stale root-owned `.php` cache files. Fix: delete them (gitignored, | |||
| regenerable) and let `www-data` recreate them: | |||
| ``` | |||
| docker exec pro-php-mvc-app find /var/www/html/storage/framework/views -maxdepth 1 -name '*.php' -delete | |||
| ``` | |||
| ## Testing gotchas | |||
| `phpunit.xml` registers `ServerExtension`, which **always** tries to | |||
| auto-start `php command.php serve` before the suite (even for non-browser | |||
| tests) and stop it after. This container has no `pcntl` extension, so both | |||
| the start (`ServeCommand::handleSignals()` → `pcntl_async_signals()`) and the | |||
| stop (`SIGTERM` constant) crash with a fatal error *after* the tests | |||
| themselves have already run — the crash happens in teardown, so a `phpunit` | |||
| run always exits non-zero even when everything passed. | |||
| Workaround: bind a dummy TCP listener on the target `APP_HOST:APP_PORT` | |||
| (check `docker exec <app> php -r "echo getenv('APP_HOST').':'.getenv('APP_PORT');"` | |||
| — currently `0.0.0.0:80`) before running phpunit, so | |||
| `ServerExtension::serverIsRunning()` sees it as already running and skips | |||
| starting/stopping the real (broken) one: | |||
| ``` | |||
| docker compose run --rm app sh -c "php -r 'stream_socket_server(\"tcp://0.0.0.0:80\"); sleep(120);' & sleep 1 && vendor/bin/phpunit; kill %1 2>/dev/null; true" | |||
| ``` | |||
| `BrowserTest.php` (Symfony Panther/Firefox) will still error — there's no | |||
| `geckodriver`/Firefox in this image. That's a pre-existing gap, not a | |||
| regression to chase. | |||
| `php command.php serve` is itself broken in this container for the same | |||
| `pcntl` reason. Don't use it to "prove the app works" — hit a real running | |||
| `docker compose up` stack over HTTP instead (curl, or a browser). | |||
| ## Framework bugs found (work around at the app level — don't patch `framework/`) | |||
| 1. **Router doesn't disambiguate by HTTP method for dynamic paths, and | |||
| matches unanchored.** `Framework\Routing\Route::matches()` — once past the | |||
| literal exact-match check, the regex built for a `{param}` route is | |||
| checked with `preg_match_all` with no `^`/`$` anchors, and without | |||
| checking `$method` at all. Consequences: | |||
| - A shorter dynamic route (`/items/view/{item}`) matches as a *prefix* of | |||
| any longer path (`/items/view/{item}/receive`) — whichever route is | |||
| registered first wins, regardless of specificity or method. | |||
| - Two routes on the *identical* dynamic path with different methods (GET | |||
| show-form / POST submit) collide the same way — first-registered wins | |||
| for both methods, so the POST silently runs the GET handler. | |||
| **Rule of thumb:** every dynamic (`{param}`) route in `app/routes.php` | |||
| needs a fully unique literal path. Never nest one dynamic route's path | |||
| under another's, and never pair GET+POST on the same dynamic path. Static | |||
| (no `{}`) paths are unaffected — they only ever use the exact-match | |||
| branch. See the stock-action routes in `app/routes.php` (and the comment | |||
| above them) for the working pattern: | |||
| `/items/receive-form/{item}` (GET) vs `/items/receive/{item}` (POST), etc. | |||
| 2. **PHP's `empty("0")` is `true`.** The framework's `required` rule | |||
| (`RequiredRule::validate`) is `!empty($data[$field])`, so a legitimate | |||
| `"0"` value fails `required`. Don't use `required` on a field where `0` is | |||
| valid (e.g. "set stock to zero") — validate presence manually instead. See | |||
| `AdjustStockController::handle()` for the pattern. | |||
| 3. **No `ORDER BY` / aggregate support** in `Framework\Database\QueryBuilder`. | |||
| Sort/sum in PHP after `->all()`. | |||
| 4. **No unique constraints** in the migration DSL. Uniqueness (e.g. | |||
| `items.sku`, `locations.code`) is not enforced anywhere — enforce at the | |||
| app level if it starts to matter. | |||
| Two drivers: `postmark` (API-based) and `smtp` (generic | |||
| `Swift_SmtpTransport` — no new dependency, ships with `swiftmailer/swiftmailer` | |||
| already). Switch via `EMAIL_DRIVER` in `.env`; `config/email.php` defaults to | |||
| `smtp`. | |||
| Emails are **always queued** (`app('queue')->push(...)`), never sent | |||
| synchronously. Nothing processes the queue automatically — run | |||
| `docker compose exec app php command.php queue:work` (blocks, polls every | |||
| second) or jobs just sit in the `jobs` table forever. | |||
| ## Domain model | |||
| - `locations`, `items`, `stock` (one row per item+location, found-or-created | |||
| via `Stock::forItemAtLocation()`), `stock_movements` (audit ledger: | |||
| receive/withdraw/adjustment). | |||
| - Auth gating is inline per-controller | |||
| (`if (!session()->has('user_id')) { return redirect(...); }`) — the | |||
| framework has no middleware concept; don't try to add one. | |||
| - Low-stock alerts fire from `WithdrawStockController`/`AdjustStockController` | |||
| after a stock change, when `item.reorder_level > 0 && total <= | |||
| reorder_level`. Not de-duplicated — repeated withdrawals under threshold | |||
| requeue repeated alerts. | |||
| @@ -0,0 +1,154 @@ | |||
| # Agent notes for this repo | |||
| > Kept in sync with `AGENTS.md` (same content, duplicated on purpose so both | |||
| > Claude Code and other agent tooling pick it up automatically — update both | |||
| > files together). | |||
| Operational gotchas discovered while building the warehouse app on top of the | |||
| hand-rolled framework in `framework/`. Read this before re-deriving any of it | |||
| from scratch — most of it was expensive to find the first time. See | |||
| `docs/framework.md` for how the framework itself works (routing, models, | |||
| views, etc.) — this file is about the *environment* and *bugs*, not the | |||
| framework's intended API. | |||
| ## No local PHP/composer — everything runs through Docker | |||
| There is no PHP or Composer on the host. Use: | |||
| - `docker compose run --rm app <cmd>` for one-off commands (migrations, tests, scratch scripts) | |||
| - `docker compose exec app <cmd>` against the running stack (`docker compose up -d`) | |||
| `.env` is required and gitignored — copy it from `.env.example` before first run. | |||
| `composer install` needs `--ignore-platform-reqs`. The committed `composer.lock` | |||
| predates the container's PHP 8.2 image: `phpspec/prophecy` requires PHP `<8.1`, | |||
| and `ext-zip` (needed by `dbrekelmans/bdi`/`php-webdriver`) isn't installed. | |||
| This is a pre-existing gap, not something to "fix" by upgrading deps unless | |||
| asked. | |||
| ## Ports are a Windows-specific minefield | |||
| Windows reserves TCP ports dynamically for Hyper-V/WSL2 NAT. A reserved port | |||
| fails to bind with `ports are not available: ... forbidden by its access | |||
| permissions` — this looks like "something else is using it" but isn't; it's | |||
| an OS-level exclusion, not a process conflict. Check before picking a port: | |||
| ``` | |||
| netsh interface ipv4 show excludedportrange protocol=tcp | |||
| ``` | |||
| `8081`, `9090`, and others have been seen reserved on this machine already. | |||
| Current mapping in `docker-compose.yml`: app → host `9092` (container `80`), | |||
| phpLiteAdmin → host `9091` (container `8081`). The **container-side** ports | |||
| must not change (`80` = nginx, `8081` = phpliteadmin's nginx vhost) — only | |||
| remap the host side, and check the exclusion list first. | |||
| ## `docker/start-container.sh` must stay LF | |||
| A Windows checkout previously gave it CRLF line endings, which breaks its | |||
| shebang inside the Linux container (`start-container: not found` in | |||
| `docker logs pro-php-mvc-app`, crash-looping). `.gitattributes` now forces | |||
| `*.sh` to LF on checkout. If this error reappears, run `file | |||
| docker/start-container.sh` to confirm, then `sed -i 's/\r$//' | |||
| docker/start-container.sh`. | |||
| ## Stale root-owned cache files break the real app | |||
| `docker compose run` (one-off containers) executes as root. The real `app` | |||
| service's php-fpm workers run as `www-data`. If you compile views or write | |||
| files via `docker compose run` and then the running app can't overwrite them | |||
| (`Permission denied` in `AdvancedEngine::render()`, `storage/framework/views/`), | |||
| it's stale root-owned `.php` cache files. Fix: delete them (gitignored, | |||
| regenerable) and let `www-data` recreate them: | |||
| ``` | |||
| docker exec pro-php-mvc-app find /var/www/html/storage/framework/views -maxdepth 1 -name '*.php' -delete | |||
| ``` | |||
| ## Testing gotchas | |||
| `phpunit.xml` registers `ServerExtension`, which **always** tries to | |||
| auto-start `php command.php serve` before the suite (even for non-browser | |||
| tests) and stop it after. This container has no `pcntl` extension, so both | |||
| the start (`ServeCommand::handleSignals()` → `pcntl_async_signals()`) and the | |||
| stop (`SIGTERM` constant) crash with a fatal error *after* the tests | |||
| themselves have already run — the crash happens in teardown, so a `phpunit` | |||
| run always exits non-zero even when everything passed. | |||
| Workaround: bind a dummy TCP listener on the target `APP_HOST:APP_PORT` | |||
| (check `docker exec <app> php -r "echo getenv('APP_HOST').':'.getenv('APP_PORT');"` | |||
| — currently `0.0.0.0:80`) before running phpunit, so | |||
| `ServerExtension::serverIsRunning()` sees it as already running and skips | |||
| starting/stopping the real (broken) one: | |||
| ``` | |||
| docker compose run --rm app sh -c "php -r 'stream_socket_server(\"tcp://0.0.0.0:80\"); sleep(120);' & sleep 1 && vendor/bin/phpunit; kill %1 2>/dev/null; true" | |||
| ``` | |||
| `BrowserTest.php` (Symfony Panther/Firefox) will still error — there's no | |||
| `geckodriver`/Firefox in this image. That's a pre-existing gap, not a | |||
| regression to chase. | |||
| `php command.php serve` is itself broken in this container for the same | |||
| `pcntl` reason. Don't use it to "prove the app works" — hit a real running | |||
| `docker compose up` stack over HTTP instead (curl, or a browser). | |||
| ## Framework bugs found (work around at the app level — don't patch `framework/`) | |||
| 1. **Router doesn't disambiguate by HTTP method for dynamic paths, and | |||
| matches unanchored.** `Framework\Routing\Route::matches()` — once past the | |||
| literal exact-match check, the regex built for a `{param}` route is | |||
| checked with `preg_match_all` with no `^`/`$` anchors, and without | |||
| checking `$method` at all. Consequences: | |||
| - A shorter dynamic route (`/items/view/{item}`) matches as a *prefix* of | |||
| any longer path (`/items/view/{item}/receive`) — whichever route is | |||
| registered first wins, regardless of specificity or method. | |||
| - Two routes on the *identical* dynamic path with different methods (GET | |||
| show-form / POST submit) collide the same way — first-registered wins | |||
| for both methods, so the POST silently runs the GET handler. | |||
| **Rule of thumb:** every dynamic (`{param}`) route in `app/routes.php` | |||
| needs a fully unique literal path. Never nest one dynamic route's path | |||
| under another's, and never pair GET+POST on the same dynamic path. Static | |||
| (no `{}`) paths are unaffected — they only ever use the exact-match | |||
| branch. See the stock-action routes in `app/routes.php` (and the comment | |||
| above them) for the working pattern: | |||
| `/items/receive-form/{item}` (GET) vs `/items/receive/{item}` (POST), etc. | |||
| 2. **PHP's `empty("0")` is `true`.** The framework's `required` rule | |||
| (`RequiredRule::validate`) is `!empty($data[$field])`, so a legitimate | |||
| `"0"` value fails `required`. Don't use `required` on a field where `0` is | |||
| valid (e.g. "set stock to zero") — validate presence manually instead. See | |||
| `AdjustStockController::handle()` for the pattern. | |||
| 3. **No `ORDER BY` / aggregate support** in `Framework\Database\QueryBuilder`. | |||
| Sort/sum in PHP after `->all()`. | |||
| 4. **No unique constraints** in the migration DSL. Uniqueness (e.g. | |||
| `items.sku`, `locations.code`) is not enforced anywhere — enforce at the | |||
| app level if it starts to matter. | |||
| Two drivers: `postmark` (API-based) and `smtp` (generic | |||
| `Swift_SmtpTransport` — no new dependency, ships with `swiftmailer/swiftmailer` | |||
| already). Switch via `EMAIL_DRIVER` in `.env`; `config/email.php` defaults to | |||
| `smtp`. | |||
| Emails are **always queued** (`app('queue')->push(...)`), never sent | |||
| synchronously. Nothing processes the queue automatically — run | |||
| `docker compose exec app php command.php queue:work` (blocks, polls every | |||
| second) or jobs just sit in the `jobs` table forever. | |||
| ## Domain model | |||
| - `locations`, `items`, `stock` (one row per item+location, found-or-created | |||
| via `Stock::forItemAtLocation()`), `stock_movements` (audit ledger: | |||
| receive/withdraw/adjustment). | |||
| - Auth gating is inline per-controller | |||
| (`if (!session()->has('user_id')) { return redirect(...); }`) — the | |||
| framework has no middleware concept; don't try to add one. | |||
| - Low-stock alerts fire from `WithdrawStockController`/`AdjustStockController` | |||
| after a stock change, when `item.reorder_level > 0 && total <= | |||
| reorder_level`. Not de-duplicated — repeated withdrawals under threshold | |||
| requeue repeated alerts. | |||
| @@ -0,0 +1,97 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Items; | |||
| use App\Models\Item; | |||
| use App\Models\Location; | |||
| use App\Models\Stock; | |||
| use App\Models\StockMovement; | |||
| use Framework\Routing\Router; | |||
| class AdjustStockController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| if (!session()->has('user_id')) { | |||
| return redirect($router->route('show-register-form')); | |||
| } | |||
| secure(); | |||
| $parameters = $router->current()->parameters(); | |||
| $item = Item::find((int) $parameters['item']); | |||
| if (!$item) { | |||
| return redirect($router->route('show-items')); | |||
| } | |||
| $data = validate($_POST, [ | |||
| 'location_id' => ['required'], | |||
| // not ['required'] - PHP's empty("0") is true, which would reject | |||
| // a legitimate "set this to zero" adjustment before it reaches | |||
| // the ctype_digit check below (which does allow zero). | |||
| 'quantity' => [], | |||
| 'notes' => [], | |||
| ], 'adjust_errors'); | |||
| if (!isset($data['quantity']) || !ctype_digit((string) $data['quantity'])) { | |||
| session()->put('adjust_errors', [ | |||
| 'quantity' => ['quantity must be a whole number, 0 or greater'], | |||
| ]); | |||
| return redirect($router->route('show-adjust-stock-form', ['item' => $item->id])); | |||
| } | |||
| $location = Location::find((int) $data['location_id']); | |||
| if (!$location) { | |||
| return redirect($router->route('show-adjust-stock-form', ['item' => $item->id])); | |||
| } | |||
| $stock = Stock::forItemAtLocation($item->id, $location->id); | |||
| $newQuantity = (int) $data['quantity']; | |||
| $change = $newQuantity - $stock->quantity; | |||
| $stock->quantity = $newQuantity; | |||
| $stock->save(); | |||
| $movement = new StockMovement(); | |||
| $movement->item_id = $item->id; | |||
| $movement->location_id = $location->id; | |||
| $movement->type = 'adjustment'; | |||
| $movement->quantity_change = $change; | |||
| $movement->quantity_after = $newQuantity; | |||
| $movement->notes = $data['notes'] ?? ''; | |||
| $movement->user_id = session('user_id'); | |||
| $movement->save(); | |||
| $this->alertIfLowStock($item); | |||
| return redirect($router->route('view-item', ['item' => $item->id])); | |||
| } | |||
| private function alertIfLowStock(Item $item): void | |||
| { | |||
| if ($item->reorder_level <= 0) { | |||
| return; | |||
| } | |||
| $total = array_sum(array_map( | |||
| fn($row) => $row->quantity, | |||
| Stock::where('item_id', $item->id)->all(), | |||
| )); | |||
| if ($total > $item->reorder_level) { | |||
| return; | |||
| } | |||
| app('queue')->push(function ($itemName, $total, $reorderLevel) { | |||
| app('email') | |||
| ->to(config('warehouse.alert_email')) | |||
| ->subject("Low stock: {$itemName}") | |||
| ->text("{$itemName} is at {$total} units, at or below its reorder level of {$reorderLevel}.") | |||
| ->send(); | |||
| }, $item->name, $total, $item->reorder_level); | |||
| } | |||
| } | |||
| @@ -0,0 +1,34 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Items; | |||
| use App\Models\Item; | |||
| use Framework\Routing\Router; | |||
| class CreateItemController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| if (!session()->has('user_id')) { | |||
| return redirect($router->route('show-register-form')); | |||
| } | |||
| secure(); | |||
| $data = validate($_POST, [ | |||
| 'sku' => ['required'], | |||
| 'name' => ['required'], | |||
| 'description' => [], | |||
| 'reorder_level' => ['positive_integer'], | |||
| ], 'create_item_errors'); | |||
| $item = new Item(); | |||
| $item->sku = $data['sku']; | |||
| $item->name = $data['name']; | |||
| $item->description = $data['description'] ?? ''; | |||
| $item->reorder_level = !empty($data['reorder_level']) ? (int) $data['reorder_level'] : 0; | |||
| $item->save(); | |||
| return redirect($router->route('view-item', ['item' => $item->id])); | |||
| } | |||
| } | |||
| @@ -0,0 +1,57 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Items; | |||
| use App\Models\Item; | |||
| use App\Models\Location; | |||
| use App\Models\Stock; | |||
| use App\Models\StockMovement; | |||
| use Framework\Routing\Router; | |||
| class ReceiveStockController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| if (!session()->has('user_id')) { | |||
| return redirect($router->route('show-register-form')); | |||
| } | |||
| secure(); | |||
| $parameters = $router->current()->parameters(); | |||
| $item = Item::find((int) $parameters['item']); | |||
| if (!$item) { | |||
| return redirect($router->route('show-items')); | |||
| } | |||
| $data = validate($_POST, [ | |||
| 'location_id' => ['required'], | |||
| 'quantity' => ['required', 'positive_integer'], | |||
| 'notes' => [], | |||
| ], 'receive_errors'); | |||
| $location = Location::find((int) $data['location_id']); | |||
| if (!$location) { | |||
| return redirect($router->route('show-receive-stock-form', ['item' => $item->id])); | |||
| } | |||
| $stock = Stock::forItemAtLocation($item->id, $location->id); | |||
| $stock->quantity = $stock->quantity + (int) $data['quantity']; | |||
| $stock->save(); | |||
| $movement = new StockMovement(); | |||
| $movement->item_id = $item->id; | |||
| $movement->location_id = $location->id; | |||
| $movement->type = 'receive'; | |||
| $movement->quantity_change = (int) $data['quantity']; | |||
| $movement->quantity_after = $stock->quantity; | |||
| $movement->notes = $data['notes'] ?? ''; | |||
| $movement->user_id = session('user_id'); | |||
| $movement->save(); | |||
| return redirect($router->route('view-item', ['item' => $item->id])); | |||
| } | |||
| } | |||
| @@ -0,0 +1,41 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Items; | |||
| use App\Models\Item; | |||
| use App\Models\Location; | |||
| use App\Models\Stock; | |||
| use Framework\Routing\Router; | |||
| class ShowAdjustStockFormController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| if (!session()->has('user_id')) { | |||
| return redirect($router->route('show-register-form')); | |||
| } | |||
| $parameters = $router->current()->parameters(); | |||
| $item = Item::find((int) $parameters['item']); | |||
| if (!$item) { | |||
| return redirect($router->route('show-items')); | |||
| } | |||
| $stock = Stock::where('item_id', $item->id)->all(); | |||
| $quantitiesByLocation = []; | |||
| foreach ($stock as $row) { | |||
| $quantitiesByLocation[$row->location_id] = $row->quantity; | |||
| } | |||
| return view('items/adjust', [ | |||
| 'item' => $item, | |||
| 'locations' => Location::all(), | |||
| 'quantitiesByLocation' => $quantitiesByLocation, | |||
| 'adjustAction' => $router->route('adjust-stock', ['item' => $item->id]), | |||
| 'csrf' => csrf(), | |||
| ]); | |||
| } | |||
| } | |||
| @@ -0,0 +1,20 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Items; | |||
| use Framework\Routing\Router; | |||
| class ShowCreateItemFormController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| if (!session()->has('user_id')) { | |||
| return redirect($router->route('show-register-form')); | |||
| } | |||
| return view('items/create', [ | |||
| 'createAction' => $router->route('create-item'), | |||
| 'csrf' => csrf(), | |||
| ]); | |||
| } | |||
| } | |||
| @@ -0,0 +1,46 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Items; | |||
| use App\Models\Item; | |||
| use App\Models\Stock; | |||
| use App\Models\StockMovement; | |||
| use Framework\Routing\Router; | |||
| class ShowItemController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| $parameters = $router->current()->parameters(); | |||
| $item = Item::find((int) $parameters['item']); | |||
| if (!$item) { | |||
| return redirect($router->route('show-items')); | |||
| } | |||
| $stock = Stock::where('item_id', $item->id)->all(); | |||
| $stock = array_filter($stock, fn($row) => $row->quantity > 0); | |||
| foreach ($stock as $row) { | |||
| $row->locationRoute = $router->route('view-location', ['location' => $row->location_id]); | |||
| } | |||
| $movements = StockMovement::where('item_id', $item->id)->all(); | |||
| usort($movements, fn($a, $b) => $b->id <=> $a->id); | |||
| $movements = array_slice($movements, 0, 20); | |||
| return view('items/view', [ | |||
| 'item' => $item, | |||
| 'totalQuantity' => array_sum(array_map(fn($row) => $row->quantity, $stock)), | |||
| 'stock' => $stock, | |||
| 'movements' => $movements, | |||
| 'receiveAction' => $router->route('show-receive-stock-form', ['item' => $item->id]), | |||
| 'withdrawAction' => $router->route('show-withdraw-stock-form', ['item' => $item->id]), | |||
| 'adjustAction' => $router->route('show-adjust-stock-form', ['item' => $item->id]), | |||
| 'canManage' => session()->has('user_id'), | |||
| ]); | |||
| } | |||
| } | |||
| @@ -0,0 +1,34 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Items; | |||
| use App\Models\Item; | |||
| use App\Models\Stock; | |||
| use Framework\Routing\Router; | |||
| class ShowItemsController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| $items = Item::all(); | |||
| $stock = Stock::all(); | |||
| $totalsByItem = []; | |||
| foreach ($stock as $row) { | |||
| $totalsByItem[$row->item_id] = ($totalsByItem[$row->item_id] ?? 0) + $row->quantity; | |||
| } | |||
| foreach ($items as $item) { | |||
| $item->totalQuantity = $totalsByItem[$item->id] ?? 0; | |||
| $item->isLowStock = $item->reorder_level > 0 && $item->totalQuantity <= $item->reorder_level; | |||
| $item->route = $router->route('view-item', ['item' => $item->id]); | |||
| } | |||
| return view('items/index', [ | |||
| 'items' => $items, | |||
| 'createAction' => $router->route('show-create-item-form'), | |||
| 'canManage' => session()->has('user_id'), | |||
| ]); | |||
| } | |||
| } | |||
| @@ -0,0 +1,32 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Items; | |||
| use App\Models\Item; | |||
| use App\Models\Location; | |||
| use Framework\Routing\Router; | |||
| class ShowReceiveStockFormController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| if (!session()->has('user_id')) { | |||
| return redirect($router->route('show-register-form')); | |||
| } | |||
| $parameters = $router->current()->parameters(); | |||
| $item = Item::find((int) $parameters['item']); | |||
| if (!$item) { | |||
| return redirect($router->route('show-items')); | |||
| } | |||
| return view('items/receive', [ | |||
| 'item' => $item, | |||
| 'locations' => Location::all(), | |||
| 'receiveAction' => $router->route('receive-stock', ['item' => $item->id]), | |||
| 'csrf' => csrf(), | |||
| ]); | |||
| } | |||
| } | |||
| @@ -0,0 +1,35 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Items; | |||
| use App\Models\Item; | |||
| use App\Models\Stock; | |||
| use Framework\Routing\Router; | |||
| class ShowWithdrawStockFormController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| if (!session()->has('user_id')) { | |||
| return redirect($router->route('show-register-form')); | |||
| } | |||
| $parameters = $router->current()->parameters(); | |||
| $item = Item::find((int) $parameters['item']); | |||
| if (!$item) { | |||
| return redirect($router->route('show-items')); | |||
| } | |||
| $stock = Stock::where('item_id', $item->id)->all(); | |||
| $stock = array_filter($stock, fn($row) => $row->quantity > 0); | |||
| return view('items/withdraw', [ | |||
| 'item' => $item, | |||
| 'stock' => $stock, | |||
| 'withdrawAction' => $router->route('withdraw-stock', ['item' => $item->id]), | |||
| 'csrf' => csrf(), | |||
| ]); | |||
| } | |||
| } | |||
| @@ -0,0 +1,93 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Items; | |||
| use App\Models\Item; | |||
| use App\Models\Location; | |||
| use App\Models\Stock; | |||
| use App\Models\StockMovement; | |||
| use Framework\Routing\Router; | |||
| class WithdrawStockController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| if (!session()->has('user_id')) { | |||
| return redirect($router->route('show-register-form')); | |||
| } | |||
| secure(); | |||
| $parameters = $router->current()->parameters(); | |||
| $item = Item::find((int) $parameters['item']); | |||
| if (!$item) { | |||
| return redirect($router->route('show-items')); | |||
| } | |||
| $data = validate($_POST, [ | |||
| 'location_id' => ['required'], | |||
| 'quantity' => ['required', 'positive_integer'], | |||
| 'notes' => [], | |||
| ], 'withdraw_errors'); | |||
| $location = Location::find((int) $data['location_id']); | |||
| if (!$location) { | |||
| return redirect($router->route('show-withdraw-stock-form', ['item' => $item->id])); | |||
| } | |||
| $stock = Stock::forItemAtLocation($item->id, $location->id); | |||
| $quantity = (int) $data['quantity']; | |||
| if ($quantity > $stock->quantity) { | |||
| session()->put('withdraw_errors', [ | |||
| 'quantity' => ['Not enough stock at this location to withdraw that much'], | |||
| ]); | |||
| return redirect($router->route('show-withdraw-stock-form', ['item' => $item->id])); | |||
| } | |||
| $stock->quantity = $stock->quantity - $quantity; | |||
| $stock->save(); | |||
| $movement = new StockMovement(); | |||
| $movement->item_id = $item->id; | |||
| $movement->location_id = $location->id; | |||
| $movement->type = 'withdraw'; | |||
| $movement->quantity_change = -$quantity; | |||
| $movement->quantity_after = $stock->quantity; | |||
| $movement->notes = $data['notes'] ?? ''; | |||
| $movement->user_id = session('user_id'); | |||
| $movement->save(); | |||
| $this->alertIfLowStock($item); | |||
| return redirect($router->route('view-item', ['item' => $item->id])); | |||
| } | |||
| private function alertIfLowStock(Item $item): void | |||
| { | |||
| if ($item->reorder_level <= 0) { | |||
| return; | |||
| } | |||
| $total = array_sum(array_map( | |||
| fn($row) => $row->quantity, | |||
| Stock::where('item_id', $item->id)->all(), | |||
| )); | |||
| if ($total > $item->reorder_level) { | |||
| return; | |||
| } | |||
| app('queue')->push(function ($itemName, $total, $reorderLevel) { | |||
| app('email') | |||
| ->to(config('warehouse.alert_email')) | |||
| ->subject("Low stock: {$itemName}") | |||
| ->text("{$itemName} is at {$total} units, at or below its reorder level of {$reorderLevel}.") | |||
| ->send(); | |||
| }, $item->name, $total, $item->reorder_level); | |||
| } | |||
| } | |||
| @@ -0,0 +1,34 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Locations; | |||
| use App\Models\Location; | |||
| use Framework\Routing\Router; | |||
| class CreateLocationController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| if (!session()->has('user_id')) { | |||
| return redirect($router->route('show-register-form')); | |||
| } | |||
| secure(); | |||
| $data = validate($_POST, [ | |||
| 'code' => ['required'], | |||
| 'name' => ['required'], | |||
| 'description' => [], | |||
| 'capacity' => ['positive_integer'], | |||
| ], 'create_location_errors'); | |||
| $location = new Location(); | |||
| $location->code = $data['code']; | |||
| $location->name = $data['name']; | |||
| $location->description = $data['description'] ?? ''; | |||
| $location->capacity = !empty($data['capacity']) ? (int) $data['capacity'] : null; | |||
| $location->save(); | |||
| return redirect($router->route('view-location', ['location' => $location->id])); | |||
| } | |||
| } | |||
| @@ -0,0 +1,20 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Locations; | |||
| use Framework\Routing\Router; | |||
| class ShowCreateLocationFormController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| if (!session()->has('user_id')) { | |||
| return redirect($router->route('show-register-form')); | |||
| } | |||
| return view('locations/create', [ | |||
| 'createAction' => $router->route('create-location'), | |||
| 'csrf' => csrf(), | |||
| ]); | |||
| } | |||
| } | |||
| @@ -0,0 +1,33 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Locations; | |||
| use App\Models\Location; | |||
| use App\Models\Stock; | |||
| use Framework\Routing\Router; | |||
| class ShowLocationController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| $parameters = $router->current()->parameters(); | |||
| $location = Location::find((int) $parameters['location']); | |||
| if (!$location) { | |||
| return redirect($router->route('show-locations')); | |||
| } | |||
| $stock = Stock::where('location_id', $location->id)->all(); | |||
| $stock = array_filter($stock, fn($row) => $row->quantity > 0); | |||
| foreach ($stock as $row) { | |||
| $row->itemRoute = $router->route('view-item', ['item' => $row->item_id]); | |||
| } | |||
| return view('locations/view', [ | |||
| 'location' => $location, | |||
| 'stock' => $stock, | |||
| ]); | |||
| } | |||
| } | |||
| @@ -0,0 +1,33 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Locations; | |||
| use App\Models\Location; | |||
| use App\Models\Stock; | |||
| use Framework\Routing\Router; | |||
| class ShowLocationsController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| $locations = Location::all(); | |||
| $stock = Stock::all(); | |||
| $totalsByLocation = []; | |||
| foreach ($stock as $row) { | |||
| $totalsByLocation[$row->location_id] = ($totalsByLocation[$row->location_id] ?? 0) + $row->quantity; | |||
| } | |||
| foreach ($locations as $location) { | |||
| $location->totalQuantity = $totalsByLocation[$location->id] ?? 0; | |||
| $location->route = $router->route('view-location', ['location' => $location->id]); | |||
| } | |||
| return view('locations/index', [ | |||
| 'locations' => $locations, | |||
| 'createAction' => $router->route('show-create-location-form'), | |||
| 'canManage' => session()->has('user_id'), | |||
| ]); | |||
| } | |||
| } | |||
| @@ -1,19 +0,0 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Products; | |||
| use Framework\Routing\Router; | |||
| class OrderProductController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| secure(); | |||
| // use $data to create a database record... | |||
| session()->put('ordered', true); | |||
| return redirect($router->route('show-home-page')); | |||
| } | |||
| } | |||
| @@ -1,22 +0,0 @@ | |||
| <?php | |||
| namespace App\Http\Controllers\Products; | |||
| use App\Models\Product; | |||
| use Framework\Routing\Router; | |||
| class ShowProductController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| $parameters = $router->current()->parameters(); | |||
| $product = Product::find((int) $parameters['product']); | |||
| return view('products/view', [ | |||
| 'product' => $product, | |||
| 'orderAction' => $router->route('order-product', ['product' => $product->id]), | |||
| 'csrf' => csrf(), | |||
| ]); | |||
| } | |||
| } | |||
| @@ -0,0 +1,39 @@ | |||
| <?php | |||
| namespace App\Http\Controllers; | |||
| use App\Models\Item; | |||
| use App\Models\Location; | |||
| use App\Models\Stock; | |||
| use Framework\Routing\Router; | |||
| class ShowDashboardController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| $items = Item::all(); | |||
| $stock = Stock::all(); | |||
| $totalsByItem = []; | |||
| foreach ($stock as $row) { | |||
| $totalsByItem[$row->item_id] = ($totalsByItem[$row->item_id] ?? 0) + $row->quantity; | |||
| } | |||
| $lowStockItems = array_values(array_filter($items, function ($item) use ($totalsByItem) { | |||
| return $item->reorder_level > 0 && ($totalsByItem[$item->id] ?? 0) <= $item->reorder_level; | |||
| })); | |||
| foreach ($lowStockItems as $item) { | |||
| $item->totalQuantity = $totalsByItem[$item->id] ?? 0; | |||
| $item->route = $router->route('view-item', ['item' => $item->id]); | |||
| } | |||
| return view('dashboard', [ | |||
| 'locationCount' => count(Location::all()), | |||
| 'itemCount' => count($items), | |||
| 'totalStock' => array_sum(array_map(fn($row) => $row->quantity, $stock)), | |||
| 'lowStockItems' => $lowStockItems, | |||
| ]); | |||
| } | |||
| } | |||
| @@ -1,46 +0,0 @@ | |||
| <?php | |||
| namespace App\Http\Controllers; | |||
| use App\Models\Product; | |||
| use Framework\Routing\Router; | |||
| class ShowHomePageController | |||
| { | |||
| public function handle(Router $router) | |||
| { | |||
| $cache = app('cache'); | |||
| $products = Product::all(); | |||
| $productsWithRoutes = array_map(function ($product) use ($router, $cache) { | |||
| $key = "route-for-product-{$product->id}"; | |||
| if (!$cache->has($key)) { | |||
| $cache->put($key, $router->route('view-product', ['product' => $product->id])); | |||
| } | |||
| $product->route = $cache->get($key); | |||
| return $product; | |||
| }, $products); | |||
| // app('queue')->push( | |||
| // fn($name) => app('logging')->info("Hello {$name}"), | |||
| // 'Chris', | |||
| // ); | |||
| // app('logging')->info('Send a task into the background'); | |||
| // app('queue')->push( | |||
| // fn($name) => app('email') | |||
| // ->to('cgpitt@gmail.com') | |||
| // ->text("Hello {$name}") | |||
| // ->send(), | |||
| // 'Chris', | |||
| // ); | |||
| return view('home', [ | |||
| 'products' => $productsWithRoutes, | |||
| ]); | |||
| } | |||
| } | |||
| @@ -22,6 +22,6 @@ class LogInUserController | |||
| session()->put('user_id', $user->id); | |||
| } | |||
| return redirect($router->route('show-home-page')); | |||
| return redirect($router->route('show-dashboard')); | |||
| } | |||
| } | |||
| @@ -10,6 +10,6 @@ class LogOutUserController | |||
| { | |||
| session()->forget('user_id'); | |||
| return redirect($router->route('show-home-page')); | |||
| return redirect($router->route('show-dashboard')); | |||
| } | |||
| } | |||
| @@ -24,11 +24,16 @@ class RegisterUserController | |||
| $user->save(); | |||
| session()->put('registered', true); | |||
| session()->put('user_id', $user->id); | |||
| app('queue')->push(function($user) { | |||
| // send a mail to the user... | |||
| }, $user); | |||
| app('queue')->push(function ($name, $email) { | |||
| app('email') | |||
| ->to($email) | |||
| ->subject('Welcome to the warehouse') | |||
| ->text("Hi {$name}, your account is ready. You can now create locations and manage stock.") | |||
| ->send(); | |||
| }, $user->name, $user->email); | |||
| return redirect($router->route('show-home-page')); | |||
| return redirect($router->route('show-dashboard')); | |||
| } | |||
| } | |||
| @@ -0,0 +1,20 @@ | |||
| <?php | |||
| namespace App\Models; | |||
| use Framework\Database\Model; | |||
| class Item extends Model | |||
| { | |||
| protected string $table = 'items'; | |||
| public function stock(): mixed | |||
| { | |||
| return $this->hasMany(Stock::class, 'item_id'); | |||
| } | |||
| public function movements(): mixed | |||
| { | |||
| return $this->hasMany(StockMovement::class, 'item_id'); | |||
| } | |||
| } | |||
| @@ -0,0 +1,20 @@ | |||
| <?php | |||
| namespace App\Models; | |||
| use Framework\Database\Model; | |||
| class Location extends Model | |||
| { | |||
| protected string $table = 'locations'; | |||
| public function stock(): mixed | |||
| { | |||
| return $this->hasMany(Stock::class, 'location_id'); | |||
| } | |||
| public function movements(): mixed | |||
| { | |||
| return $this->hasMany(StockMovement::class, 'location_id'); | |||
| } | |||
| } | |||
| @@ -1,15 +0,0 @@ | |||
| <?php | |||
| namespace App\Models; | |||
| use Framework\Database\Model; | |||
| class Order extends Model | |||
| { | |||
| protected string $table = 'orders'; | |||
| public function user(): mixed | |||
| { | |||
| return $this->belongsTo(User::class, 'user_id'); | |||
| } | |||
| } | |||
| @@ -1,27 +0,0 @@ | |||
| <?php | |||
| namespace App\Models; | |||
| use Framework\Database\Model; | |||
| class Product extends Model | |||
| { | |||
| protected string $table = 'products'; | |||
| public function getNameAttribute($value): string | |||
| { | |||
| return ucwords($value); | |||
| } | |||
| public function setDescriptionAttribute(string $value) | |||
| { | |||
| $limit = 50; | |||
| $ending = '...'; | |||
| if (mb_strwidth($value, 'UTF-8') <= $limit) { | |||
| return $value; | |||
| } | |||
| return rtrim(mb_strimwidth($value, 0, $limit, '', 'UTF-8')) . $ending; | |||
| } | |||
| } | |||
| @@ -0,0 +1,36 @@ | |||
| <?php | |||
| namespace App\Models; | |||
| use Framework\Database\Model; | |||
| class Stock extends Model | |||
| { | |||
| protected string $table = 'stock'; | |||
| public function item(): mixed | |||
| { | |||
| return $this->belongsTo(Item::class, 'item_id'); | |||
| } | |||
| public function location(): mixed | |||
| { | |||
| return $this->belongsTo(Location::class, 'location_id'); | |||
| } | |||
| public static function forItemAtLocation(int $itemId, int $locationId): static | |||
| { | |||
| $stock = static::where('item_id', $itemId)->where('location_id', $locationId)->first(); | |||
| if ($stock) { | |||
| return $stock; | |||
| } | |||
| $stock = new static(); | |||
| $stock->item_id = $itemId; | |||
| $stock->location_id = $locationId; | |||
| $stock->quantity = 0; | |||
| return $stock; | |||
| } | |||
| } | |||
| @@ -0,0 +1,25 @@ | |||
| <?php | |||
| namespace App\Models; | |||
| use Framework\Database\Model; | |||
| class StockMovement extends Model | |||
| { | |||
| protected string $table = 'stock_movements'; | |||
| public function item(): mixed | |||
| { | |||
| return $this->belongsTo(Item::class, 'item_id'); | |||
| } | |||
| public function location(): mixed | |||
| { | |||
| return $this->belongsTo(Location::class, 'location_id'); | |||
| } | |||
| public function user(): mixed | |||
| { | |||
| return $this->belongsTo(User::class, 'user_id'); | |||
| } | |||
| } | |||
| @@ -0,0 +1,14 @@ | |||
| <?php | |||
| namespace App\Providers; | |||
| use App\Validation\Rule\PositiveIntegerRule; | |||
| use Framework\App; | |||
| class ValidationExtensionsProvider | |||
| { | |||
| public function bind(App $app): void | |||
| { | |||
| $app->resolve('validator')->addRule('positive_integer', new PositiveIntegerRule()); | |||
| } | |||
| } | |||
| @@ -0,0 +1,22 @@ | |||
| <?php | |||
| namespace App\Validation\Rule; | |||
| use Framework\Validation\Rule\Rule; | |||
| class PositiveIntegerRule implements Rule | |||
| { | |||
| public function validate(array $data, string $field, array $params) | |||
| { | |||
| if (empty($data[$field])) { | |||
| return true; | |||
| } | |||
| return ctype_digit((string) $data[$field]) && (int) $data[$field] > 0; | |||
| } | |||
| public function getMessage(array $data, string $field, array $params) | |||
| { | |||
| return "{$field} must be a positive whole number"; | |||
| } | |||
| } | |||
| @@ -1,8 +1,20 @@ | |||
| <?php | |||
| use App\Http\Controllers\ShowHomePageController; | |||
| use App\Http\Controllers\Products\OrderProductController; | |||
| use App\Http\Controllers\Products\ShowProductController; | |||
| use App\Http\Controllers\ShowDashboardController; | |||
| use App\Http\Controllers\Locations\CreateLocationController; | |||
| use App\Http\Controllers\Locations\ShowCreateLocationFormController; | |||
| use App\Http\Controllers\Locations\ShowLocationController; | |||
| use App\Http\Controllers\Locations\ShowLocationsController; | |||
| use App\Http\Controllers\Items\AdjustStockController; | |||
| use App\Http\Controllers\Items\CreateItemController; | |||
| use App\Http\Controllers\Items\ReceiveStockController; | |||
| use App\Http\Controllers\Items\ShowAdjustStockFormController; | |||
| use App\Http\Controllers\Items\ShowCreateItemFormController; | |||
| use App\Http\Controllers\Items\ShowItemController; | |||
| use App\Http\Controllers\Items\ShowItemsController; | |||
| use App\Http\Controllers\Items\ShowReceiveStockFormController; | |||
| use App\Http\Controllers\Items\ShowWithdrawStockFormController; | |||
| use App\Http\Controllers\Items\WithdrawStockController; | |||
| use App\Http\Controllers\Users\LogInUserController; | |||
| use App\Http\Controllers\Users\LogOutUserController; | |||
| use App\Http\Controllers\Users\RegisterUserController; | |||
| @@ -16,18 +28,96 @@ return function(Router $router) { | |||
| $router->add( | |||
| 'GET', '/', | |||
| [ShowHomePageController::class, 'handle'], | |||
| )->name('show-home-page'); | |||
| [ShowDashboardController::class, 'handle'], | |||
| )->name('show-dashboard'); | |||
| // locations... | |||
| $router->add( | |||
| 'GET', '/locations', | |||
| [ShowLocationsController::class, 'handle'], | |||
| )->name('show-locations'); | |||
| $router->add( | |||
| 'GET', '/locations/create', | |||
| [ShowCreateLocationFormController::class, 'handle'], | |||
| )->name('show-create-location-form'); | |||
| $router->add( | |||
| 'POST', '/locations/create', | |||
| [CreateLocationController::class, 'handle'], | |||
| )->name('create-location'); | |||
| $router->add( | |||
| 'GET', '/locations/view/{location}', | |||
| [ShowLocationController::class, 'handle'], | |||
| )->name('view-location'); | |||
| // items... | |||
| $router->add( | |||
| 'GET', '/items', | |||
| [ShowItemsController::class, 'handle'], | |||
| )->name('show-items'); | |||
| $router->add( | |||
| 'GET', '/items/create', | |||
| [ShowCreateItemFormController::class, 'handle'], | |||
| )->name('show-create-item-form'); | |||
| $router->add( | |||
| 'GET', '/products/view/{product}', | |||
| [ShowProductController::class, 'handle'], | |||
| )->name('view-product'); | |||
| 'POST', '/items/create', | |||
| [CreateItemController::class, 'handle'], | |||
| )->name('create-item'); | |||
| $router->add( | |||
| 'POST', '/products/order/{product}', | |||
| [OrderProductController::class, 'handle'], | |||
| )->name('order-product'); | |||
| 'GET', '/items/view/{item}', | |||
| [ShowItemController::class, 'handle'], | |||
| )->name('view-item'); | |||
| // stock movements... | |||
| // | |||
| // note: each of these six paths uses a distinct literal segment | |||
| // (receive-form/receive, withdraw-form/withdraw, adjust-form/adjust). | |||
| // Framework\Routing\Route::matches() does not check the HTTP method | |||
| // once it falls through to its parameterised-path regex, and matches | |||
| // that regex unanchored — so a shorter dynamic route (like view-item | |||
| // above) would otherwise swallow any longer path that starts with the | |||
| // same segments (e.g. "/items/view/{item}/receive"), regardless of | |||
| // method. Giving every dynamic route here its own unique path segment | |||
| // sidesteps that rather than patching the router. | |||
| $router->add( | |||
| 'GET', '/items/receive-form/{item}', | |||
| [ShowReceiveStockFormController::class, 'handle'], | |||
| )->name('show-receive-stock-form'); | |||
| $router->add( | |||
| 'POST', '/items/receive/{item}', | |||
| [ReceiveStockController::class, 'handle'], | |||
| )->name('receive-stock'); | |||
| $router->add( | |||
| 'GET', '/items/withdraw-form/{item}', | |||
| [ShowWithdrawStockFormController::class, 'handle'], | |||
| )->name('show-withdraw-stock-form'); | |||
| $router->add( | |||
| 'POST', '/items/withdraw/{item}', | |||
| [WithdrawStockController::class, 'handle'], | |||
| )->name('withdraw-stock'); | |||
| $router->add( | |||
| 'GET', '/items/adjust-form/{item}', | |||
| [ShowAdjustStockFormController::class, 'handle'], | |||
| )->name('show-adjust-stock-form'); | |||
| $router->add( | |||
| 'POST', '/items/adjust/{item}', | |||
| [AdjustStockController::class, 'handle'], | |||
| )->name('adjust-stock'); | |||
| // users... | |||
| $router->add( | |||
| 'GET', '/register', | |||
| @@ -1,6 +1,6 @@ | |||
| { | |||
| "name": "whoosh/website", | |||
| "description": "A website that sells Whoosh rockets", | |||
| "name": "warehouse/app", | |||
| "description": "A warehouse management app: locations, items, and stock movements", | |||
| "scripts": { | |||
| "serve": "php command.php serve", | |||
| "test": "vendor/bin/phpunit" | |||
| @@ -1,7 +1,19 @@ | |||
| <?php | |||
| return [ | |||
| 'default' => 'postmark', | |||
| 'default' => env('EMAIL_DRIVER', 'smtp'), | |||
| 'smtp' => [ | |||
| 'type' => 'smtp', | |||
| 'host' => env('EMAIL_SMTP_HOST'), | |||
| 'port' => env('EMAIL_SMTP_PORT', 25), | |||
| 'encryption' => env('EMAIL_SMTP_ENCRYPTION'), | |||
| 'username' => env('EMAIL_SMTP_USERNAME'), | |||
| 'password' => env('EMAIL_SMTP_PASSWORD'), | |||
| 'from' => [ | |||
| 'name' => env('EMAIL_FROM_NAME'), | |||
| 'email' => env('EMAIL_FROM_EMAIL'), | |||
| ], | |||
| ], | |||
| 'postmark' => [ | |||
| 'type' => 'postmark', | |||
| 'token' => env('EMAIL_TOKEN'), | |||
| @@ -9,5 +21,5 @@ return [ | |||
| 'name' => env('EMAIL_FROM_NAME'), | |||
| 'email' => env('EMAIL_FROM_EMAIL'), | |||
| ], | |||
| ] | |||
| ], | |||
| ]; | |||
| @@ -14,4 +14,7 @@ return [ | |||
| \Framework\Provider\SessionProvider::class, | |||
| \Framework\Provider\ValidationProvider::class, | |||
| \Framework\Provider\ViewProvider::class, | |||
| // app-level providers... | |||
| \App\Providers\ValidationExtensionsProvider::class, | |||
| ]; | |||
| @@ -0,0 +1,5 @@ | |||
| <?php | |||
| return [ | |||
| 'alert_email' => env('WAREHOUSE_ALERT_EMAIL'), | |||
| ]; | |||
| @@ -1,18 +0,0 @@ | |||
| <?php | |||
| use Framework\Database\Connection\Connection; | |||
| class CreateOrdersTable | |||
| { | |||
| public function migrate(Connection $connection) | |||
| { | |||
| $table = $connection->createTable('orders'); | |||
| $table->id('id'); | |||
| $table->int('quantity')->default(1); | |||
| $table->float('price')->nullable(); | |||
| $table->bool('is_confirmed')->default(false); | |||
| $table->dateTime('ordered_at')->default('CURRENT_TIMESTAMP'); | |||
| $table->text('notes'); | |||
| $table->execute(); | |||
| } | |||
| } | |||
| @@ -1,13 +0,0 @@ | |||
| <?php | |||
| use Framework\Database\Connection\Connection; | |||
| class AddDeliveryInstructions | |||
| { | |||
| public function migrate(Connection $connection) | |||
| { | |||
| $table = $connection->alterTable('orders'); | |||
| $table->text('delivery_instructions'); | |||
| $table->execute(); | |||
| } | |||
| } | |||
| @@ -1,13 +0,0 @@ | |||
| <?php | |||
| use Framework\Database\Connection\Connection; | |||
| class ChangeQuantity | |||
| { | |||
| public function migrate(Connection $connection) | |||
| { | |||
| $table = $connection->alterTable('orders'); | |||
| $table->int('quantity')->nullable()->alter(); | |||
| $table->execute(); | |||
| } | |||
| } | |||
| @@ -0,0 +1,18 @@ | |||
| <?php | |||
| use Framework\Database\Connection\Connection; | |||
| class CreateLocationsTable | |||
| { | |||
| public function migrate(Connection $connection) | |||
| { | |||
| $table = $connection->createTable('locations'); | |||
| $table->id('id'); | |||
| $table->string('code'); | |||
| $table->string('name'); | |||
| $table->text('description'); | |||
| $table->int('capacity')->nullable(); | |||
| $table->dateTime('created_at')->default('CURRENT_TIMESTAMP'); | |||
| $table->execute(); | |||
| } | |||
| } | |||
| @@ -1,13 +0,0 @@ | |||
| <?php | |||
| use Framework\Database\Connection\Connection; | |||
| class DropPrice | |||
| { | |||
| public function migrate(Connection $connection) | |||
| { | |||
| $table = $connection->alterTable('orders'); | |||
| $table->dropColumn('price'); | |||
| $table->execute(); | |||
| } | |||
| } | |||
| @@ -2,14 +2,17 @@ | |||
| use Framework\Database\Connection\Connection; | |||
| class CreateProductsTable | |||
| class CreateItemsTable | |||
| { | |||
| public function migrate(Connection $connection) | |||
| { | |||
| $table = $connection->createTable('products'); | |||
| $table = $connection->createTable('items'); | |||
| $table->id('id'); | |||
| $table->string('sku'); | |||
| $table->string('name'); | |||
| $table->text('description'); | |||
| $table->int('reorder_level')->default(0); | |||
| $table->dateTime('created_at')->default('CURRENT_TIMESTAMP'); | |||
| $table->execute(); | |||
| } | |||
| } | |||
| @@ -0,0 +1,16 @@ | |||
| <?php | |||
| use Framework\Database\Connection\Connection; | |||
| class CreateStockTable | |||
| { | |||
| public function migrate(Connection $connection) | |||
| { | |||
| $table = $connection->createTable('stock'); | |||
| $table->id('id'); | |||
| $table->int('item_id'); | |||
| $table->int('location_id'); | |||
| $table->int('quantity')->default(0); | |||
| $table->execute(); | |||
| } | |||
| } | |||
| @@ -1,31 +0,0 @@ | |||
| <?php | |||
| use Framework\Database\Connection\Connection; | |||
| class SeedProducts | |||
| { | |||
| public function migrate(Connection $connection) | |||
| { | |||
| $products = [ | |||
| [ | |||
| 'name' => 'Space Tour', | |||
| 'description' => 'Take a trip on a rocket ship. Our tours are out of this world. Sign up now for a journey you won't soon forget.', | |||
| ], | |||
| [ | |||
| 'name' => 'Large Rocket', | |||
| 'description' => 'Need to bring some extra space-baggage? Everyone asking you to bring back a moon rock for them? This is the rocket you want...', | |||
| ], | |||
| [ | |||
| 'name' => 'Small Rocket', | |||
| 'description' => 'Space exploration is expensive. This rocket comes in under budget and atmosphere.', | |||
| ], | |||
| ]; | |||
| foreach ($products as $product) { | |||
| $connection | |||
| ->query() | |||
| ->from('products') | |||
| ->insert(['name', 'description'], $product); | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,21 @@ | |||
| <?php | |||
| use Framework\Database\Connection\Connection; | |||
| class CreateStockMovementsTable | |||
| { | |||
| public function migrate(Connection $connection) | |||
| { | |||
| $table = $connection->createTable('stock_movements'); | |||
| $table->id('id'); | |||
| $table->int('item_id'); | |||
| $table->int('location_id'); | |||
| $table->string('type'); | |||
| $table->int('quantity_change'); | |||
| $table->int('quantity_after'); | |||
| $table->text('notes'); | |||
| $table->int('user_id'); | |||
| $table->dateTime('created_at')->default('CURRENT_TIMESTAMP'); | |||
| $table->execute(); | |||
| } | |||
| } | |||
| @@ -1,13 +0,0 @@ | |||
| <?php | |||
| use Framework\Database\Connection\Connection; | |||
| class AddUserId | |||
| { | |||
| public function migrate(Connection $connection) | |||
| { | |||
| $table = $connection->alterTable('orders'); | |||
| $table->int('user_id'); | |||
| $table->execute(); | |||
| } | |||
| } | |||
| @@ -8,8 +8,8 @@ services: | |||
| depends_on: | |||
| - db | |||
| ports: | |||
| - "8080:80" | |||
| - "8081:8081" | |||
| - "9092:80" | |||
| - "9091:8081" | |||
| volumes: | |||
| - ./:/var/www/html | |||
| - vendor_data:/var/www/html/vendor | |||
| @@ -0,0 +1,103 @@ | |||
| <?php | |||
| namespace Framework\Email\Driver; | |||
| use Framework\Email\Exception\CompositionException; | |||
| use Swift_Mailer; | |||
| use Swift_Message; | |||
| use Swift_SmtpTransport; | |||
| class SmtpDriver implements Driver | |||
| { | |||
| private array $config; | |||
| private Swift_Mailer $mailer; | |||
| private string $to; | |||
| private string $subject; | |||
| private string $text; | |||
| private string $html; | |||
| public function __construct(array $config) | |||
| { | |||
| $this->config = $config; | |||
| } | |||
| public function to(string $to): static | |||
| { | |||
| $this->to = $to; | |||
| return $this; | |||
| } | |||
| public function subject(string $subject): static | |||
| { | |||
| $this->subject = $subject; | |||
| return $this; | |||
| } | |||
| public function text(string $text): static | |||
| { | |||
| $this->text = $text; | |||
| return $this; | |||
| } | |||
| public function html(string $html): static | |||
| { | |||
| $this->html = $html; | |||
| return $this; | |||
| } | |||
| public function send(): void | |||
| { | |||
| if (!isset($this->to)) { | |||
| throw new CompositionException('to required'); | |||
| } | |||
| if (!isset($this->text) && !isset($this->html)) { | |||
| throw new CompositionException('text or email required'); | |||
| } | |||
| $fromName = $this->config['from']['name']; | |||
| $fromEmail = $this->config['from']['email']; | |||
| $subject = $this->subject ?? "Message from {$fromName}"; | |||
| $message = (new Swift_Message($subject)) | |||
| ->setFrom([$fromEmail => $fromName]) | |||
| ->setTo([$this->to]); | |||
| if (isset($this->text) && !isset($this->html)) { | |||
| $message->setBody($this->text, 'text/plain'); | |||
| } | |||
| if (!isset($this->text) && isset($this->html)) { | |||
| $message->setBody($this->html, 'text/html'); | |||
| } | |||
| if (isset($this->text, $this->html)) { | |||
| $message | |||
| ->setBody($this->html, 'text/html') | |||
| ->addPart($this->text, 'text/plain'); | |||
| } | |||
| $this->mailer()->send($message); | |||
| } | |||
| private function mailer(): Swift_Mailer | |||
| { | |||
| if (!isset($this->mailer)) { | |||
| $transport = new Swift_SmtpTransport( | |||
| $this->config['host'], | |||
| $this->config['port'], | |||
| $this->config['encryption'] ?: null, | |||
| ); | |||
| if (!empty($this->config['username'])) { | |||
| $transport->setUsername($this->config['username']); | |||
| $transport->setPassword($this->config['password'] ?? ''); | |||
| } | |||
| $this->mailer = new Swift_Mailer($transport); | |||
| } | |||
| return $this->mailer; | |||
| } | |||
| } | |||
| @@ -4,6 +4,7 @@ namespace Framework\Provider; | |||
| use Framework\Email\Factory; | |||
| use Framework\Email\Driver\PostmarkDriver; | |||
| use Framework\Email\Driver\SmtpDriver; | |||
| use Framework\Support\DriverProvider; | |||
| use Framework\Support\DriverFactory; | |||
| @@ -25,6 +26,9 @@ class EmailProvider extends DriverProvider | |||
| 'postmark' => function($config) { | |||
| return new PostmarkDriver($config); | |||
| }, | |||
| 'smtp' => function($config) { | |||
| return new SmtpDriver($config); | |||
| }, | |||
| ]; | |||
| } | |||
| } | |||
| @@ -1,4 +1,6 @@ | |||
| # Whoosh website | |||
| # Warehouse | |||
| A warehousing app: define storage locations, create items, then receive, withdraw, and adjust stock, with an audit trail and low-stock email alerts. | |||
| To run the local development server, try: | |||
| @@ -6,12 +8,6 @@ To run the local development server, try: | |||
| export PHP_ENV=prod && php -S 127.0.0.1:8000 -t . | |||
| ``` | |||
| To make requests with cURL, try: | |||
| ``` | |||
| curl -X DELETE http://127.0.0.1:8000/old-home | |||
| ``` | |||
| ## Running with Docker | |||
| ``` | |||
| @@ -20,7 +16,7 @@ docker compose up -d --build | |||
| This starts two services: | |||
| - **app** — nginx + php-fpm, served at http://localhost:8080 | |||
| - **app** — nginx + php-fpm, served at http://localhost:9092 | |||
| - **db** — MySQL 8, exposed on host port `33060` (only used if `DB_CONNECTION=mysql`) | |||
| The default database connection is **SQLite** (`database/database.sqlite`), configured via `DB_CONNECTION` in `.env` / `docker-compose.yml`. On first boot the `app` container automatically creates the SQLite file with the correct permissions and runs migrations. To switch to MySQL instead, set `DB_CONNECTION=mysql` in `docker-compose.yml`. | |||
| @@ -34,7 +30,9 @@ docker compose exec app php command.php migrate --fresh # drops all tables fir | |||
| ### phpLiteAdmin | |||
| A SQLite admin UI is available at http://localhost:8081, password `admin` (change it via the `PHPLITEADMIN_PASSWORD` environment variable in `docker-compose.yml`). It's a development-only tool — don't expose port 8081 outside your local machine. | |||
| A SQLite admin UI is available at http://localhost:9091, password `admin` (change it via the `PHPLITEADMIN_PASSWORD` environment variable in `docker-compose.yml`). It's a development-only tool — don't expose this port outside your local machine. | |||
| Note: host ports `9092`/`9091` map to the app's internal `80`/`8081` — the host side is deliberately different from the container side because Windows reserves `8081` and `9090` in its dynamic TCP port exclusion range (Hyper-V/WSL2), which blocks Docker from binding either. Check with `netsh interface ipv4 show excludedportrange protocol=tcp` if you hit a similar "ports are not available ... forbidden by its access permissions" error on another port. | |||
| ## Framework guide | |||
| @@ -0,0 +1,40 @@ | |||
| @extends('layout') | |||
| @includes('includes/large-feature') | |||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||
| <h1 class="text-3xl font-bold"> | |||
| Warehouse overview | |||
| </h1> | |||
| <div class="flex flex-col md:flex-row md:space-x-4 my-8"> | |||
| <div class="bg-gray-50 rounded-lg p-4 flex-1"> | |||
| <div class="text-3xl font-bold">{{ $locationCount }}</div> | |||
| <div class="text-gray-500">Locations</div> | |||
| </div> | |||
| <div class="bg-gray-50 rounded-lg p-4 flex-1"> | |||
| <div class="text-3xl font-bold">{{ $itemCount }}</div> | |||
| <div class="text-gray-500">Items</div> | |||
| </div> | |||
| <div class="bg-gray-50 rounded-lg p-4 flex-1"> | |||
| <div class="text-3xl font-bold">{{ $totalStock }}</div> | |||
| <div class="text-gray-500">Units in stock</div> | |||
| </div> | |||
| </div> | |||
| <h2 class="text-2xl font-bold"> | |||
| Low stock | |||
| </h2> | |||
| @if(count($lowStockItems) === 0) | |||
| <p class="text-gray-500 my-4">Nothing is at or below its reorder level.</p> | |||
| @endif | |||
| @if(count($lowStockItems) > 0) | |||
| <ul class="my-4 space-y-2"> | |||
| @foreach($lowStockItems as $item) | |||
| <li class="bg-red-50 rounded-lg p-4 flex justify-between items-center"> | |||
| <a href="{{ $item->route }}" class="font-bold underline">{{ $item->name }}</a> | |||
| <span class="text-red-500">{{ $item->totalQuantity }} left (reorder at {{ $item->reorder_level }})</span> | |||
| </li> | |||
| @endforeach | |||
| </ul> | |||
| @endif | |||
| </div> | |||
| @@ -1,22 +0,0 @@ | |||
| @extends('layout') | |||
| @includes('includes/large-feature') | |||
| @foreach($products as $i => $product) | |||
| <div class=" | |||
| z-10 | |||
| @if($i % 2 === 0) | |||
| bg-gray-50 | |||
| @endif | |||
| "> | |||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||
| <h2 class="text-3xl font-bold"> | |||
| {{ $product->name }} | |||
| </h2> | |||
| <p class="text-xl my-4"> | |||
| {!! $product->description !!} | |||
| </p> | |||
| <a href="{{ $product->route }}" class="bg-indigo-500 rounded-lg p-2 text-white"> | |||
| Order | |||
| </a> | |||
| </div> | |||
| </div> | |||
| @endforeach | |||
| @@ -1,22 +1,24 @@ | |||
| <div class="bg-gray-900 z-20"> | |||
| <div class="container mx-auto px-8 py-8 md:py-16 flex flex-col md:flex-row items-center text-center md:text-left"> | |||
| <div class="flex flex-shrink justify-end mb-8 md:-mb-20 pr-0 md:pr-16"> | |||
| @includes('rocket') | |||
| <div class="flex flex-shrink justify-end mb-8 md:-mb-4 pr-0 md:pr-16 text-6xl"> | |||
| 📦 | |||
| </div> | |||
| <div class="flex flex-col justify-center flex-grow"> | |||
| <a href="/" class="text-4xl text-white"> | |||
| Whoosh! | |||
| Warehouse | |||
| </a> | |||
| <div class="text-2xl text-gray-300"> | |||
| A place to buy rocket things | |||
| Locations, items, and stock, all in one place | |||
| </div> | |||
| <ol class="text-white flex flex-row space-x-2"> | |||
| <li><a class="underline" href="/">Home</a></li> | |||
| <li><a class="underline" href="/">Dashboard</a></li> | |||
| <li><a class="underline" href="/locations">Locations</a></li> | |||
| <li><a class="underline" href="/items">Items</a></li> | |||
| @if(session()->has('user_id')) | |||
| <li><a class="underline" href="/log-out">Log out</a></li> | |||
| @endif | |||
| @if(!session()->has('user_id')) | |||
| <li><a class="underline" href="/register">Register</a></li> | |||
| <li><a class="underline" href="/register">Register / Log in</a></li> | |||
| @endif | |||
| </ol> | |||
| </div> | |||
| @@ -1,19 +1,21 @@ | |||
| <div class="bg-gray-900 z-20"> | |||
| <div class="container mx-auto px-8 py-4 flex flex-col md:flex-row items-center text-center md:text-left"> | |||
| <div class="flex flex-shrink items-center pr-0 md:pr-4 h-32"> | |||
| @includes('rocket') | |||
| <div class="flex flex-shrink items-center pr-0 md:pr-4 h-32 text-5xl"> | |||
| 📦 | |||
| </div> | |||
| <div class="flex flex-col justify-center flex-grow"> | |||
| <a href="/" class="text-4xl text-white"> | |||
| Whoosh! | |||
| Warehouse | |||
| </a> | |||
| <ol class="text-white flex flex-row space-x-2"> | |||
| <li><a class="underline" href="/">Home</a></li> | |||
| <li><a class="underline" href="/">Dashboard</a></li> | |||
| <li><a class="underline" href="/locations">Locations</a></li> | |||
| <li><a class="underline" href="/items">Items</a></li> | |||
| @if(session()->has('user_id')) | |||
| <li><a class="underline" href="/log-out">Log out</a></li> | |||
| @endif | |||
| @if(!session()->has('user_id')) | |||
| <li><a class="underline" href="/register">Register</a></li> | |||
| <li><a class="underline" href="/register">Register / Log in</a></li> | |||
| @endif | |||
| </ol> | |||
| </div> | |||
| @@ -0,0 +1,72 @@ | |||
| @extends('layout') | |||
| @includes('includes/small-feature') | |||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||
| <h1 class="text-3xl font-bold"> | |||
| Adjust stock — {{ $item->name }} | |||
| </h1> | |||
| <p class="text-gray-500 my-4"> | |||
| Use this after a physical count to correct the recorded quantity at a location. Enter the new total, not a delta. | |||
| </p> | |||
| <ul class="my-4 space-y-1 text-gray-500"> | |||
| @foreach($locations as $location) | |||
| <li>{{ $location->code }} — {{ $location->name }}: {{ $quantitiesByLocation[$location->id] ?? 0 }} currently recorded</li> | |||
| @endforeach | |||
| </ul> | |||
| <form | |||
| method="post" | |||
| action="{{ $adjustAction }}" | |||
| class="flex flex-col w-full space-y-4 max-w-xl my-4" | |||
| > | |||
| @if(session()->has('adjust_errors')) | |||
| <ol class="list-disc text-red-500"> | |||
| @foreach(session('adjust_errors') as $field => $errors) | |||
| @foreach($errors as $error) | |||
| <li>{{ $error }}</li> | |||
| @endforeach | |||
| @endforeach | |||
| </ol> | |||
| @endif | |||
| <input type="hidden" name="csrf" value="{{ $csrf }}" /> | |||
| <label for="location_id" class="flex flex-col w-full"> | |||
| <span class="flex">Location:</span> | |||
| <select | |||
| id="location_id" | |||
| name="location_id" | |||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||
| > | |||
| @foreach($locations as $location) | |||
| <option value="{{ $location->id }}">{{ $location->code }} — {{ $location->name }}</option> | |||
| @endforeach | |||
| </select> | |||
| </label> | |||
| <label for="quantity" class="flex flex-col w-full"> | |||
| <span class="flex">New quantity:</span> | |||
| <input | |||
| id="quantity" | |||
| name="quantity" | |||
| type="number" | |||
| min="0" | |||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||
| placeholder="0" | |||
| /> | |||
| </label> | |||
| <label for="notes" class="flex flex-col w-full"> | |||
| <span class="flex">Notes (optional):</span> | |||
| <input | |||
| id="notes" | |||
| name="notes" | |||
| type="text" | |||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||
| placeholder="Physical count on 2026-07-21" | |||
| /> | |||
| </label> | |||
| <button | |||
| type="submit" | |||
| class="bg-indigo-500 rounded-lg p-2 text-white" | |||
| > | |||
| Save adjustment | |||
| </button> | |||
| </form> | |||
| </div> | |||
| @@ -0,0 +1,68 @@ | |||
| @extends('layout') | |||
| @includes('includes/small-feature') | |||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||
| <h1 class="text-3xl font-bold"> | |||
| Add an item | |||
| </h1> | |||
| <form | |||
| method="post" | |||
| action="{{ $createAction }}" | |||
| class="flex flex-col w-full space-y-4 max-w-xl my-4" | |||
| > | |||
| @if(session()->has('create_item_errors')) | |||
| <ol class="list-disc text-red-500"> | |||
| @foreach(session('create_item_errors') as $field => $errors) | |||
| @foreach($errors as $error) | |||
| <li>{{ $error }}</li> | |||
| @endforeach | |||
| @endforeach | |||
| </ol> | |||
| @endif | |||
| <input type="hidden" name="csrf" value="{{ $csrf }}" /> | |||
| <label for="sku" class="flex flex-col w-full"> | |||
| <span class="flex">SKU:</span> | |||
| <input | |||
| id="sku" | |||
| name="sku" | |||
| type="text" | |||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||
| placeholder="WH-001" | |||
| /> | |||
| </label> | |||
| <label for="name" class="flex flex-col w-full"> | |||
| <span class="flex">Name:</span> | |||
| <input | |||
| id="name" | |||
| name="name" | |||
| type="text" | |||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||
| placeholder="Packing tape" | |||
| /> | |||
| </label> | |||
| <label for="description" class="flex flex-col w-full"> | |||
| <span class="flex">Description:</span> | |||
| <input | |||
| id="description" | |||
| name="description" | |||
| type="text" | |||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||
| /> | |||
| </label> | |||
| <label for="reorder_level" class="flex flex-col w-full"> | |||
| <span class="flex">Reorder level (0 = no alert):</span> | |||
| <input | |||
| id="reorder_level" | |||
| name="reorder_level" | |||
| type="number" | |||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||
| placeholder="10" | |||
| /> | |||
| </label> | |||
| <button | |||
| type="submit" | |||
| class="bg-indigo-500 rounded-lg p-2 text-white" | |||
| > | |||
| Add item | |||
| </button> | |||
| </form> | |||
| </div> | |||
| @@ -0,0 +1,35 @@ | |||
| @extends('layout') | |||
| @includes('includes/small-feature') | |||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||
| <div class="flex justify-between items-center"> | |||
| <h1 class="text-3xl font-bold"> | |||
| Items | |||
| </h1> | |||
| @if($canManage) | |||
| <a href="{{ $createAction }}" class="bg-indigo-500 rounded-lg p-2 text-white"> | |||
| Add item | |||
| </a> | |||
| @endif | |||
| </div> | |||
| @if(count($items) === 0) | |||
| <p class="text-gray-500 my-4">No items yet.</p> | |||
| @endif | |||
| <ul class="my-4 space-y-2"> | |||
| @foreach($items as $item) | |||
| <li class=" | |||
| rounded-lg p-4 flex justify-between items-center | |||
| @if($item->isLowStock) bg-red-50 @endif | |||
| @if(!$item->isLowStock) bg-gray-50 @endif | |||
| "> | |||
| <a href="{{ $item->route }}" class="font-bold underline"> | |||
| {{ $item->sku }} — {{ $item->name }} | |||
| </a> | |||
| <span class="@if($item->isLowStock) text-red-500 @endif"> | |||
| {{ $item->totalQuantity }} units | |||
| </span> | |||
| </li> | |||
| @endforeach | |||
| </ul> | |||
| </div> | |||
| @@ -0,0 +1,60 @@ | |||
| @extends('layout') | |||
| @includes('includes/small-feature') | |||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||
| <h1 class="text-3xl font-bold"> | |||
| Receive stock — {{ $item->name }} | |||
| </h1> | |||
| <form | |||
| method="post" | |||
| action="{{ $receiveAction }}" | |||
| class="flex flex-col w-full space-y-4 max-w-xl my-4" | |||
| > | |||
| @if(session()->has('receive_errors')) | |||
| <ol class="list-disc text-red-500"> | |||
| @foreach(session('receive_errors') as $field => $errors) | |||
| @foreach($errors as $error) | |||
| <li>{{ $error }}</li> | |||
| @endforeach | |||
| @endforeach | |||
| </ol> | |||
| @endif | |||
| <input type="hidden" name="csrf" value="{{ $csrf }}" /> | |||
| <label for="location_id" class="flex flex-col w-full"> | |||
| <span class="flex">Location:</span> | |||
| <select | |||
| id="location_id" | |||
| name="location_id" | |||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||
| > | |||
| @foreach($locations as $location) | |||
| <option value="{{ $location->id }}">{{ $location->code }} — {{ $location->name }}</option> | |||
| @endforeach | |||
| </select> | |||
| </label> | |||
| <label for="quantity" class="flex flex-col w-full"> | |||
| <span class="flex">Quantity:</span> | |||
| <input | |||
| id="quantity" | |||
| name="quantity" | |||
| type="number" | |||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||
| placeholder="1" | |||
| /> | |||
| </label> | |||
| <label for="notes" class="flex flex-col w-full"> | |||
| <span class="flex">Notes (optional):</span> | |||
| <input | |||
| id="notes" | |||
| name="notes" | |||
| type="text" | |||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||
| /> | |||
| </label> | |||
| <button | |||
| type="submit" | |||
| class="bg-indigo-500 rounded-lg p-2 text-white" | |||
| > | |||
| Receive | |||
| </button> | |||
| </form> | |||
| </div> | |||
| @@ -0,0 +1,77 @@ | |||
| @extends('layout') | |||
| @includes('includes/small-feature') | |||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||
| <h1 class="text-3xl font-bold"> | |||
| {{ $item->sku }} — {{ $item->name }} | |||
| </h1> | |||
| @if($item->description) | |||
| <p class="text-xl my-4"> | |||
| {{ $item->description }} | |||
| </p> | |||
| @endif | |||
| <p class="text-gray-500"> | |||
| {{ $totalQuantity }} units in stock | |||
| @if($item->reorder_level > 0) | |||
| (reorder at {{ $item->reorder_level }}) | |||
| @endif | |||
| </p> | |||
| @if($canManage) | |||
| <div class="flex space-x-4 my-4"> | |||
| <a href="{{ $receiveAction }}" class="bg-indigo-500 rounded-lg p-2 text-white">Receive stock</a> | |||
| <a href="{{ $withdrawAction }}" class="bg-indigo-500 rounded-lg p-2 text-white">Withdraw stock</a> | |||
| <a href="{{ $adjustAction }}" class="bg-indigo-500 rounded-lg p-2 text-white">Adjust stock</a> | |||
| </div> | |||
| @endif | |||
| <h2 class="text-2xl font-bold mt-8"> | |||
| Stock by location | |||
| </h2> | |||
| @if(count($stock) === 0) | |||
| <p class="text-gray-500 my-4">Not stored anywhere yet.</p> | |||
| @endif | |||
| <ul class="my-4 space-y-2"> | |||
| @foreach($stock as $row) | |||
| <li class="bg-gray-50 rounded-lg p-4 flex justify-between items-center"> | |||
| <a href="{{ $row->locationRoute }}" class="font-bold underline">{{ $row->location->code }} — {{ $row->location->name }}</a> | |||
| <span class="text-gray-500">{{ $row->quantity }} units</span> | |||
| </li> | |||
| @endforeach | |||
| </ul> | |||
| <h2 class="text-2xl font-bold mt-8"> | |||
| Recent activity | |||
| </h2> | |||
| @if(count($movements) === 0) | |||
| <p class="text-gray-500 my-4">No stock movements yet.</p> | |||
| @endif | |||
| <ul class="my-4 space-y-2"> | |||
| @foreach($movements as $movement) | |||
| <li class="bg-gray-50 rounded-lg p-2 flex justify-between items-center text-sm"> | |||
| <span> | |||
| {{ $movement->type }} — {{ $movement->location->code }} | |||
| @if($movement->notes) | |||
| — {{ $movement->notes }} | |||
| @endif | |||
| </span> | |||
| <span class=" | |||
| @if($movement->quantity_change < 0) | |||
| text-red-500 | |||
| @endif | |||
| @if($movement->quantity_change >= 0) | |||
| text-green-600 | |||
| @endif | |||
| "> | |||
| @if($movement->quantity_change >= 0) | |||
| + | |||
| @endif | |||
| {{ $movement->quantity_change }} | |||
| </span> | |||
| </li> | |||
| @endforeach | |||
| </ul> | |||
| </div> | |||
| @@ -0,0 +1,67 @@ | |||
| @extends('layout') | |||
| @includes('includes/small-feature') | |||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||
| <h1 class="text-3xl font-bold"> | |||
| Withdraw stock — {{ $item->name }} | |||
| </h1> | |||
| @if(count($stock) === 0) | |||
| <p class="text-gray-500 my-4">There's no stock anywhere to withdraw from.</p> | |||
| @endif | |||
| @if(count($stock) > 0) | |||
| <form | |||
| method="post" | |||
| action="{{ $withdrawAction }}" | |||
| class="flex flex-col w-full space-y-4 max-w-xl my-4" | |||
| > | |||
| @if(session()->has('withdraw_errors')) | |||
| <ol class="list-disc text-red-500"> | |||
| @foreach(session('withdraw_errors') as $field => $errors) | |||
| @foreach($errors as $error) | |||
| <li>{{ $error }}</li> | |||
| @endforeach | |||
| @endforeach | |||
| </ol> | |||
| @endif | |||
| <input type="hidden" name="csrf" value="{{ $csrf }}" /> | |||
| <label for="location_id" class="flex flex-col w-full"> | |||
| <span class="flex">Location:</span> | |||
| <select | |||
| id="location_id" | |||
| name="location_id" | |||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||
| > | |||
| @foreach($stock as $row) | |||
| <option value="{{ $row->location_id }}">{{ $row->location->code }} — {{ $row->location->name }} ({{ $row->quantity }} available)</option> | |||
| @endforeach | |||
| </select> | |||
| </label> | |||
| <label for="quantity" class="flex flex-col w-full"> | |||
| <span class="flex">Quantity:</span> | |||
| <input | |||
| id="quantity" | |||
| name="quantity" | |||
| type="number" | |||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||
| placeholder="1" | |||
| /> | |||
| </label> | |||
| <label for="notes" class="flex flex-col w-full"> | |||
| <span class="flex">Notes (optional):</span> | |||
| <input | |||
| id="notes" | |||
| name="notes" | |||
| type="text" | |||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||
| /> | |||
| </label> | |||
| <button | |||
| type="submit" | |||
| class="bg-indigo-500 rounded-lg p-2 text-white" | |||
| > | |||
| Withdraw | |||
| </button> | |||
| </form> | |||
| @endif | |||
| </div> | |||
| @@ -1,7 +1,7 @@ | |||
| <!doctype html> | |||
| <html lang="en"> | |||
| <head> | |||
| <title>Whoosh!</title> | |||
| <title>Warehouse</title> | |||
| <link rel="preconnect" href="https://fonts.gstatic.com"> | |||
| <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@500;700&display=swap"> | |||
| <link rel="stylesheet" href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css"> | |||
| @@ -0,0 +1,68 @@ | |||
| @extends('layout') | |||
| @includes('includes/small-feature') | |||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||
| <h1 class="text-3xl font-bold"> | |||
| Add a location | |||
| </h1> | |||
| <form | |||
| method="post" | |||
| action="{{ $createAction }}" | |||
| class="flex flex-col w-full space-y-4 max-w-xl my-4" | |||
| > | |||
| @if(session()->has('create_location_errors')) | |||
| <ol class="list-disc text-red-500"> | |||
| @foreach(session('create_location_errors') as $field => $errors) | |||
| @foreach($errors as $error) | |||
| <li>{{ $error }}</li> | |||
| @endforeach | |||
| @endforeach | |||
| </ol> | |||
| @endif | |||
| <input type="hidden" name="csrf" value="{{ $csrf }}" /> | |||
| <label for="code" class="flex flex-col w-full"> | |||
| <span class="flex">Code:</span> | |||
| <input | |||
| id="code" | |||
| name="code" | |||
| type="text" | |||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||
| placeholder="A1-01" | |||
| /> | |||
| </label> | |||
| <label for="name" class="flex flex-col w-full"> | |||
| <span class="flex">Name:</span> | |||
| <input | |||
| id="name" | |||
| name="name" | |||
| type="text" | |||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||
| placeholder="Aisle A, Shelf 1" | |||
| /> | |||
| </label> | |||
| <label for="description" class="flex flex-col w-full"> | |||
| <span class="flex">Description:</span> | |||
| <input | |||
| id="description" | |||
| name="description" | |||
| type="text" | |||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||
| /> | |||
| </label> | |||
| <label for="capacity" class="flex flex-col w-full"> | |||
| <span class="flex">Capacity (optional):</span> | |||
| <input | |||
| id="capacity" | |||
| name="capacity" | |||
| type="number" | |||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||
| placeholder="100" | |||
| /> | |||
| </label> | |||
| <button | |||
| type="submit" | |||
| class="bg-indigo-500 rounded-lg p-2 text-white" | |||
| > | |||
| Add location | |||
| </button> | |||
| </form> | |||
| </div> | |||
| @@ -0,0 +1,35 @@ | |||
| @extends('layout') | |||
| @includes('includes/small-feature') | |||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||
| <div class="flex justify-between items-center"> | |||
| <h1 class="text-3xl font-bold"> | |||
| Locations | |||
| </h1> | |||
| @if($canManage) | |||
| <a href="{{ $createAction }}" class="bg-indigo-500 rounded-lg p-2 text-white"> | |||
| Add location | |||
| </a> | |||
| @endif | |||
| </div> | |||
| @if(count($locations) === 0) | |||
| <p class="text-gray-500 my-4">No locations yet.</p> | |||
| @endif | |||
| <ul class="my-4 space-y-2"> | |||
| @foreach($locations as $location) | |||
| <li class="bg-gray-50 rounded-lg p-4 flex justify-between items-center"> | |||
| <a href="{{ $location->route }}" class="font-bold underline"> | |||
| {{ $location->code }} — {{ $location->name }} | |||
| </a> | |||
| <span class="text-gray-500"> | |||
| {{ $location->totalQuantity }} | |||
| @if($location->capacity) | |||
| / {{ $location->capacity }} | |||
| @endif | |||
| units | |||
| </span> | |||
| </li> | |||
| @endforeach | |||
| </ul> | |||
| </div> | |||
| @@ -0,0 +1,34 @@ | |||
| @extends('layout') | |||
| @includes('includes/small-feature') | |||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||
| <h1 class="text-3xl font-bold"> | |||
| {{ $location->code }} — {{ $location->name }} | |||
| </h1> | |||
| @if($location->description) | |||
| <p class="text-xl my-4"> | |||
| {{ $location->description }} | |||
| </p> | |||
| @endif | |||
| @if($location->capacity) | |||
| <p class="text-gray-500"> | |||
| Capacity: {{ $location->capacity }} units | |||
| </p> | |||
| @endif | |||
| <h2 class="text-2xl font-bold mt-8"> | |||
| Stock held here | |||
| </h2> | |||
| @if(count($stock) === 0) | |||
| <p class="text-gray-500 my-4">Nothing is stored here yet.</p> | |||
| @endif | |||
| <ul class="my-4 space-y-2"> | |||
| @foreach($stock as $row) | |||
| <li class="bg-gray-50 rounded-lg p-4 flex justify-between items-center"> | |||
| <a href="{{ $row->itemRoute }}" class="font-bold underline">{{ $row->item->name }}</a> | |||
| <span class="text-gray-500">{{ $row->quantity }} units</span> | |||
| </li> | |||
| @endforeach | |||
| </ul> | |||
| </div> | |||
| @@ -1,45 +0,0 @@ | |||
| @extends('layout') | |||
| @includes('includes/large-feature') | |||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||
| <h1 class="text-3xl font-bold"> | |||
| {{ $product->name }} | |||
| </h1> | |||
| <p class="text-xl my-4"> | |||
| {!! $product->description !!} | |||
| </p> | |||
| <h2 class="text-2xl font-bold"> | |||
| Order | |||
| </h2> | |||
| <form | |||
| method="post" | |||
| action="{{ $orderAction }}" | |||
| class="flex flex-col w-full space-y-4 max-w-xl" | |||
| > | |||
| @if(session()->has('errors')) | |||
| <ol class="list-disc text-red-500"> | |||
| @foreach(session('errors') as $field => $errors) | |||
| @foreach($errors as $error) | |||
| <li>{{ $error }}</li> | |||
| @endforeach | |||
| @endforeach | |||
| </ol> | |||
| @endif | |||
| <input type="hidden" name="csrf" value="{{ $csrf }}" /> | |||
| <label for="quantity" class="flex flex-col w-full"> | |||
| <span class="flex">Quantity:</span> | |||
| <input | |||
| id="quantity" | |||
| name="quantity" | |||
| type="number" | |||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||
| placeholder="1" | |||
| /> | |||
| </label> | |||
| <button | |||
| type="submit" | |||
| class="bg-indigo-500 rounded-lg p-2 text-white" | |||
| > | |||
| Order | |||
| </button> | |||
| </form> | |||
| </div> | |||
| @@ -10,7 +10,7 @@ class RoutingTest extends TestCase | |||
| $_SERVER['REQUEST_METHOD'] = 'GET'; | |||
| $_SERVER['REQUEST_URI'] = '/'; | |||
| $expected = 'Take a trip on a rocket ship'; | |||
| $expected = 'Warehouse overview'; | |||
| $this->assertStringContainsString($expected, app()->run()->content()); | |||
| } | |||
| @@ -0,0 +1,97 @@ | |||
| <?php | |||
| use Framework\Testing\TestCase; | |||
| use Framework\Testing\TestResponse; | |||
| class WarehouseTest extends TestCase | |||
| { | |||
| public function testCreatingALocationRequiresLogin() | |||
| { | |||
| $_SERVER['REQUEST_METHOD'] = 'POST'; | |||
| $_SERVER['REQUEST_URI'] = '/locations/create'; | |||
| $_POST['csrf'] = csrf(); | |||
| $_POST['code'] = 'A1'; | |||
| $_POST['name'] = 'Aisle 1'; | |||
| $response = new TestResponse(app()->run()); | |||
| $this->assertTrue($response->isRedirecting()); | |||
| $this->assertEquals('/register', $response->redirectingTo()); | |||
| } | |||
| public function testLoggedInUserCanCreateALocation() | |||
| { | |||
| session()->put('user_id', 1); | |||
| $_SERVER['REQUEST_METHOD'] = 'POST'; | |||
| $_SERVER['REQUEST_URI'] = '/locations/create'; | |||
| $_POST['csrf'] = csrf(); | |||
| $_POST['code'] = 'A1'; | |||
| $_POST['name'] = 'Aisle 1'; | |||
| $_POST['description'] = ''; | |||
| $response = new TestResponse(app()->run()); | |||
| $this->assertTrue($response->isRedirecting()); | |||
| $_SERVER['REQUEST_METHOD'] = 'GET'; | |||
| $_SERVER['REQUEST_URI'] = '/locations'; | |||
| $content = app()->run()->content(); | |||
| $this->assertStringContainsString('Aisle 1', $content); | |||
| } | |||
| public function testReceivingStockIncreasesTheItemsQuantity() | |||
| { | |||
| session()->put('user_id', 1); | |||
| $_SERVER['REQUEST_METHOD'] = 'POST'; | |||
| $_SERVER['REQUEST_URI'] = '/locations/create'; | |||
| $_POST = [ | |||
| 'csrf' => csrf(), | |||
| 'code' => 'B1', | |||
| 'name' => 'Bay 1', | |||
| 'description' => '', | |||
| ]; | |||
| $locationResponse = new TestResponse(app()->run()); | |||
| preg_match('#/locations/view/(\d+)#', $locationResponse->redirectingTo(), $locationMatch); | |||
| $locationId = (int) $locationMatch[1]; | |||
| $_SERVER['REQUEST_METHOD'] = 'POST'; | |||
| $_SERVER['REQUEST_URI'] = '/items/create'; | |||
| $_POST = [ | |||
| 'csrf' => csrf(), | |||
| 'sku' => 'SKU-1', | |||
| 'name' => 'Widget', | |||
| 'description' => '', | |||
| 'reorder_level' => '', | |||
| ]; | |||
| $itemResponse = new TestResponse(app()->run()); | |||
| preg_match('#/items/view/(\d+)#', $itemResponse->redirectingTo(), $itemMatch); | |||
| $itemId = (int) $itemMatch[1]; | |||
| $_SERVER['REQUEST_METHOD'] = 'POST'; | |||
| $_SERVER['REQUEST_URI'] = "/items/receive/{$itemId}"; | |||
| $_POST = [ | |||
| 'csrf' => csrf(), | |||
| 'location_id' => (string) $locationId, | |||
| 'quantity' => '5', | |||
| 'notes' => '', | |||
| ]; | |||
| $receiveResponse = new TestResponse(app()->run()); | |||
| $this->assertTrue($receiveResponse->isRedirecting()); | |||
| $_SERVER['REQUEST_METHOD'] = 'GET'; | |||
| $_SERVER['REQUEST_URI'] = "/items/view/{$itemId}"; | |||
| $content = app()->run()->content(); | |||
| $this->assertStringContainsString('5 units in stock', $content); | |||
| } | |||
| } | |||
Powered by TurnKey Linux.