From 2e172397661a957f216c841526898aa63658fa57 Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Tue, 21 Jul 2026 16:18:07 -0400 Subject: [PATCH] Replace rocket-shop demo with a warehouse management app 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 --- .env.example | 14 +- .gitattributes | 3 + AGENTS.md | 154 ++++++++++++++++++ CLAUDE.md | 154 ++++++++++++++++++ .../Items/AdjustStockController.php | 97 +++++++++++ .../Items/CreateItemController.php | 34 ++++ .../Items/ReceiveStockController.php | 57 +++++++ .../Items/ShowAdjustStockFormController.php | 41 +++++ .../Items/ShowCreateItemFormController.php | 20 +++ .../Controllers/Items/ShowItemController.php | 46 ++++++ .../Controllers/Items/ShowItemsController.php | 34 ++++ .../Items/ShowReceiveStockFormController.php | 32 ++++ .../Items/ShowWithdrawStockFormController.php | 35 ++++ .../Items/WithdrawStockController.php | 93 +++++++++++ .../Locations/CreateLocationController.php | 34 ++++ .../ShowCreateLocationFormController.php | 20 +++ .../Locations/ShowLocationController.php | 33 ++++ .../Locations/ShowLocationsController.php | 33 ++++ .../Products/OrderProductController.php | 19 --- .../Products/ShowProductController.php | 22 --- .../Controllers/ShowDashboardController.php | 39 +++++ .../Controllers/ShowHomePageController.php | 46 ------ .../Controllers/Users/LogInUserController.php | 2 +- .../Users/LogOutUserController.php | 2 +- .../Users/RegisterUserController.php | 13 +- app/Models/Item.php | 20 +++ app/Models/Location.php | 20 +++ app/Models/Order.php | 15 -- app/Models/Product.php | 27 --- app/Models/Stock.php | 36 ++++ app/Models/StockMovement.php | 25 +++ .../ValidationExtensionsProvider.php | 14 ++ app/Validation/Rule/PositiveIntegerRule.php | 22 +++ app/routes.php | 112 +++++++++++-- composer.json | 4 +- config/email.php | 16 +- config/providers.php | 3 + config/warehouse.php | 5 + database/migrations/001_CreateOrdersTable.php | 18 -- ...sersTable.php => 001_CreateUsersTable.php} | 0 .../002_AddDeliveryInstructions.php | 13 -- ...sTable.php => 002_CreateProfilesTable.php} | 0 database/migrations/003_ChangeQuantity.php | 13 -- ...eJobsTable.php => 003_CreateJobsTable.php} | 0 .../migrations/004_CreateLocationsTable.php | 18 ++ database/migrations/004_DropPrice.php | 13 -- ...uctsTable.php => 005_CreateItemsTable.php} | 7 +- database/migrations/006_CreateStockTable.php | 16 ++ database/migrations/006_SeedProducts.php | 31 ---- .../007_CreateStockMovementsTable.php | 21 +++ database/migrations/009_AddUserId.php | 13 -- docker-compose.yml | 4 +- framework/Email/Driver/SmtpDriver.php | 103 ++++++++++++ framework/Provider/EmailProvider.php | 4 + readme.md | 16 +- resources/views/dashboard.advanced.php | 40 +++++ resources/views/home.advanced.php | 22 --- .../views/includes/large-feature.advanced.php | 14 +- .../views/includes/small-feature.advanced.php | 12 +- resources/views/items/adjust.advanced.php | 72 ++++++++ resources/views/items/create.advanced.php | 68 ++++++++ resources/views/items/index.advanced.php | 35 ++++ resources/views/items/receive.advanced.php | 60 +++++++ resources/views/items/view.advanced.php | 77 +++++++++ resources/views/items/withdraw.advanced.php | 67 ++++++++ resources/views/layout.advanced.php | 2 +- resources/views/locations/create.advanced.php | 68 ++++++++ resources/views/locations/index.advanced.php | 35 ++++ resources/views/locations/view.advanced.php | 34 ++++ resources/views/products/view.advanced.php | 45 ----- tests/RoutingTest.php | 2 +- tests/WarehouseTest.php | 97 +++++++++++ 72 files changed, 2091 insertions(+), 345 deletions(-) create mode 100644 .gitattributes create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 app/Http/Controllers/Items/AdjustStockController.php create mode 100644 app/Http/Controllers/Items/CreateItemController.php create mode 100644 app/Http/Controllers/Items/ReceiveStockController.php create mode 100644 app/Http/Controllers/Items/ShowAdjustStockFormController.php create mode 100644 app/Http/Controllers/Items/ShowCreateItemFormController.php create mode 100644 app/Http/Controllers/Items/ShowItemController.php create mode 100644 app/Http/Controllers/Items/ShowItemsController.php create mode 100644 app/Http/Controllers/Items/ShowReceiveStockFormController.php create mode 100644 app/Http/Controllers/Items/ShowWithdrawStockFormController.php create mode 100644 app/Http/Controllers/Items/WithdrawStockController.php create mode 100644 app/Http/Controllers/Locations/CreateLocationController.php create mode 100644 app/Http/Controllers/Locations/ShowCreateLocationFormController.php create mode 100644 app/Http/Controllers/Locations/ShowLocationController.php create mode 100644 app/Http/Controllers/Locations/ShowLocationsController.php delete mode 100644 app/Http/Controllers/Products/OrderProductController.php delete mode 100644 app/Http/Controllers/Products/ShowProductController.php create mode 100644 app/Http/Controllers/ShowDashboardController.php delete mode 100644 app/Http/Controllers/ShowHomePageController.php create mode 100644 app/Models/Item.php create mode 100644 app/Models/Location.php delete mode 100644 app/Models/Order.php delete mode 100644 app/Models/Product.php create mode 100644 app/Models/Stock.php create mode 100644 app/Models/StockMovement.php create mode 100644 app/Providers/ValidationExtensionsProvider.php create mode 100644 app/Validation/Rule/PositiveIntegerRule.php create mode 100644 config/warehouse.php delete mode 100644 database/migrations/001_CreateOrdersTable.php rename database/migrations/{007_CreateUsersTable.php => 001_CreateUsersTable.php} (100%) delete mode 100644 database/migrations/002_AddDeliveryInstructions.php rename database/migrations/{008_CreateProfilesTable.php => 002_CreateProfilesTable.php} (100%) delete mode 100644 database/migrations/003_ChangeQuantity.php rename database/migrations/{010_CreateJobsTable.php => 003_CreateJobsTable.php} (100%) create mode 100644 database/migrations/004_CreateLocationsTable.php delete mode 100644 database/migrations/004_DropPrice.php rename database/migrations/{005_CreateProductsTable.php => 005_CreateItemsTable.php} (51%) create mode 100644 database/migrations/006_CreateStockTable.php delete mode 100644 database/migrations/006_SeedProducts.php create mode 100644 database/migrations/007_CreateStockMovementsTable.php delete mode 100644 database/migrations/009_AddUserId.php create mode 100644 framework/Email/Driver/SmtpDriver.php create mode 100644 resources/views/dashboard.advanced.php delete mode 100644 resources/views/home.advanced.php create mode 100644 resources/views/items/adjust.advanced.php create mode 100644 resources/views/items/create.advanced.php create mode 100644 resources/views/items/index.advanced.php create mode 100644 resources/views/items/receive.advanced.php create mode 100644 resources/views/items/view.advanced.php create mode 100644 resources/views/items/withdraw.advanced.php create mode 100644 resources/views/locations/create.advanced.php create mode 100644 resources/views/locations/index.advanced.php create mode 100644 resources/views/locations/view.advanced.php delete mode 100644 resources/views/products/view.advanced.php create mode 100644 tests/WarehouseTest.php diff --git a/.env.example b/.env.example index 9e22b62..77d7c6f 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3f05195 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf + +*.sh text eol=lf diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c966d3b --- /dev/null +++ b/AGENTS.md @@ -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 ` for one-off commands (migrations, tests, scratch scripts) +- `docker compose exec app ` 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 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. + +## Email + +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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..03199b8 --- /dev/null +++ b/CLAUDE.md @@ -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 ` for one-off commands (migrations, tests, scratch scripts) +- `docker compose exec app ` 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 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. + +## Email + +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. diff --git a/app/Http/Controllers/Items/AdjustStockController.php b/app/Http/Controllers/Items/AdjustStockController.php new file mode 100644 index 0000000..c2b0df9 --- /dev/null +++ b/app/Http/Controllers/Items/AdjustStockController.php @@ -0,0 +1,97 @@ +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); + } +} diff --git a/app/Http/Controllers/Items/CreateItemController.php b/app/Http/Controllers/Items/CreateItemController.php new file mode 100644 index 0000000..1296da4 --- /dev/null +++ b/app/Http/Controllers/Items/CreateItemController.php @@ -0,0 +1,34 @@ +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])); + } +} diff --git a/app/Http/Controllers/Items/ReceiveStockController.php b/app/Http/Controllers/Items/ReceiveStockController.php new file mode 100644 index 0000000..0accd6d --- /dev/null +++ b/app/Http/Controllers/Items/ReceiveStockController.php @@ -0,0 +1,57 @@ +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])); + } +} diff --git a/app/Http/Controllers/Items/ShowAdjustStockFormController.php b/app/Http/Controllers/Items/ShowAdjustStockFormController.php new file mode 100644 index 0000000..079171f --- /dev/null +++ b/app/Http/Controllers/Items/ShowAdjustStockFormController.php @@ -0,0 +1,41 @@ +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(), + ]); + } +} diff --git a/app/Http/Controllers/Items/ShowCreateItemFormController.php b/app/Http/Controllers/Items/ShowCreateItemFormController.php new file mode 100644 index 0000000..a5a98f7 --- /dev/null +++ b/app/Http/Controllers/Items/ShowCreateItemFormController.php @@ -0,0 +1,20 @@ +has('user_id')) { + return redirect($router->route('show-register-form')); + } + + return view('items/create', [ + 'createAction' => $router->route('create-item'), + 'csrf' => csrf(), + ]); + } +} diff --git a/app/Http/Controllers/Items/ShowItemController.php b/app/Http/Controllers/Items/ShowItemController.php new file mode 100644 index 0000000..9a85b59 --- /dev/null +++ b/app/Http/Controllers/Items/ShowItemController.php @@ -0,0 +1,46 @@ +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'), + ]); + } +} diff --git a/app/Http/Controllers/Items/ShowItemsController.php b/app/Http/Controllers/Items/ShowItemsController.php new file mode 100644 index 0000000..2c4326f --- /dev/null +++ b/app/Http/Controllers/Items/ShowItemsController.php @@ -0,0 +1,34 @@ +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'), + ]); + } +} diff --git a/app/Http/Controllers/Items/ShowReceiveStockFormController.php b/app/Http/Controllers/Items/ShowReceiveStockFormController.php new file mode 100644 index 0000000..59d6cb2 --- /dev/null +++ b/app/Http/Controllers/Items/ShowReceiveStockFormController.php @@ -0,0 +1,32 @@ +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(), + ]); + } +} diff --git a/app/Http/Controllers/Items/ShowWithdrawStockFormController.php b/app/Http/Controllers/Items/ShowWithdrawStockFormController.php new file mode 100644 index 0000000..db260ef --- /dev/null +++ b/app/Http/Controllers/Items/ShowWithdrawStockFormController.php @@ -0,0 +1,35 @@ +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(), + ]); + } +} diff --git a/app/Http/Controllers/Items/WithdrawStockController.php b/app/Http/Controllers/Items/WithdrawStockController.php new file mode 100644 index 0000000..03cfdff --- /dev/null +++ b/app/Http/Controllers/Items/WithdrawStockController.php @@ -0,0 +1,93 @@ +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); + } +} diff --git a/app/Http/Controllers/Locations/CreateLocationController.php b/app/Http/Controllers/Locations/CreateLocationController.php new file mode 100644 index 0000000..3f1abff --- /dev/null +++ b/app/Http/Controllers/Locations/CreateLocationController.php @@ -0,0 +1,34 @@ +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])); + } +} diff --git a/app/Http/Controllers/Locations/ShowCreateLocationFormController.php b/app/Http/Controllers/Locations/ShowCreateLocationFormController.php new file mode 100644 index 0000000..35f69e8 --- /dev/null +++ b/app/Http/Controllers/Locations/ShowCreateLocationFormController.php @@ -0,0 +1,20 @@ +has('user_id')) { + return redirect($router->route('show-register-form')); + } + + return view('locations/create', [ + 'createAction' => $router->route('create-location'), + 'csrf' => csrf(), + ]); + } +} diff --git a/app/Http/Controllers/Locations/ShowLocationController.php b/app/Http/Controllers/Locations/ShowLocationController.php new file mode 100644 index 0000000..315164d --- /dev/null +++ b/app/Http/Controllers/Locations/ShowLocationController.php @@ -0,0 +1,33 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/Locations/ShowLocationsController.php b/app/Http/Controllers/Locations/ShowLocationsController.php new file mode 100644 index 0000000..d7ad286 --- /dev/null +++ b/app/Http/Controllers/Locations/ShowLocationsController.php @@ -0,0 +1,33 @@ +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'), + ]); + } +} diff --git a/app/Http/Controllers/Products/OrderProductController.php b/app/Http/Controllers/Products/OrderProductController.php deleted file mode 100644 index e89a8f2..0000000 --- a/app/Http/Controllers/Products/OrderProductController.php +++ /dev/null @@ -1,19 +0,0 @@ -put('ordered', true); - - return redirect($router->route('show-home-page')); - } -} diff --git a/app/Http/Controllers/Products/ShowProductController.php b/app/Http/Controllers/Products/ShowProductController.php deleted file mode 100644 index 2f4ed3d..0000000 --- a/app/Http/Controllers/Products/ShowProductController.php +++ /dev/null @@ -1,22 +0,0 @@ -current()->parameters(); - - $product = Product::find((int) $parameters['product']); - - return view('products/view', [ - 'product' => $product, - 'orderAction' => $router->route('order-product', ['product' => $product->id]), - 'csrf' => csrf(), - ]); - } -} diff --git a/app/Http/Controllers/ShowDashboardController.php b/app/Http/Controllers/ShowDashboardController.php new file mode 100644 index 0000000..7d309fc --- /dev/null +++ b/app/Http/Controllers/ShowDashboardController.php @@ -0,0 +1,39 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/ShowHomePageController.php b/app/Http/Controllers/ShowHomePageController.php deleted file mode 100644 index 2309977..0000000 --- a/app/Http/Controllers/ShowHomePageController.php +++ /dev/null @@ -1,46 +0,0 @@ -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, - ]); - } -} diff --git a/app/Http/Controllers/Users/LogInUserController.php b/app/Http/Controllers/Users/LogInUserController.php index 028500b..2004f26 100644 --- a/app/Http/Controllers/Users/LogInUserController.php +++ b/app/Http/Controllers/Users/LogInUserController.php @@ -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')); } } diff --git a/app/Http/Controllers/Users/LogOutUserController.php b/app/Http/Controllers/Users/LogOutUserController.php index 185f31d..4d053c1 100644 --- a/app/Http/Controllers/Users/LogOutUserController.php +++ b/app/Http/Controllers/Users/LogOutUserController.php @@ -10,6 +10,6 @@ class LogOutUserController { session()->forget('user_id'); - return redirect($router->route('show-home-page')); + return redirect($router->route('show-dashboard')); } } diff --git a/app/Http/Controllers/Users/RegisterUserController.php b/app/Http/Controllers/Users/RegisterUserController.php index d201f28..0ac97d9 100644 --- a/app/Http/Controllers/Users/RegisterUserController.php +++ b/app/Http/Controllers/Users/RegisterUserController.php @@ -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')); } } diff --git a/app/Models/Item.php b/app/Models/Item.php new file mode 100644 index 0000000..d9c2c32 --- /dev/null +++ b/app/Models/Item.php @@ -0,0 +1,20 @@ +hasMany(Stock::class, 'item_id'); + } + + public function movements(): mixed + { + return $this->hasMany(StockMovement::class, 'item_id'); + } +} diff --git a/app/Models/Location.php b/app/Models/Location.php new file mode 100644 index 0000000..6a1467d --- /dev/null +++ b/app/Models/Location.php @@ -0,0 +1,20 @@ +hasMany(Stock::class, 'location_id'); + } + + public function movements(): mixed + { + return $this->hasMany(StockMovement::class, 'location_id'); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php deleted file mode 100644 index 1b24a6d..0000000 --- a/app/Models/Order.php +++ /dev/null @@ -1,15 +0,0 @@ -belongsTo(User::class, 'user_id'); - } -} diff --git a/app/Models/Product.php b/app/Models/Product.php deleted file mode 100644 index 9baa7f0..0000000 --- a/app/Models/Product.php +++ /dev/null @@ -1,27 +0,0 @@ -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; + } +} diff --git a/app/Models/StockMovement.php b/app/Models/StockMovement.php new file mode 100644 index 0000000..2859a03 --- /dev/null +++ b/app/Models/StockMovement.php @@ -0,0 +1,25 @@ +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'); + } +} diff --git a/app/Providers/ValidationExtensionsProvider.php b/app/Providers/ValidationExtensionsProvider.php new file mode 100644 index 0000000..c4d5e91 --- /dev/null +++ b/app/Providers/ValidationExtensionsProvider.php @@ -0,0 +1,14 @@ +resolve('validator')->addRule('positive_integer', new PositiveIntegerRule()); + } +} diff --git a/app/Validation/Rule/PositiveIntegerRule.php b/app/Validation/Rule/PositiveIntegerRule.php new file mode 100644 index 0000000..76fb595 --- /dev/null +++ b/app/Validation/Rule/PositiveIntegerRule.php @@ -0,0 +1,22 @@ + 0; + } + + public function getMessage(array $data, string $field, array $params) + { + return "{$field} must be a positive whole number"; + } +} diff --git a/app/routes.php b/app/routes.php index fd34ed4..3893078 100644 --- a/app/routes.php +++ b/app/routes.php @@ -1,8 +1,20 @@ 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', diff --git a/composer.json b/composer.json index 2fe461c..4c4f213 100644 --- a/composer.json +++ b/composer.json @@ -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" diff --git a/config/email.php b/config/email.php index 710671e..cea8209 100644 --- a/config/email.php +++ b/config/email.php @@ -1,7 +1,19 @@ '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'), ], - ] + ], ]; diff --git a/config/providers.php b/config/providers.php index 1673c5b..3c2911f 100644 --- a/config/providers.php +++ b/config/providers.php @@ -14,4 +14,7 @@ return [ \Framework\Provider\SessionProvider::class, \Framework\Provider\ValidationProvider::class, \Framework\Provider\ViewProvider::class, + + // app-level providers... + \App\Providers\ValidationExtensionsProvider::class, ]; diff --git a/config/warehouse.php b/config/warehouse.php new file mode 100644 index 0000000..5a81b08 --- /dev/null +++ b/config/warehouse.php @@ -0,0 +1,5 @@ + env('WAREHOUSE_ALERT_EMAIL'), +]; diff --git a/database/migrations/001_CreateOrdersTable.php b/database/migrations/001_CreateOrdersTable.php deleted file mode 100644 index fd30ecb..0000000 --- a/database/migrations/001_CreateOrdersTable.php +++ /dev/null @@ -1,18 +0,0 @@ -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(); - } -} diff --git a/database/migrations/007_CreateUsersTable.php b/database/migrations/001_CreateUsersTable.php similarity index 100% rename from database/migrations/007_CreateUsersTable.php rename to database/migrations/001_CreateUsersTable.php diff --git a/database/migrations/002_AddDeliveryInstructions.php b/database/migrations/002_AddDeliveryInstructions.php deleted file mode 100644 index d908332..0000000 --- a/database/migrations/002_AddDeliveryInstructions.php +++ /dev/null @@ -1,13 +0,0 @@ -alterTable('orders'); - $table->text('delivery_instructions'); - $table->execute(); - } -} diff --git a/database/migrations/008_CreateProfilesTable.php b/database/migrations/002_CreateProfilesTable.php similarity index 100% rename from database/migrations/008_CreateProfilesTable.php rename to database/migrations/002_CreateProfilesTable.php diff --git a/database/migrations/003_ChangeQuantity.php b/database/migrations/003_ChangeQuantity.php deleted file mode 100644 index 56fa336..0000000 --- a/database/migrations/003_ChangeQuantity.php +++ /dev/null @@ -1,13 +0,0 @@ -alterTable('orders'); - $table->int('quantity')->nullable()->alter(); - $table->execute(); - } -} diff --git a/database/migrations/010_CreateJobsTable.php b/database/migrations/003_CreateJobsTable.php similarity index 100% rename from database/migrations/010_CreateJobsTable.php rename to database/migrations/003_CreateJobsTable.php diff --git a/database/migrations/004_CreateLocationsTable.php b/database/migrations/004_CreateLocationsTable.php new file mode 100644 index 0000000..516acee --- /dev/null +++ b/database/migrations/004_CreateLocationsTable.php @@ -0,0 +1,18 @@ +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(); + } +} diff --git a/database/migrations/004_DropPrice.php b/database/migrations/004_DropPrice.php deleted file mode 100644 index 7a4f8aa..0000000 --- a/database/migrations/004_DropPrice.php +++ /dev/null @@ -1,13 +0,0 @@ -alterTable('orders'); - $table->dropColumn('price'); - $table->execute(); - } -} diff --git a/database/migrations/005_CreateProductsTable.php b/database/migrations/005_CreateItemsTable.php similarity index 51% rename from database/migrations/005_CreateProductsTable.php rename to database/migrations/005_CreateItemsTable.php index 61e226c..8681904 100644 --- a/database/migrations/005_CreateProductsTable.php +++ b/database/migrations/005_CreateItemsTable.php @@ -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(); } } diff --git a/database/migrations/006_CreateStockTable.php b/database/migrations/006_CreateStockTable.php new file mode 100644 index 0000000..9b02697 --- /dev/null +++ b/database/migrations/006_CreateStockTable.php @@ -0,0 +1,16 @@ +createTable('stock'); + $table->id('id'); + $table->int('item_id'); + $table->int('location_id'); + $table->int('quantity')->default(0); + $table->execute(); + } +} diff --git a/database/migrations/006_SeedProducts.php b/database/migrations/006_SeedProducts.php deleted file mode 100644 index 579fc63..0000000 --- a/database/migrations/006_SeedProducts.php +++ /dev/null @@ -1,31 +0,0 @@ - '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); - } - } -} diff --git a/database/migrations/007_CreateStockMovementsTable.php b/database/migrations/007_CreateStockMovementsTable.php new file mode 100644 index 0000000..507594b --- /dev/null +++ b/database/migrations/007_CreateStockMovementsTable.php @@ -0,0 +1,21 @@ +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(); + } +} diff --git a/database/migrations/009_AddUserId.php b/database/migrations/009_AddUserId.php deleted file mode 100644 index b34895d..0000000 --- a/database/migrations/009_AddUserId.php +++ /dev/null @@ -1,13 +0,0 @@ -alterTable('orders'); - $table->int('user_id'); - $table->execute(); - } -} diff --git a/docker-compose.yml b/docker-compose.yml index 49ee8a8..32e5256 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/framework/Email/Driver/SmtpDriver.php b/framework/Email/Driver/SmtpDriver.php new file mode 100644 index 0000000..f52e4fd --- /dev/null +++ b/framework/Email/Driver/SmtpDriver.php @@ -0,0 +1,103 @@ +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; + } +} diff --git a/framework/Provider/EmailProvider.php b/framework/Provider/EmailProvider.php index e8448cf..b03d8ad 100644 --- a/framework/Provider/EmailProvider.php +++ b/framework/Provider/EmailProvider.php @@ -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); + }, ]; } } diff --git a/readme.md b/readme.md index 865f03e..16a7c31 100644 --- a/readme.md +++ b/readme.md @@ -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 diff --git a/resources/views/dashboard.advanced.php b/resources/views/dashboard.advanced.php new file mode 100644 index 0000000..f21ad56 --- /dev/null +++ b/resources/views/dashboard.advanced.php @@ -0,0 +1,40 @@ +@extends('layout') +@includes('includes/large-feature') +
+

