# WP6 — `is_drug` flag + drug details table + ingredient pivot

**Repo:** BE · **Branch:** `hazemdev2` · **Migration:** **YES** · **Depends:** WP5

## Goal

Make a product declarable as a **drug** and give it pharmaceutical attributes — **without** touching the `type` column.

## Why not a `ProductType::Drug` — decided, do not re-open

`products.type` has exactly two cases (`product`, `service`) and three live queries hard-filter the literal `'product'`:

| Site | Effect if a drug were a third type |
|---|---|
| `Modules/POS/app/Http/Controllers/POSProductController.php:42` (`index`) | every drug disappears from the POS product grid |
| `Modules/POS/app/Http/Controllers/POSProductController.php:80` (`search`) | drugs unfindable by search **and by barcode** at the till |
| `Modules/Inventory/app/Http/Controllers/InventoryCountController.php:246` | drugs excluded from stock counts |

`type` is *tangible vs intangible*, not a taxonomy. A pharmacy sells drugs at the till and counts them monthly, so a third type ships three silent regressions on day one.

## Shape

```
products.is_drug            boolean, default false      ← the tab trigger
product_drug_details        1:1,  unique(company_id, product_id)
    dosage_form_id          FK → dosage_forms (nullable)
    strength_value          decimal(12,4) nullable
    strength_unit           varchar(20) nullable          (mg / mcg / g / ml / IU / % — free string)
    is_prescription         boolean default false          (Rx vs OTC)
    is_controlled           boolean default false          (narcotic / restricted)
    storage_temperature     varchar(50) nullable
    atc_code                varchar(20) nullable
    notes                   text nullable
product_active_ingredient   pivot(product_id, active_ingredient_id)
    strength_value          decimal(12,4) nullable         ← per-ingredient, for combination drugs
    strength_unit           varchar(20) nullable
    timestamps
```

**Precedent to copy for the 1:1 table:** `Modules/Production/database/migrations/2026_06_12_160001_create_mfg_item_mrp_settings_table.php` — Production put its 11 domain columns in a side table keyed `unique(['company_id','product_id'])` rather than widening `products`. Copy that shape, including the `Schema::hasTable` guard and the try/catch around the FK (sqlite bootstrap).

**`products.is_drug` is a column on a table another module owns.** The established pattern for that here is a migration in the **root** `database/migrations/` guarded by `Schema::hasTable`/`hasColumn` — see `database/migrations/2026_02_23_102407_add_tracking_type_to_products_table.php` and `..._000012_add_variant_type_to_products_table.php`. Follow it.

**Why a boolean and not "row exists in `product_drug_details`":** the FE needs a plain form control to bind the conditional tab's `@if` to, and a boolean is the cheapest signal. Keep them consistent — creating details implies `is_drug = true`.

## Wire it into Core in THIS WP — non-negotiable

The single biggest risk is repeating the `manufacturer_id` / `shelf_life_days` pattern: columns that exist on `products` but are **absent from Core's `ProductResource`, `StoreProductRequest` and `UpdateProductRequest`**, and are therefore invisible and uneditable.

So this WP must also:
- `Modules/Core/app/Models/Product.php` — `is_drug` in `$fillable` + boolean cast; `drugDetails()` hasOne; `activeIngredients()` belongsToMany **with `->withPivot('strength_value','strength_unit')->withTimestamps()`** (precedent: `Product::offers()` in the same model).
- `Modules/Core/app/Http/Requests/StoreProductRequest.php` + `UpdateProductRequest.php` — accept `is_drug`, the `drug_details` object, and `active_ingredients` as an array of `{id, strength_value, strength_unit}`.
  **Company-scope every `exists` rule** — use the `Rule::exists(...)->where('company_id', $companyId)` form (precedent: `Modules/WebStore/app/Http/Requests/AdminProductRequest.php:47-56`), never a bare `exists:`.
- `Modules/Core/app/Http/Resources/ProductResource.php` — return `is_drug`, `drug_details` and `active_ingredients` as `whenLoaded` (precedent: how `tags` is returned in the same file).
- `Modules/Core/app/Http/Controllers/ProductController.php` — upsert the details row and `sync()` the pivot on store and update (precedent: `AdminProductController` store/update, which pulls the array, `unset`s it, then syncs after create/update). Eager-load company-scoped on read.

**While you are here (cheap, related, explicitly in scope):** also surface the two existing orphans — `manufacturer_id` and `shelf_life_days` — in Core's `ProductResource` and both FormRequests. They already exist on the table; they are simply unreachable. Do not add new columns for them.

## Acceptance criteria

1. `POST /core/products` with `is_drug=true`, a `drug_details` object and 2 active ingredients (each with its own strength) creates all three rows; `GET` returns them.
2. `PUT` updates details and **re-syncs** ingredients (removing one actually removes it).
3. A drug still has `type = 'product'` and therefore **still appears in POS index, POS search and Inventory Count** — assert this explicitly with a test; it is the whole reason for the design.
4. Cross-company: an `active_ingredient_id` or `dosage_form_id` belonging to another company is rejected (422), not silently linked.
5. `is_drug=false` products are unaffected — no details row required, resource unchanged in shape.
6. `manufacturer_id` and `shelf_life_days` are now readable and writable through Core's product endpoints.
7. Migrations run clean on the dev DB and are idempotent/guarded.

## Tests

Extend `Modules/Core/tests/Feature/ProductApiTest.php` or add `ProductDrugDetailsApiTest.php`. Criterion 3 must be a real assertion against the POS/Inventory query paths, not a comment.

⚠️ **Pest loads every test file into ONE process** — prefix top-level helpers with the file's subject. A generic name is a fatal redeclare that kills the entire suite (exit 255, zero output). Fourth occurrence fixed today.

## Migration execution

Run on `moonui2_dev_be` in the same step the code lands. Timestamps sort after all existing migrations.
⛔ Never `migrate:fresh`/`migrate:refresh`/`db:wipe`/`RefreshDatabase` against the dev DB.

## Out of scope

- The FE drug tab — **WP7**.
- The units table — **WP8**.
- WebStore's separate admin product path (`AdminProductRequest`/`AdminProductController`) — deliberately not mirrored; the owner's decision is Core-first.
- Do not migrate `clinic_drugs.active_ingredient` free text into the new table. **Instead: report the count of distinct non-empty values found**, so the owner can decide. (Dev has zero rows.)

## Environment

- BE tests: `/opt/cpanel/ea-php82/root/usr/bin/php -d memory_limit=1G vendor/bin/pest --filter='ProductApiTest|ProductDrugDetailsApiTest'`.
- Baseline 174 passed / 14 pre-existing failures (all `StorefrontProductApiTest`). Green = no NEW failures.
- `pint` on touched files only; `chown moonui2:moonui2` after each edit; **moonui2 only — never `/home/moonui`**.
- Dirty working tree by design — never revert/stash/commit what you did not write; do not commit at all.
