Procházet zdrojové kódy

Add 'CLAUDE.MD'

main
dcovington před 1 týdnem
rodič
revize
4abcfb3c52
1 změnil soubory, kde provedl 154 přidání a 0 odebrání
  1. +154
    -0
      CLAUDE.MD

+ 154
- 0
CLAUDE.MD Zobrazit soubor

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

Načítá se…
Zrušit
Uložit

Powered by TurnKey Linux.