+ Warehouse overview +

+
+
+
{{ $locationCount }}
+
Locations
+
+
+
{{ $itemCount }}
+
Items
+
+
+
{{ $totalStock }}
+
Units in stock
+
+
+ +

+ Low stock +

+ + @if(count($lowStockItems) === 0) +

Nothing is at or below its reorder level.

+ @endif + + @if(count($lowStockItems) > 0) +
    + @foreach($lowStockItems as $item) +
  • + {{ $item->name }} + {{ $item->totalQuantity }} left (reorder at {{ $item->reorder_level }}) +
  • + @endforeach +
+ @endif +
diff --git a/resources/views/home.advanced.php b/resources/views/home.advanced.php deleted file mode 100644 index 8132d5a..0000000 --- a/resources/views/home.advanced.php +++ /dev/null @@ -1,22 +0,0 @@ -@extends('layout') -@includes('includes/large-feature') -@foreach($products as $i => $product) -
-
-

- {{ $product->name }} -

-

- {!! $product->description !!} -

- - Order - -
-
-@endforeach diff --git a/resources/views/includes/large-feature.advanced.php b/resources/views/includes/large-feature.advanced.php index b10c2fc..4661986 100644 --- a/resources/views/includes/large-feature.advanced.php +++ b/resources/views/includes/large-feature.advanced.php @@ -1,22 +1,24 @@
-
- @includes('rocket') +
+ 📦
- Whoosh! + Warehouse
- A place to buy rocket things + Locations, items, and stock, all in one place
    -
  1. Home
  2. +
  3. Dashboard
  4. +
  5. Locations
  6. +
  7. Items
  8. @if(session()->has('user_id'))
  9. Log out
  10. @endif @if(!session()->has('user_id')) -
  11. Register
  12. +
  13. Register / Log in
  14. @endif
