Browse Source

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 <noreply@anthropic.com>
master
Daniel Covington 1 week ago
parent
commit
2e17239766
72 changed files with 2091 additions and 345 deletions
  1. +13
    -1
      .env.example
  2. +3
    -0
      .gitattributes
  3. +154
    -0
      AGENTS.md
  4. +154
    -0
      CLAUDE.md
  5. +97
    -0
      app/Http/Controllers/Items/AdjustStockController.php
  6. +34
    -0
      app/Http/Controllers/Items/CreateItemController.php
  7. +57
    -0
      app/Http/Controllers/Items/ReceiveStockController.php
  8. +41
    -0
      app/Http/Controllers/Items/ShowAdjustStockFormController.php
  9. +20
    -0
      app/Http/Controllers/Items/ShowCreateItemFormController.php
  10. +46
    -0
      app/Http/Controllers/Items/ShowItemController.php
  11. +34
    -0
      app/Http/Controllers/Items/ShowItemsController.php
  12. +32
    -0
      app/Http/Controllers/Items/ShowReceiveStockFormController.php
  13. +35
    -0
      app/Http/Controllers/Items/ShowWithdrawStockFormController.php
  14. +93
    -0
      app/Http/Controllers/Items/WithdrawStockController.php
  15. +34
    -0
      app/Http/Controllers/Locations/CreateLocationController.php
  16. +20
    -0
      app/Http/Controllers/Locations/ShowCreateLocationFormController.php
  17. +33
    -0
      app/Http/Controllers/Locations/ShowLocationController.php
  18. +33
    -0
      app/Http/Controllers/Locations/ShowLocationsController.php
  19. +0
    -19
      app/Http/Controllers/Products/OrderProductController.php
  20. +0
    -22
      app/Http/Controllers/Products/ShowProductController.php
  21. +39
    -0
      app/Http/Controllers/ShowDashboardController.php
  22. +0
    -46
      app/Http/Controllers/ShowHomePageController.php
  23. +1
    -1
      app/Http/Controllers/Users/LogInUserController.php
  24. +1
    -1
      app/Http/Controllers/Users/LogOutUserController.php
  25. +9
    -4
      app/Http/Controllers/Users/RegisterUserController.php
  26. +20
    -0
      app/Models/Item.php
  27. +20
    -0
      app/Models/Location.php
  28. +0
    -15
      app/Models/Order.php
  29. +0
    -27
      app/Models/Product.php
  30. +36
    -0
      app/Models/Stock.php
  31. +25
    -0
      app/Models/StockMovement.php
  32. +14
    -0
      app/Providers/ValidationExtensionsProvider.php
  33. +22
    -0
      app/Validation/Rule/PositiveIntegerRule.php
  34. +101
    -11
      app/routes.php
  35. +2
    -2
      composer.json
  36. +14
    -2
      config/email.php
  37. +3
    -0
      config/providers.php
  38. +5
    -0
      config/warehouse.php
  39. +0
    -18
      database/migrations/001_CreateOrdersTable.php
  40. +0
    -0
      database/migrations/001_CreateUsersTable.php
  41. +0
    -13
      database/migrations/002_AddDeliveryInstructions.php
  42. +0
    -0
      database/migrations/002_CreateProfilesTable.php
  43. +0
    -13
      database/migrations/003_ChangeQuantity.php
  44. +0
    -0
      database/migrations/003_CreateJobsTable.php
  45. +18
    -0
      database/migrations/004_CreateLocationsTable.php
  46. +0
    -13
      database/migrations/004_DropPrice.php
  47. +5
    -2
      database/migrations/005_CreateItemsTable.php
  48. +16
    -0
      database/migrations/006_CreateStockTable.php
  49. +0
    -31
      database/migrations/006_SeedProducts.php
  50. +21
    -0
      database/migrations/007_CreateStockMovementsTable.php
  51. +0
    -13
      database/migrations/009_AddUserId.php
  52. +2
    -2
      docker-compose.yml
  53. +103
    -0
      framework/Email/Driver/SmtpDriver.php
  54. +4
    -0
      framework/Provider/EmailProvider.php
  55. +7
    -9
      readme.md
  56. +40
    -0
      resources/views/dashboard.advanced.php
  57. +0
    -22
      resources/views/home.advanced.php
  58. +8
    -6
      resources/views/includes/large-feature.advanced.php
  59. +7
    -5
      resources/views/includes/small-feature.advanced.php
  60. +72
    -0
      resources/views/items/adjust.advanced.php
  61. +68
    -0
      resources/views/items/create.advanced.php
  62. +35
    -0
      resources/views/items/index.advanced.php
  63. +60
    -0
      resources/views/items/receive.advanced.php
  64. +77
    -0
      resources/views/items/view.advanced.php
  65. +67
    -0
      resources/views/items/withdraw.advanced.php
  66. +1
    -1
      resources/views/layout.advanced.php
  67. +68
    -0
      resources/views/locations/create.advanced.php
  68. +35
    -0
      resources/views/locations/index.advanced.php
  69. +34
    -0
      resources/views/locations/view.advanced.php
  70. +0
    -45
      resources/views/products/view.advanced.php
  71. +1
    -1
      tests/RoutingTest.php
  72. +97
    -0
      tests/WarehouseTest.php

