Add bulk CSV product import for POS (retail + restaurant)
Deploy Ladill POS / deploy (push) Successful in 1m39s
Deploy Ladill POS / deploy (push) Successful in 1m39s
Customers with large catalogs can upload a CSV instead of adding products one by one. Mode-aware: restaurant imports into the local catalog (chunked INSERT, categories resolved/created by name); retail batches to the CRM products/bulk endpoint. Streamed parsing + batched writes handle thousands of rows in one request. Includes a downloadable template, an Import button on both product pages, and a skipped-rows report. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
62e396195b
commit
9fcea24680
@@ -11,9 +11,12 @@ use App\Models\PosProduct;
|
||||
use App\Models\PosSaleLine;
|
||||
use App\Models\PosStation;
|
||||
use App\Services\Crm\CrmClient;
|
||||
use App\Services\Import\PosProductCsvImportService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* Products are mode-aware:
|
||||
@@ -119,6 +122,62 @@ class ProductController extends Controller
|
||||
return redirect()->route('pos.products.index')->with('success', 'Product removed.');
|
||||
}
|
||||
|
||||
/** Bulk CSV import — upload form (mode-aware target catalog). */
|
||||
public function importForm(Request $request): View
|
||||
{
|
||||
return view('pos.products.import', [
|
||||
'restaurant' => $this->isRestaurant($request),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Bulk CSV import — handle the upload. */
|
||||
public function import(Request $request, PosProductCsvImportService $import): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'file' => ['required', 'file', 'mimes:csv,txt', 'max:10240'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $import->import(
|
||||
$this->ownerRef($request),
|
||||
$request->file('file'),
|
||||
$this->isRestaurant($request),
|
||||
);
|
||||
} catch (RuntimeException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
$message = "Imported {$result['imported']} product(s).";
|
||||
if ($result['skipped'] > 0) {
|
||||
$message .= " Skipped {$result['skipped']}.";
|
||||
}
|
||||
|
||||
return redirect()->route('pos.products.index')
|
||||
->with('success', $message)
|
||||
->with('import_errors', array_slice($result['errors'], 0, 50));
|
||||
}
|
||||
|
||||
/** Bulk CSV import — downloadable template with example rows. */
|
||||
public function importTemplate(Request $request): StreamedResponse
|
||||
{
|
||||
$restaurant = $this->isRestaurant($request);
|
||||
$filename = 'pos-products-template.csv';
|
||||
|
||||
return response()->streamDownload(function () use ($restaurant) {
|
||||
$out = fopen('php://output', 'wb');
|
||||
if ($restaurant) {
|
||||
fputcsv($out, ['name', 'sku', 'price', 'currency', 'category', 'active']);
|
||||
fputcsv($out, ['Coffee', 'CFE-01', '15.00', 'GHS', 'Drinks', 'yes']);
|
||||
fputcsv($out, ['Croissant', 'CRS-02', '8.50', 'GHS', 'Pastry', 'yes']);
|
||||
} else {
|
||||
fputcsv($out, ['name', 'sku', 'price', 'currency', 'description', 'tax_rate', 'active']);
|
||||
fputcsv($out, ['Mug', 'MUG-01', '25.00', 'GHS', 'Ceramic mug', '0', 'yes']);
|
||||
fputcsv($out, ['T-Shirt', 'TSH-02', '60.00', 'GHS', 'Cotton tee', '0', 'yes']);
|
||||
}
|
||||
fclose($out);
|
||||
}, $filename, ['Content-Type' => 'text/csv']);
|
||||
}
|
||||
|
||||
private function isRestaurant(Request $request): bool
|
||||
{
|
||||
return PosLocation::owned($this->ownerRef($request))
|
||||
|
||||
@@ -36,6 +36,17 @@ class CrmClient
|
||||
return $this->send('post', 'products', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-create products (one CRM INSERT per call). Caller batches large
|
||||
* catalogs; the CRM caps each request at 500 rows.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $products
|
||||
*/
|
||||
public function bulkCreateProducts(array $products): array
|
||||
{
|
||||
return $this->send('post', 'products/bulk', ['products' => array_values($products)]);
|
||||
}
|
||||
|
||||
public function updateProduct(int|string $id, array $data): array
|
||||
{
|
||||
return $this->send('patch', "products/{$id}", $data);
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Import;
|
||||
|
||||
use App\Models\PosCategory;
|
||||
use App\Models\PosProduct;
|
||||
use App\Services\Crm\CrmClient;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Bulk product import from a CSV spreadsheet, mode-aware:
|
||||
* - Restaurant → local pos_products (chunked INSERT; categories resolved/created by name).
|
||||
* - Retail → the CRM products API in batches via the bulk-create endpoint.
|
||||
*
|
||||
* Built for large catalogs: parsing is streamed row-by-row and writes are
|
||||
* batched, so hundreds–thousands of rows import in one request.
|
||||
*/
|
||||
class PosProductCsvImportService
|
||||
{
|
||||
/** Hard ceiling so a runaway file can't exhaust memory; split larger files. */
|
||||
private const MAX_ROWS = 5000;
|
||||
|
||||
/** Batch size for CRM bulk-create calls (CRM caps each request at 500). */
|
||||
private const CRM_BATCH = 200;
|
||||
|
||||
/** Canonical column → accepted header aliases (lowercased). */
|
||||
private const ALIASES = [
|
||||
'name' => ['name', 'product', 'product name', 'title', 'item'],
|
||||
'sku' => ['sku', 'code', 'barcode'],
|
||||
'price' => ['price', 'unit_price', 'unit price', 'amount', 'cost', 'selling price'],
|
||||
'currency' => ['currency', 'cur'],
|
||||
'category' => ['category', 'group', 'department'],
|
||||
'description' => ['description', 'desc', 'details'],
|
||||
'tax_rate' => ['tax_rate', 'tax rate', 'tax', 'vat'],
|
||||
'active' => ['active', 'is_active', 'status', 'enabled'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{imported: int, skipped: int, errors: array<int, string>}
|
||||
*/
|
||||
public function import(string $ownerRef, UploadedFile $file, bool $restaurant): array
|
||||
{
|
||||
$handle = fopen($file->getRealPath(), 'rb');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('Could not read the uploaded file.');
|
||||
}
|
||||
|
||||
try {
|
||||
$map = $this->headerMap($handle);
|
||||
if (! isset($map['name'], $map['price'])) {
|
||||
throw new RuntimeException('The file must have at least "name" and "price" columns.');
|
||||
}
|
||||
|
||||
$currency = strtoupper((string) config('pos.default_currency', 'GHS'));
|
||||
$errors = [];
|
||||
$skipped = 0;
|
||||
$seen = []; // de-dupe by lowercased name within the file
|
||||
$valid = []; // normalized rows
|
||||
$line = 1; // header was line 1
|
||||
|
||||
while (($raw = fgetcsv($handle)) !== false) {
|
||||
$line++;
|
||||
if ($this->isBlankRow($raw)) {
|
||||
continue;
|
||||
}
|
||||
if (count($valid) >= self::MAX_ROWS) {
|
||||
$errors[] = 'Stopped at '.self::MAX_ROWS.' rows — split the file and import the rest.';
|
||||
break;
|
||||
}
|
||||
|
||||
$name = trim((string) ($raw[$map['name']] ?? ''));
|
||||
if ($name === '') {
|
||||
$errors[] = "Row {$line}: missing product name — skipped.";
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$price = $this->parsePrice($raw[$map['price']] ?? null);
|
||||
if ($price === null) {
|
||||
$errors[] = "Row {$line}: invalid price for \"{$name}\" — skipped.";
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = mb_strtolower($name);
|
||||
if (isset($seen[$key])) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
|
||||
$valid[] = [
|
||||
'name' => mb_substr($name, 0, 200),
|
||||
'sku' => $this->cell($raw, $map, 'sku', 80),
|
||||
'price_minor' => (int) round($price * 100),
|
||||
'currency' => $this->cellCurrency($raw, $map, $currency),
|
||||
'category' => $this->cell($raw, $map, 'category', 120),
|
||||
'description' => $this->cell($raw, $map, 'description', 5000),
|
||||
'tax_rate' => $this->parseTaxRate($raw[$map['tax_rate'] ?? -1] ?? null),
|
||||
'active' => $this->parseActive($raw[$map['active'] ?? -1] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
if ($valid === []) {
|
||||
throw new RuntimeException('No valid rows found to import. Check the column headers and try again.');
|
||||
}
|
||||
|
||||
$imported = $restaurant
|
||||
? $this->importLocal($ownerRef, $valid)
|
||||
: $this->importCrm($ownerRef, $valid);
|
||||
|
||||
return ['imported' => $imported, 'skipped' => $skipped, 'errors' => $errors];
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/** Restaurant: chunked INSERT into pos_products, categories resolved by name. */
|
||||
private function importLocal(string $ownerRef, array $rows): int
|
||||
{
|
||||
$categories = $this->categoryMap($ownerRef, $rows);
|
||||
$now = now();
|
||||
$imported = 0;
|
||||
|
||||
foreach (array_chunk($rows, 500) as $chunk) {
|
||||
$insert = array_map(fn (array $r): array => [
|
||||
'owner_ref' => $ownerRef,
|
||||
'category_id' => $r['category'] !== null ? ($categories[mb_strtolower($r['category'])] ?? null) : null,
|
||||
'name' => $r['name'],
|
||||
'sku' => $r['sku'],
|
||||
'price_minor' => $r['price_minor'],
|
||||
'currency' => $r['currency'],
|
||||
'is_active' => $r['active'],
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
], $chunk);
|
||||
|
||||
PosProduct::insert($insert);
|
||||
$imported += count($insert);
|
||||
}
|
||||
|
||||
return $imported;
|
||||
}
|
||||
|
||||
/** Retail: batch to the CRM bulk-create endpoint. */
|
||||
private function importCrm(string $ownerRef, array $rows): int
|
||||
{
|
||||
$client = CrmClient::for($ownerRef);
|
||||
$imported = 0;
|
||||
|
||||
foreach (array_chunk($rows, self::CRM_BATCH) as $chunk) {
|
||||
$payload = array_map(fn (array $r): array => [
|
||||
'name' => $r['name'],
|
||||
'sku' => $r['sku'],
|
||||
'type' => 'product',
|
||||
'description' => $r['description'],
|
||||
'unit_price_minor' => $r['price_minor'],
|
||||
'currency' => $r['currency'],
|
||||
'tax_rate' => $r['tax_rate'],
|
||||
'active' => $r['active'],
|
||||
], $chunk);
|
||||
|
||||
$result = $client->bulkCreateProducts($payload);
|
||||
$imported += (int) ($result['imported'] ?? count($payload));
|
||||
}
|
||||
|
||||
return $imported;
|
||||
}
|
||||
|
||||
/** Resolve every distinct category name to an id, creating missing ones. */
|
||||
private function categoryMap(string $ownerRef, array $rows): array
|
||||
{
|
||||
$names = [];
|
||||
foreach ($rows as $r) {
|
||||
if ($r['category'] !== null) {
|
||||
$names[mb_strtolower($r['category'])] = $r['category'];
|
||||
}
|
||||
}
|
||||
if ($names === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$existing = PosCategory::owned($ownerRef)->get(['id', 'name'])
|
||||
->keyBy(fn ($c) => mb_strtolower($c->name));
|
||||
|
||||
$map = [];
|
||||
$position = (int) PosCategory::owned($ownerRef)->max('position');
|
||||
foreach ($names as $lower => $original) {
|
||||
if ($existing->has($lower)) {
|
||||
$map[$lower] = $existing->get($lower)->id;
|
||||
continue;
|
||||
}
|
||||
$map[$lower] = PosCategory::create([
|
||||
'owner_ref' => $ownerRef,
|
||||
'name' => $original,
|
||||
'position' => ++$position,
|
||||
])->id;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/** Build canonical-column → CSV-index map from the header row. */
|
||||
private function headerMap($handle): array
|
||||
{
|
||||
$header = fgetcsv($handle);
|
||||
if ($header === false || $header === null) {
|
||||
throw new RuntimeException('The file appears to be empty.');
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ($header as $i => $label) {
|
||||
$clean = mb_strtolower(trim($this->stripBom((string) $label)));
|
||||
foreach (self::ALIASES as $canonical => $aliases) {
|
||||
if (in_array($clean, $aliases, true)) {
|
||||
$map[$canonical] ??= $i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function cell(array $raw, array $map, string $key, int $max): ?string
|
||||
{
|
||||
if (! isset($map[$key])) {
|
||||
return null;
|
||||
}
|
||||
$value = trim((string) ($raw[$map[$key]] ?? ''));
|
||||
|
||||
return $value === '' ? null : mb_substr($value, 0, $max);
|
||||
}
|
||||
|
||||
private function cellCurrency(array $raw, array $map, string $default): string
|
||||
{
|
||||
$value = strtoupper(trim((string) ($raw[$map['currency'] ?? -1] ?? '')));
|
||||
|
||||
return strlen($value) === 3 ? $value : $default;
|
||||
}
|
||||
|
||||
private function parsePrice(mixed $value): ?float
|
||||
{
|
||||
$clean = preg_replace('/[^0-9.\-]/', '', (string) $value);
|
||||
if ($clean === '' || ! is_numeric($clean)) {
|
||||
return null;
|
||||
}
|
||||
$price = (float) $clean;
|
||||
|
||||
return $price < 0 ? null : $price;
|
||||
}
|
||||
|
||||
private function parseTaxRate(mixed $value): float
|
||||
{
|
||||
$clean = preg_replace('/[^0-9.]/', '', (string) $value);
|
||||
|
||||
return is_numeric($clean) ? min(100.0, max(0.0, (float) $clean)) : 0.0;
|
||||
}
|
||||
|
||||
private function parseActive(mixed $value): bool
|
||||
{
|
||||
$v = mb_strtolower(trim((string) $value));
|
||||
if ($v === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! in_array($v, ['0', 'no', 'n', 'false', 'inactive', 'disabled', 'off'], true);
|
||||
}
|
||||
|
||||
private function isBlankRow(array $raw): bool
|
||||
{
|
||||
foreach ($raw as $cell) {
|
||||
if (trim((string) $cell) !== '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function stripBom(string $value): string
|
||||
{
|
||||
return str_starts_with($value, "\xEF\xBB\xBF") ? substr($value, 3) : $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
@if (!empty(session('import_errors')))
|
||||
<details class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
<summary class="cursor-pointer font-semibold">{{ count(session('import_errors')) }} row(s) were skipped — view details</summary>
|
||||
<ul class="mt-2 list-disc space-y-0.5 pl-5 text-xs text-amber-700">
|
||||
@foreach (session('import_errors') as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</details>
|
||||
@endif
|
||||
@@ -0,0 +1,55 @@
|
||||
<x-app-layout title="Import products">
|
||||
<div class="mx-auto max-w-xl space-y-5">
|
||||
<div>
|
||||
<a href="{{ route('pos.products.index') }}" class="text-sm text-slate-500 hover:text-slate-700">← Products</a>
|
||||
<h1 class="mt-2 text-lg font-semibold text-slate-900">Import products</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Upload a CSV to add many products at once
|
||||
@if ($restaurant)
|
||||
to your <strong>restaurant</strong> catalog.
|
||||
@else
|
||||
to your <strong>retail</strong> catalog (synced to Ladill CRM).
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (session('error'))
|
||||
<div class="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{{ session('error') }}</div>
|
||||
@endif
|
||||
@error('file')
|
||||
<div class="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{{ $message }}</div>
|
||||
@enderror
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">1. Start from the template</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Required columns: <code class="rounded bg-slate-100 px-1 py-0.5 text-xs">name</code> and
|
||||
<code class="rounded bg-slate-100 px-1 py-0.5 text-xs">price</code>. Optional:
|
||||
<code class="rounded bg-slate-100 px-1 py-0.5 text-xs">sku</code>,
|
||||
<code class="rounded bg-slate-100 px-1 py-0.5 text-xs">currency</code>,
|
||||
@if ($restaurant)
|
||||
<code class="rounded bg-slate-100 px-1 py-0.5 text-xs">category</code>,
|
||||
@else
|
||||
<code class="rounded bg-slate-100 px-1 py-0.5 text-xs">description</code>,
|
||||
<code class="rounded bg-slate-100 px-1 py-0.5 text-xs">tax_rate</code>,
|
||||
@endif
|
||||
<code class="rounded bg-slate-100 px-1 py-0.5 text-xs">active</code>.
|
||||
</p>
|
||||
<a href="{{ route('pos.products.import.template') }}"
|
||||
class="mt-3 inline-flex items-center gap-1.5 text-sm font-semibold text-indigo-600 hover:text-indigo-800">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
|
||||
Download CSV template
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('pos.products.import') }}" enctype="multipart/form-data"
|
||||
class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
@csrf
|
||||
<h2 class="text-sm font-semibold text-slate-900">2. Upload your file</h2>
|
||||
<input type="file" name="file" accept=".csv,text/csv" required
|
||||
class="mt-3 block w-full text-sm text-slate-600 file:mr-3 file:rounded-xl file:border-0 file:bg-slate-100 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-slate-700 hover:file:bg-slate-200">
|
||||
<p class="mt-2 text-xs text-slate-500">CSV up to 10 MB. Rows with a missing name or invalid price are skipped and reported.</p>
|
||||
<button type="submit" class="btn-primary mt-4 w-full">Import products</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -1,9 +1,14 @@
|
||||
<x-app-layout title="Products">
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-lg font-semibold text-slate-900">Products</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ route('pos.products.import.form') }}" class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">Import</a>
|
||||
<a href="{{ route('pos.products.create') }}" class="btn-primary">Add product</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include('pos.products._import-errors')
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<table class="min-w-full text-sm">
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
<h1 class="text-lg font-semibold text-slate-900">Products</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Your retail catalog, synced live with Ladill CRM.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ route('pos.products.import.form') }}" class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">Import</a>
|
||||
<a href="{{ route('pos.products.create') }}" class="btn-primary">New product</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="rounded-xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ session('success') }}</div>
|
||||
@@ -15,6 +18,8 @@
|
||||
<div class="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{{ session('error') }}</div>
|
||||
@endif
|
||||
|
||||
@include('pos.products._import-errors')
|
||||
|
||||
<form method="get" class="flex max-w-sm items-center gap-2">
|
||||
<input type="search" name="search" value="{{ $search }}" placeholder="Search products…"
|
||||
class="w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
|
||||
@@ -86,6 +86,9 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||
Route::delete('/menu/modifiers/{modifier}', [MenuController::class, 'destroyModifier'])->name('pos.menu.modifiers.destroy');
|
||||
|
||||
Route::get('/products', [ProductController::class, 'index'])->name('pos.products.index');
|
||||
Route::get('/products/import', [ProductController::class, 'importForm'])->name('pos.products.import.form');
|
||||
Route::post('/products/import', [ProductController::class, 'import'])->name('pos.products.import');
|
||||
Route::get('/products/import/template', [ProductController::class, 'importTemplate'])->name('pos.products.import.template');
|
||||
Route::get('/products/create', [ProductController::class, 'create'])->name('pos.products.create');
|
||||
Route::post('/products', [ProductController::class, 'store'])->name('pos.products.store');
|
||||
Route::get('/products/{product}/edit', [ProductController::class, 'edit'])->name('pos.products.edit');
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"qr_wallets":[],"qr_codes":[{"platform_id":10,"owner_public_id":"usr_events_owner","owner_email":"dejah85@example.org","short_code":"demo-programme","type":"itinerary","label":"Demo Programme","payload":"{\"content\":{\"title\":\"Demo Programme\"},\"style\":[]}","is_active":true,"scans_total":0,"created_at":"2026-06-24 00:00:25","updated_at":"2026-06-24 00:00:25","destination_updated_at":"2026-06-24 00:00:25"},{"platform_id":11,"owner_public_id":"usr_events_owner","owner_email":"dejah85@example.org","short_code":"demo-event","type":"event","label":"Demo Event","payload":"{\"content\":{\"name\":\"Demo Event\",\"programme_qr_id\":10},\"style\":[]}","is_active":true,"scans_total":0,"created_at":"2026-06-24 00:00:25","updated_at":"2026-06-24 00:00:25","destination_updated_at":"2026-06-24 00:00:25"}],"qr_event_registrations":[],"qr_scan_events":[],"qr_transactions":[]}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\EnsurePlatformSession;
|
||||
use App\Models\PosCategory;
|
||||
use App\Models\PosLocation;
|
||||
use App\Models\PosProduct;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PosProductImportTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function user(): User
|
||||
{
|
||||
return User::create([
|
||||
'public_id' => 'u-'.uniqid(),
|
||||
'name' => 'Owner',
|
||||
'email' => uniqid().'@example.com',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||
$this->withoutVite();
|
||||
config(['crm.url' => 'https://crm.test/api', 'crm.key' => 'test-crm-key']);
|
||||
}
|
||||
|
||||
private function csv(string $content): UploadedFile
|
||||
{
|
||||
return UploadedFile::fake()->createWithContent('products.csv', $content);
|
||||
}
|
||||
|
||||
public function test_restaurant_import_creates_local_products_and_categories(): void
|
||||
{
|
||||
$user = $this->user();
|
||||
PosLocation::create(['owner_ref' => $user->public_id, 'name' => 'Main register', 'currency' => 'GHS', 'service_style' => 'restaurant']);
|
||||
|
||||
$csv = "name,sku,price,category,active\n"
|
||||
."Coffee,CFE-01,15.00,Drinks,yes\n"
|
||||
."Latte,LAT-02,18.50,Drinks,yes\n"
|
||||
."Croissant,,8.00,Pastry,no\n"
|
||||
.",NONAME,5.00,Drinks,yes\n" // skipped: no name
|
||||
."Bad,BAD-1,notaprice,Drinks,yes\n"; // skipped: bad price
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('pos.products.import'), ['file' => $this->csv($csv)])
|
||||
->assertRedirect(route('pos.products.index'))
|
||||
->assertSessionHas('success');
|
||||
|
||||
$this->assertSame(3, PosProduct::where('owner_ref', $user->public_id)->count());
|
||||
$coffee = PosProduct::where('owner_ref', $user->public_id)->where('name', 'Coffee')->firstOrFail();
|
||||
$this->assertSame(1500, $coffee->price_minor);
|
||||
$this->assertNotNull($coffee->category_id);
|
||||
$this->assertFalse((bool) PosProduct::where('name', 'Croissant')->firstOrFail()->is_active);
|
||||
// Drinks + Pastry created once each.
|
||||
$this->assertSame(2, PosCategory::where('owner_ref', $user->public_id)->count());
|
||||
}
|
||||
|
||||
public function test_retail_import_batches_to_crm_bulk_endpoint(): void
|
||||
{
|
||||
Http::fake([
|
||||
'crm.test/api/products/bulk' => Http::response(['imported' => 2], 201),
|
||||
'crm.test/api/*' => Http::response(['data' => []], 200),
|
||||
]);
|
||||
|
||||
$user = $this->user(); // retail by default (no restaurant location)
|
||||
$csv = "name,sku,price,description,active\n"
|
||||
."Mug,MUG-01,25.00,Ceramic,yes\n"
|
||||
."Tee,TSH-02,60.00,Cotton,yes\n";
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('pos.products.import'), ['file' => $this->csv($csv)])
|
||||
->assertRedirect(route('pos.products.index'))
|
||||
->assertSessionHas('success');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), '/products/bulk')
|
||||
&& count($request['products']) === 2
|
||||
&& $request['products'][0]['name'] === 'Mug'
|
||||
&& $request['products'][0]['unit_price_minor'] === 2500
|
||||
&& $request['products'][0]['type'] === 'product';
|
||||
});
|
||||
|
||||
// No local products created in retail mode.
|
||||
$this->assertSame(0, PosProduct::where('owner_ref', $user->public_id)->count());
|
||||
}
|
||||
|
||||
public function test_template_download_is_csv(): void
|
||||
{
|
||||
$response = $this->actingAs($this->user())
|
||||
->get(route('pos.products.import.template'))
|
||||
->assertOk()
|
||||
->assertHeader('content-disposition', 'attachment; filename=pos-products-template.csv');
|
||||
|
||||
$this->assertStringContainsString('text/csv', $response->headers->get('content-type'));
|
||||
$this->assertStringContainsString('name', $response->streamedContent());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user