diff --git a/resources/views/includes/small-feature.advanced.php b/resources/views/includes/small-feature.advanced.php index fded808..0e98d1e 100644 --- a/resources/views/includes/small-feature.advanced.php +++ b/resources/views/includes/small-feature.advanced.php @@ -1,19 +1,21 @@
-
- @includes('rocket') +
+ 📦
- Whoosh! + Warehouse
    -
  1. Home
  2. +
  3. Dashboard
  4. +
  5. Locations
  6. +
  7. Items
  8. @if(session()->has('user_id'))
  9. Log out
  10. @endif @if(!session()->has('user_id')) -
  11. Register
  12. +
  13. Register / Log in
  14. @endif
diff --git a/resources/views/items/adjust.advanced.php b/resources/views/items/adjust.advanced.php new file mode 100644 index 0000000..c471fd3 --- /dev/null +++ b/resources/views/items/adjust.advanced.php @@ -0,0 +1,72 @@ +@extends('layout') +@includes('includes/small-feature') +
+

+ Adjust stock — {{ $item->name }} +

+

+ Use this after a physical count to correct the recorded quantity at a location. Enter the new total, not a delta. +

+ +
    + @foreach($locations as $location) +
  • {{ $location->code }} — {{ $location->name }}: {{ $quantitiesByLocation[$location->id] ?? 0 }} currently recorded
  • + @endforeach +