+ 13
- 1
.env.example View File

@@ -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=

+ 3
- 0
.gitattributes View File

@@ -0,0 +1,3 @@
* text=auto eol=lf

*.sh text eol=lf

+ 154
- 0
AGENTS.md View File

@@ -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.

## 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.

+ 154
- 0
CLAUDE.md View File

@@ -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.

## 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.

+ 97
- 0
app/Http/Controllers/Items/AdjustStockController.php View File

@@ -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);
}
}

+ 34
- 0
app/Http/Controllers/Items/CreateItemController.php View File

@@ -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]));
}
}

+ 57
- 0
app/Http/Controllers/Items/ReceiveStockController.php View File

@@ -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]));
}
}

+ 41
- 0
app/Http/Controllers/Items/ShowAdjustStockFormController.php View File

@@ -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(),
]);
}
}

+ 20
- 0
app/Http/Controllers/Items/ShowCreateItemFormController.php View File

@@ -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(),
]);
}
}

+ 46
- 0
app/Http/Controllers/Items/ShowItemController.php View File

@@ -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'),
]);
}
}

+ 34
- 0
app/Http/Controllers/Items/ShowItemsController.php View File

@@ -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'),
]);
}
}

+ 32
- 0
app/Http/Controllers/Items/ShowReceiveStockFormController.php View File

@@ -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(),
]);
}
}

+ 35
- 0
app/Http/Controllers/Items/ShowWithdrawStockFormController.php View File

@@ -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(),
]);
}
}

+ 93
- 0
app/Http/Controllers/Items/WithdrawStockController.php View File

@@ -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);
}
}

+ 34
- 0
app/Http/Controllers/Locations/CreateLocationController.php View File

@@ -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]));
}
}

+ 20
- 0
app/Http/Controllers/Locations/ShowCreateLocationFormController.php View File

@@ -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(),
]);
}
}

+ 33
- 0
app/Http/Controllers/Locations/ShowLocationController.php View File

@@ -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,
]);
}
}

+ 33
- 0
app/Http/Controllers/Locations/ShowLocationsController.php View File

@@ -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'),
]);
}
}

+ 0
- 19
app/Http/Controllers/Products/OrderProductController.php View File

@@ -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'));
}
}

+ 0
- 22
app/Http/Controllers/Products/ShowProductController.php View File

@@ -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(),
]);
}
}

+ 39
- 0
app/Http/Controllers/ShowDashboardController.php View File

@@ -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,
]);
}
}

+ 0
- 46
app/Http/Controllers/ShowHomePageController.php View File

@@ -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,
]);
}
}

+ 1
- 1
app/Http/Controllers/Users/LogInUserController.php View File

@@ -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'));
}
}

+ 1
- 1
app/Http/Controllers/Users/LogOutUserController.php View File

@@ -10,6 +10,6 @@ class LogOutUserController
{
session()->forget('user_id');

return redirect($router->route('show-home-page'));
return redirect($router->route('show-dashboard'));
}
}

+ 9
- 4
app/Http/Controllers/Users/RegisterUserController.php View File

@@ -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'));
}
}

+ 20
- 0
app/Models/Item.php View File

@@ -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');
}
}

+ 20
- 0
app/Models/Location.php View File

@@ -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');
}
}

+ 0
- 15
app/Models/Order.php View File

@@ -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');
}
}

+ 0
- 27
app/Models/Product.php View File

@@ -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;
}
}

+ 36
- 0
app/Models/Stock.php View File

@@ -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;
}
}

+ 25
- 0
app/Models/StockMovement.php View File

@@ -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');
}
}

+ 14
- 0
app/Providers/ValidationExtensionsProvider.php View File

@@ -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());
}
}

+ 22
- 0
app/Validation/Rule/PositiveIntegerRule.php View File

@@ -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";
}
}

+ 101
- 11
app/routes.php View File

@@ -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',


+ 2
- 2
composer.json View File

@@ -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"


+ 14
- 2
config/email.php View File

