Link retail POS products to the CRM products API (restaurant stays local)
Deploy Ladill POS / deploy (push) Successful in 28s

Products are now mode-aware:
- Retail: the catalog lives in Ladill CRM. The Products page proxies CRUD to the
  CRM products API (CrmClient gains product/create/update/delete), and the
  register reads CRM products live; a sold line is stored as a name/price
  snapshot (product_id null, since CRM products aren't local rows). Resilient —
  the register shows an empty catalog if CRM is unreachable. CRM import is hidden
  in Settings (not needed when reading live).
- Restaurant: unchanged — the local pos_products catalog keeps its POS-only depth
  (category, kitchen station, course, modifier groups).

ProductController routes take a raw {product} id (CRM id in retail, local id in
restaurant). Suite green (17), covering both modes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
isaacclad
2026-06-25 12:27:19 +00:00
co-authored by Claude Opus 4.8
parent 0d816daed3
commit 5d8e185223
10 changed files with 377 additions and 61 deletions
+41 -11
View File
@@ -3,6 +3,7 @@
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\PosLocation;
use App\Models\PosProduct;
use App\Models\PosSale;
use App\Models\User;
@@ -49,21 +50,50 @@ class PosRegisterTest extends TestCase
->assertSee('favicon.ico', false);
}
public function test_register_renders_products(): void
public function test_register_renders_local_products_in_restaurant_mode(): void
{
$user = $this->user();
PosProduct::create([
'owner_ref' => $user->public_id,
'name' => 'Coffee',
'price_minor' => 1500,
'currency' => 'GHS',
'is_active' => true,
// ensureDefault() matches owner+name 'Main register'.
PosLocation::create(['owner_ref' => $user->public_id, 'name' => 'Main register', 'currency' => 'GHS', 'service_style' => 'restaurant']);
PosProduct::create(['owner_ref' => $user->public_id, 'name' => 'Coffee', 'price_minor' => 1500, 'currency' => 'GHS', 'is_active' => true]);
$this->actingAs($user)->get(route('pos.register'))->assertOk()->assertSee('Coffee');
}
public function test_retail_register_reads_crm_products_and_snapshots_lines(): void
{
Http::fake([
'crm.test/api/products*' => Http::response(['data' => [
['id' => 7, 'name' => 'Mug', 'unit_price_minor' => 2500, 'currency' => 'GHS', 'active' => true],
]], 200),
'crm.test/api/customers*' => Http::response(['data' => []], 200),
'crm.test/api/*' => Http::response(['id' => 1], 201),
]);
$this->actingAs($user)
->get(route('pos.register'))
->assertOk()
->assertSee('Coffee');
$user = $this->user(); // retail by default (no restaurant location)
$this->actingAs($user)->get(route('pos.register'))->assertOk()->assertSee('Mug');
// A line built from a CRM product is stored as a snapshot — no local FK.
$this->actingAs($user)->post(route('pos.register.charge'), [
'payment_method' => 'cash',
'lines' => [['product_id' => 7, 'name' => 'Mug', 'unit_price_minor' => 2500, 'quantity' => 1]],
])->assertRedirect();
$line = PosSale::where('owner_ref', $user->public_id)->firstOrFail()->lines()->firstOrFail();
$this->assertNull($line->product_id);
$this->assertSame('Mug', $line->name);
$this->assertSame(2500, $line->unit_price_minor);
}
public function test_retail_products_page_lists_crm_products(): void
{
Http::fake([
'crm.test/api/products*' => Http::response(['data' => [
['id' => 7, 'name' => 'Mug', 'sku' => 'M1', 'unit_price_minor' => 2500, 'currency' => 'GHS', 'active' => true],
]], 200),
]);
$this->actingAs($this->user())->get(route('pos.products.index'))->assertOk()->assertSee('Mug');
}
public function test_cash_sale_is_recorded(): void