+ +
+ @if(session()->has('adjust_errors')) +
    + @foreach(session('adjust_errors') as $field => $errors) + @foreach($errors as $error) +
  1. {{ $error }}
  2. + @endforeach + @endforeach +
+ @endif + + + + + +
+
diff --git a/resources/views/items/create.advanced.php b/resources/views/items/create.advanced.php new file mode 100644 index 0000000..27d9556 --- /dev/null +++ b/resources/views/items/create.advanced.php @@ -0,0 +1,68 @@ +@extends('layout') +@includes('includes/small-feature') +
+

+ Add an item +

+
+ @if(session()->has('create_item_errors')) +
    + @foreach(session('create_item_errors') as $field => $errors) + @foreach($errors as $error) +
  1. {{ $error }}
  2. + @endforeach + @endforeach +
+ @endif + + + + + + +
+
diff --git a/resources/views/items/index.advanced.php b/resources/views/items/index.advanced.php new file mode 100644 index 0000000..da9da35 --- /dev/null +++ b/resources/views/items/index.advanced.php @@ -0,0 +1,35 @@ +@extends('layout') +@includes('includes/small-feature') +
+
+

+ Items +

+ @if($canManage) + + Add item + + @endif +
+ + @if(count($items) === 0) +

No items yet.

+ @endif + + +
diff --git a/resources/views/items/receive.advanced.php b/resources/views/items/receive.advanced.php new file mode 100644 index 0000000..8101da2 --- /dev/null +++ b/resources/views/items/receive.advanced.php @@ -0,0 +1,60 @@ +@extends('layout') +@includes('includes/small-feature') +
+