@@ -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'),
],
]
],
];

+ 3
- 0
config/providers.php View File

@@ -14,4 +14,7 @@ return [
\Framework\Provider\SessionProvider::class,
\Framework\Provider\ValidationProvider::class,
\Framework\Provider\ViewProvider::class,

// app-level providers...
\App\Providers\ValidationExtensionsProvider::class,
];

+ 5
- 0
config/warehouse.php View File

@@ -0,0 +1,5 @@
<?php

return [
'alert_email' => env('WAREHOUSE_ALERT_EMAIL'),
];

+ 0
- 18
database/migrations/001_CreateOrdersTable.php View File

@@ -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();
}
}

database/migrations/007_CreateUsersTable.php → database/migrations/001_CreateUsersTable.php View File


+ 0
- 13
database/migrations/002_AddDeliveryInstructions.php View File

@@ -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();
}
}

database/migrations/008_CreateProfilesTable.php → database/migrations/002_CreateProfilesTable.php View File


+ 0
- 13
database/migrations/003_ChangeQuantity.php View File

@@ -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();
}
}

database/migrations/010_CreateJobsTable.php → database/migrations/003_CreateJobsTable.php View File


+ 18
- 0
database/migrations/004_CreateLocationsTable.php View File

@@ -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();
}
}

+ 0
- 13
database/migrations/004_DropPrice.php View File

@@ -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();
}
}

database/migrations/005_CreateProductsTable.php → database/migrations/005_CreateItemsTable.php View File

@@ -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();
}
}

+ 16
- 0
database/migrations/006_CreateStockTable.php View File

@@ -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();
}
}

+ 0
- 31
database/migrations/006_SeedProducts.php View File

@@ -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&apos;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);
}
}
}

+ 21
- 0
database/migrations/007_CreateStockMovementsTable.php View File

@@ -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();
}
}

+ 0
- 13
database/migrations/009_AddUserId.php View File

@@ -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();
}
}

+ 2
- 2
docker-compose.yml View File

@@ -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


+ 103
- 0
framework/Email/Driver/SmtpDriver.php View File

@@ -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
- 0
framework/Provider/EmailProvider.php View File

@@ -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);
},
];
}
}

+ 7
- 9
readme.md View File

@@ -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



+ 40
- 0
resources/views/dashboard.advanced.php View File

@@ -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>

+ 0
- 22
resources/views/home.advanced.php View File

@@ -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

+ 8
- 6
resources/views/includes/large-feature.advanced.php View File

@@ -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>


+ 7
- 5
resources/views/includes/small-feature.advanced.php View File

@@ -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>


+ 72
- 0
resources/views/items/adjust.advanced.php View File

@@ -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 &mdash; {{ $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 }} &mdash; {{ $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 }} &mdash; {{ $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>

+ 68
- 0
resources/views/items/create.advanced.php View File

@@ -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>

+ 35
- 0
resources/views/items/index.advanced.php View File

@@ -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 }} &mdash; {{ $item->name }}
</a>
<span class="@if($item->isLowStock) text-red-500 @endif">
{{ $item->totalQuantity }} units
</span>
</li>
@endforeach
</ul>
</div>

+ 60
- 0
resources/views/items/receive.advanced.php View File

@@ -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 &mdash; {{ $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 }} &mdash; {{ $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>

+ 77
- 0
resources/views/items/view.advanced.php View File

@@ -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 }} &mdash; {{ $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 }} &mdash; {{ $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 }} &mdash; {{ $movement->location->code }}
@if($movement->notes)
&mdash; {{ $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>

+ 67
- 0
resources/views/items/withdraw.advanced.php View File

@@ -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 &mdash; {{ $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 }} &mdash; {{ $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
- 1
resources/views/layout.advanced.php View File

@@ -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">


+ 68
- 0
resources/views/locations/create.advanced.php View File

@@ -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>

+ 35
- 0
resources/views/locations/index.advanced.php View File

@@ -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 }} &mdash; {{ $location->name }}
</a>
<span class="text-gray-500">
{{ $location->totalQuantity }}
@if($location->capacity)
/ {{ $location->capacity }}
@endif
units
</span>
</li>
@endforeach
</ul>
</div>

+ 34
- 0
resources/views/locations/view.advanced.php View File

@@ -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 }} &mdash; {{ $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>

+ 0
- 45
resources/views/products/view.advanced.php View File

@@ -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>

+ 1
- 1
tests/RoutingTest.php View File

@@ -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());
}


+ 97
- 0
tests/WarehouseTest.php View File

@@ -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);
}
}

Loading…
Cancel
Save

Powered by TurnKey Linux.