Files
ladill-pos/app/Services/Pos/DemoSeeder.php
T
isaaccladandCursor ff098c0a29
Deploy Ladill POS / deploy (push) Successful in 2m34s
Add POS demo:seed for register walkthroughs.
Seed locations, products, and cash sales within Free caps and Pro multi-site data.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:03:57 +00:00

418 lines
15 KiB
PHP

<?php
namespace App\Services\Pos;
use App\Models\Pos\ProSubscription;
use App\Models\PosCategory;
use App\Models\PosDevice;
use App\Models\PosLocation;
use App\Models\PosMember;
use App\Models\PosPayment;
use App\Models\PosProduct;
use App\Models\PosSale;
use App\Models\PosSaleLine;
use App\Models\PosStation;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class DemoSeeder
{
/** @var array<string, array{locations: int, products_per_location: int, stations: int, sales_per_location: int}> */
private const VOLUMES = [
'free' => [
'locations' => 1,
'products_per_location' => 40,
'stations' => 0,
'sales_per_location' => 12,
],
'pro' => [
'locations' => 3,
'products_per_location' => 55,
'stations' => 3,
'sales_per_location' => 25,
],
'enterprise' => [
'locations' => 5,
'products_per_location' => 70,
'stations' => 5,
'sales_per_location' => 40,
],
];
private const PRODUCT_NAMES = [
'Bottled Water', 'Soft Drink', 'Rice Pack', 'Chicken Wrap', 'Veggie Bowl',
'Coffee', 'Tea', 'Pastry', 'Sandwich', 'Energy Bar',
'Phone Charger', 'USB Cable', 'Notebook', 'Pen Set', 'Tote Bag',
'Shampoo', 'Soap', 'Toothpaste', 'Detergent', 'Tissue Pack',
];
/**
* @return array{user: User, plan: string, counts: array<string, int>}
*/
public function seed(string $identity, string $plan, bool $reset = false): array
{
$plan = $this->normalizePlan($plan);
$user = $this->resolveUser($identity);
if ($reset) {
$this->resetForOwner($user->public_id);
}
$this->assignPlan($user, $plan);
$volumes = self::VOLUMES[$plan];
$stations = $this->seedStations($user->public_id, $volumes['stations']);
$categories = $this->seedCategories($user->public_id);
$locations = $this->seedLocations($user->public_id, $volumes['locations']);
foreach ($locations as $index => $location) {
$this->seedRegister($user->public_id, $location, $index === 0);
$products = $this->seedProducts(
$user->public_id,
$location,
$categories,
$stations,
$volumes['products_per_location'],
);
$this->seedCashSales(
$user->public_id,
$location,
$products,
$volumes['sales_per_location'],
);
}
if ($plan !== 'free') {
$this->seedTeam($user);
}
return [
'user' => $user,
'plan' => $plan,
'counts' => [
'locations' => PosLocation::owned($user->public_id)->count(),
'products' => PosProduct::owned($user->public_id)->count(),
'stations' => PosStation::owned($user->public_id)->count(),
'sales' => PosSale::owned($user->public_id)->count(),
'registers' => PosDevice::owned($user->public_id)->where('type', 'register')->count(),
],
];
}
public function resolveUser(string $identity): User
{
$identity = trim($identity);
$user = User::query()
->where(function ($q) use ($identity) {
$q->where('email', $identity)->orWhere('public_id', $identity);
})
->first();
if ($user) {
return $user;
}
$email = str_contains($identity, '@') ? $identity : $identity.'@demo.ladill.local';
$publicId = str_contains($identity, '@') ? (string) Str::uuid() : $identity;
return User::query()->create([
'public_id' => $publicId,
'name' => 'Demo User',
'email' => $email,
'password' => bcrypt('password'),
'email_verified_at' => now(),
]);
}
public function assignPlan(User $user, string $plan): void
{
if ($plan === 'free') {
ProSubscription::query()->where('user_id', $user->id)->delete();
return;
}
$price = $plan === ProSubscription::PLAN_ENTERPRISE
? (int) config('pos.plans.enterprise.price_minor', 249000)
: (int) config('pos.plans.pro.price_minor', config('pos.pro.price_minor', 179000));
ProSubscription::updateOrCreate(
['user_id' => $user->id],
[
'plan' => $plan,
'status' => ProSubscription::STATUS_ACTIVE,
'price_minor' => $price,
'currency' => (string) config('pos.pro.currency', 'GHS'),
'auto_renew' => false,
'started_at' => now(),
'current_period_end' => now()->addYear(),
'last_charged_at' => now(),
'canceled_at' => null,
'last_reference' => 'demo-pos-'.$plan.'-'.$user->id,
'last_error' => null,
],
);
}
public function resetForOwner(string $ownerRef): void
{
DB::transaction(function () use ($ownerRef) {
$saleIds = PosSale::owned($ownerRef)->pluck('id');
if ($saleIds->isNotEmpty()) {
$lineIds = PosSaleLine::query()->whereIn('pos_sale_id', $saleIds)->pluck('id');
if ($lineIds->isNotEmpty() && DB::getSchemaBuilder()->hasTable('pos_sale_line_modifiers')) {
DB::table('pos_sale_line_modifiers')->whereIn('pos_sale_line_id', $lineIds)->delete();
}
PosSaleLine::query()->whereIn('pos_sale_id', $saleIds)->delete();
PosPayment::query()->whereIn('pos_sale_id', $saleIds)->delete();
PosSale::owned($ownerRef)->delete();
}
if (DB::getSchemaBuilder()->hasTable('pos_product_modifier_group')) {
$productIds = PosProduct::owned($ownerRef)->pluck('id');
if ($productIds->isNotEmpty()) {
DB::table('pos_product_modifier_group')->whereIn('product_id', $productIds)->delete();
}
}
PosProduct::owned($ownerRef)->delete();
PosDevice::owned($ownerRef)->delete();
PosMember::owned($ownerRef)->delete();
if (DB::getSchemaBuilder()->hasTable('pos_tables')) {
DB::table('pos_tables')->where('owner_ref', $ownerRef)->delete();
}
if (DB::getSchemaBuilder()->hasTable('pos_customer_displays')) {
DB::table('pos_customer_displays')->where('owner_ref', $ownerRef)->delete();
}
if (DB::getSchemaBuilder()->hasTable('pos_modifiers')) {
DB::table('pos_modifiers')->where('owner_ref', $ownerRef)->delete();
}
if (DB::getSchemaBuilder()->hasTable('pos_modifier_groups')) {
DB::table('pos_modifier_groups')->where('owner_ref', $ownerRef)->delete();
}
PosCategory::owned($ownerRef)->delete();
PosStation::owned($ownerRef)->delete();
PosLocation::owned($ownerRef)->delete();
});
}
/** @return list<PosStation> */
private function seedStations(string $ownerRef, int $count): array
{
if ($count <= 0) {
return [];
}
$stations = PosStation::owned($ownerRef)->orderBy('id')->get()->all();
$names = ['Hot Kitchen', 'Cold Prep', 'Bar', 'Grill', 'Dessert'];
for ($i = count($stations); $i < $count; $i++) {
$stations[] = PosStation::query()->create([
'owner_ref' => $ownerRef,
'name' => $names[$i] ?? ('Station '.($i + 1)),
'position' => $i + 1,
]);
}
return array_slice($stations, 0, $count);
}
/** @return list<PosCategory> */
private function seedCategories(string $ownerRef): array
{
$names = ['Beverages', 'Food', 'Retail', 'Services'];
$categories = [];
foreach ($names as $i => $name) {
$categories[] = PosCategory::query()->firstOrCreate(
['owner_ref' => $ownerRef, 'name' => $name],
['position' => $i + 1],
);
}
return $categories;
}
/** @return list<PosLocation> */
private function seedLocations(string $ownerRef, int $count): array
{
$locations = PosLocation::owned($ownerRef)->orderBy('id')->get()->all();
$names = ['Main Register', 'Airport Kiosk', 'Mall Counter', 'Campus Store', 'Harbour Outlet'];
for ($i = count($locations); $i < $count; $i++) {
$locations[] = PosLocation::query()->create([
'owner_ref' => $ownerRef,
'name' => $names[$i] ?? ('Location '.($i + 1)),
'currency' => 'GHS',
'is_default' => count($locations) === 0,
'service_style' => PosLocation::STYLE_RETAIL,
'receipt_footer' => 'Thank you for shopping with Demo POS.',
]);
}
if ($locations !== [] && ! collect($locations)->contains(fn (PosLocation $l) => $l->is_default)) {
$locations[0]->forceFill(['is_default' => true])->save();
}
return array_slice($locations, 0, $count);
}
private function seedRegister(string $ownerRef, PosLocation $location, bool $primary): void
{
$exists = PosDevice::owned($ownerRef)
->where('location_id', $location->id)
->where('type', 'register')
->exists();
if ($exists) {
return;
}
PosDevice::query()->create([
'owner_ref' => $ownerRef,
'location_id' => $location->id,
'name' => $location->name.' Till',
'type' => 'register',
'status' => PosDevice::STATUS_ONLINE,
'last_online_at' => now(),
'config' => ['demo' => true, 'primary' => $primary],
]);
}
/**
* @param list<PosCategory> $categories
* @param list<PosStation> $stations
* @return list<PosProduct>
*/
private function seedProducts(
string $ownerRef,
PosLocation $location,
array $categories,
array $stations,
int $count,
): array {
$products = PosProduct::owned($ownerRef)
->where('location_id', $location->id)
->orderBy('id')
->get()
->all();
for ($i = count($products); $i < $count; $i++) {
$name = self::PRODUCT_NAMES[$i % count(self::PRODUCT_NAMES)];
$products[] = PosProduct::query()->create([
'owner_ref' => $ownerRef,
'location_id' => $location->id,
'category_id' => $categories[$i % count($categories)]->id,
'station_id' => $stations !== [] ? $stations[$i % count($stations)]->id : null,
'name' => $name.' '.($i + 1),
'sku' => 'POS-'.$location->id.'-'.str_pad((string) ($i + 1), 3, '0', STR_PAD_LEFT),
'price_minor' => 500 + (($i % 20) * 250),
'currency' => 'GHS',
'is_active' => true,
]);
}
return array_slice($products, 0, $count);
}
/**
* @param list<PosProduct> $products
*/
private function seedCashSales(string $ownerRef, PosLocation $location, array $products, int $count): void
{
if ($products === []) {
return;
}
$existing = PosSale::owned($ownerRef)->where('location_id', $location->id)->count();
for ($i = $existing; $i < $count; $i++) {
$lineCount = 1 + ($i % 3);
$lines = [];
$subtotal = 0;
for ($l = 0; $l < $lineCount; $l++) {
$product = $products[($i + $l) % count($products)];
$qty = 1 + ($l % 2);
$lineTotal = $product->price_minor * $qty;
$subtotal += $lineTotal;
$lines[] = [$product, $qty, $lineTotal];
}
$sale = PosSale::query()->create([
'owner_ref' => $ownerRef,
'location_id' => $location->id,
'reference' => 'DEMO-'.$location->id.'-'.str_pad((string) ($i + 1), 4, '0', STR_PAD_LEFT).'-'.substr(md5($ownerRef.$i), 0, 6),
'status' => PosSale::STATUS_PAID,
'payment_method' => PosSale::METHOD_CASH,
'order_type' => PosSale::ORDER_COUNTER,
'kitchen_status' => PosSale::KITCHEN_NONE,
'customer_name' => 'Cash Customer '.($i + 1),
'subtotal_minor' => $subtotal,
'total_minor' => $subtotal,
'currency' => 'GHS',
'paid_at' => now()->subDays($i % 10)->subMinutes($i * 3),
'opened_at' => now()->subDays($i % 10)->subMinutes(($i * 3) + 5),
'closed_at' => now()->subDays($i % 10)->subMinutes($i * 3),
]);
foreach ($lines as $pos => [$product, $qty, $lineTotal]) {
PosSaleLine::query()->create([
'pos_sale_id' => $sale->id,
'product_id' => $product->id,
'station_id' => $product->station_id,
'name' => $product->name,
'unit_price_minor' => $product->price_minor,
'quantity' => $qty,
'line_total_minor' => $lineTotal,
'position' => $pos,
]);
}
PosPayment::query()->create([
'pos_sale_id' => $sale->id,
'owner_ref' => $ownerRef,
'amount_minor' => $subtotal,
'method' => PosPayment::METHOD_CASH,
'status' => PosPayment::STATUS_PAID,
'paid_at' => $sale->paid_at,
]);
}
}
private function seedTeam(User $owner): void
{
if (PosMember::owned($owner->public_id)->exists()) {
return;
}
$cashier = User::query()->firstOrCreate(
['email' => 'demo-cashier-'.$owner->id.'@ladill.local'],
[
'public_id' => (string) Str::uuid(),
'name' => 'Demo Cashier',
'password' => bcrypt('password'),
'email_verified_at' => now(),
],
);
$location = PosLocation::owned($owner->public_id)->orderBy('id')->first();
PosMember::query()->create([
'owner_ref' => $owner->public_id,
'user_ref' => $cashier->public_id,
'role' => PosMember::ROLE_CASHIER,
'location_id' => $location?->id,
]);
}
private function normalizePlan(string $plan): string
{
$plan = strtolower(trim($plan));
if (! in_array($plan, ['free', 'pro', 'enterprise'], true)) {
throw new \InvalidArgumentException('Plan must be free, pro, or enterprise.');
}
return $plan;
}
}