+ Receive stock — {{ $item->name }} +

+
+ @if(session()->has('receive_errors')) +
    + @foreach(session('receive_errors') as $field => $errors) + @foreach($errors as $error) +
  1. {{ $error }}
  2. + @endforeach + @endforeach +
+ @endif + + + + + +
+
diff --git a/resources/views/items/view.advanced.php b/resources/views/items/view.advanced.php new file mode 100644 index 0000000..9593abf --- /dev/null +++ b/resources/views/items/view.advanced.php @@ -0,0 +1,77 @@ +@extends('layout') +@includes('includes/small-feature') +
+

+ {{ $item->sku }} — {{ $item->name }} +

+ @if($item->description) +

+ {{ $item->description }} +

+ @endif +

+ {{ $totalQuantity }} units in stock + @if($item->reorder_level > 0) + (reorder at {{ $item->reorder_level }}) + @endif +

+ + @if($canManage) + + @endif + +

+ Stock by location +

+ + @if(count($stock) === 0) +

Not stored anywhere yet.

+ @endif + + + +

+ Recent activity +

+ + @if(count($movements) === 0) +

No stock movements yet.

+ @endif + +
    + @foreach($movements as $movement) +
  • + + {{ $movement->type }} — {{ $movement->location->code }} + @if($movement->notes) + — {{ $movement->notes }} + @endif + + + @if($movement->quantity_change >= 0) + + + @endif + {{ $movement->quantity_change }} + +
  • + @endforeach +
+
diff --git a/resources/views/items/withdraw.advanced.php b/resources/views/items/withdraw.advanced.php new file mode 100644 index 0000000..d464ff8 --- /dev/null +++ b/resources/views/items/withdraw.advanced.php @@ -0,0 +1,67 @@ +@extends('layout') +@includes('includes/small-feature') +
+

+ Withdraw stock — {{ $item->name }} +

+ + @if(count($stock) === 0) +

There's no stock anywhere to withdraw from.

+ @endif + + @if(count($stock) > 0) +
+ @if(session()->has('withdraw_errors')) +
    + @foreach(session('withdraw_errors') as $field => $errors) + @foreach($errors as $error) +
  1. {{ $error }}
  2. + @endforeach + @endforeach +
+ @endif + + + + + +
+ @endif +
diff --git a/resources/views/layout.advanced.php b/resources/views/layout.advanced.php index 14887c6..1b9ff47 100644 --- a/resources/views/layout.advanced.php +++ b/resources/views/layout.advanced.php @@ -1,7 +1,7 @@ - Whoosh! + Warehouse diff --git a/resources/views/locations/create.advanced.php b/resources/views/locations/create.advanced.php new file mode 100644 index 0000000..ddba935 --- /dev/null +++ b/resources/views/locations/create.advanced.php @@ -0,0 +1,68 @@ +@extends('layout') +@includes('includes/small-feature') +
+

+ Add a location +

+
+ @if(session()->has('create_location_errors')) +
    + @foreach(session('create_location_errors') as $field => $errors) + @foreach($errors as $error) +
  1. {{ $error }}
  2. + @endforeach + @endforeach +
+ @endif + + + + + + +
+
diff --git a/resources/views/locations/index.advanced.php b/resources/views/locations/index.advanced.php new file mode 100644 index 0000000..56849a3 --- /dev/null +++ b/resources/views/locations/index.advanced.php @@ -0,0 +1,35 @@ +@extends('layout') +@includes('includes/small-feature') +
+
+

+ Locations +

+ @if($canManage) + + Add location + + @endif +
+ + @if(count($locations) === 0) +

No locations yet.

+ @endif + + +
diff --git a/resources/views/locations/view.advanced.php b/resources/views/locations/view.advanced.php new file mode 100644 index 0000000..facd196 --- /dev/null +++ b/resources/views/locations/view.advanced.php @@ -0,0 +1,34 @@ +@extends('layout') +@includes('includes/small-feature') +
+

+ {{ $location->code }} — {{ $location->name }} +

+ @if($location->description) +

+ {{ $location->description }} +

+ @endif + @if($location->capacity) +

+ Capacity: {{ $location->capacity }} units +

+ @endif + +

+ Stock held here +

+ + @if(count($stock) === 0) +

Nothing is stored here yet.

+ @endif + + +
diff --git a/resources/views/products/view.advanced.php b/resources/views/products/view.advanced.php deleted file mode 100644 index fd7dc94..0000000 --- a/resources/views/products/view.advanced.php +++ /dev/null @@ -1,45 +0,0 @@ -@extends('layout') -@includes('includes/large-feature') -
-

- {{ $product->name }} -

-

- {!! $product->description !!} -

-

- Order -

-
- @if(session()->has('errors')) -
    - @foreach(session('errors') as $field => $errors) - @foreach($errors as $error) -
  1. {{ $error }}
  2. - @endforeach - @endforeach -
- @endif - - - -
-
diff --git a/tests/RoutingTest.php b/tests/RoutingTest.php index c9e417a..e7ee5d1 100644 --- a/tests/RoutingTest.php +++ b/tests/RoutingTest.php @@ -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()); } diff --git a/tests/WarehouseTest.php b/tests/WarehouseTest.php new file mode 100644 index 0000000..d904947 --- /dev/null +++ b/tests/WarehouseTest.php @@ -0,0 +1,97 @@ +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); + } +}