Add POS demo:seed for register walkthroughs.
Deploy Ladill POS / deploy (push) Successful in 2m34s

Seed locations, products, and cash sales within Free caps and Pro multi-site data.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-17 10:03:57 +00:00
co-authored by Cursor
parent 49a0020522
commit ff098c0a29
3 changed files with 584 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Console\Commands;
use App\Services\Pos\DemoSeeder;
use App\Services\Pos\SubscriptionService;
use Illuminate\Console\Command;
/**
* Seed plan-aware POS demo data for a platform identity (email or public_id).
*/
class DemoSeedCommand extends Command
{
protected $signature = 'demo:seed
{identity : Email or public_id of the local User mirror}
{--plan=free : free|pro|enterprise}
{--reset : Wipe tenant-scoped demo data before reseeding}';
protected $description = 'Seed realistic POS demo data for a Free/Pro/Enterprise account';
public function handle(DemoSeeder $seeder, SubscriptionService $subscriptions): int
{
$identity = (string) $this->argument('identity');
$plan = strtolower((string) $this->option('plan'));
$reset = (bool) $this->option('reset');
if (! in_array($plan, ['free', 'pro', 'enterprise'], true)) {
$this->error('Invalid --plan. Use free, pro, or enterprise.');
return self::FAILURE;
}
try {
$result = $seeder->seed($identity, $plan, $reset);
} catch (\InvalidArgumentException $e) {
$this->error($e->getMessage());
return self::FAILURE;
}
$user = $result['user'];
$effective = $subscriptions->planKey($user);
$this->info(sprintf(
'Seeded POS demo for %s (%s) plan=%s (effective=%s)%s',
$user->email,
$user->public_id,
$result['plan'],
$effective,
$reset ? ' [reset]' : '',
));
$this->table(
['Metric', 'Count'],
collect($result['counts'])->map(fn ($v, $k) => [$k, (string) $v])->values()->all()
);
return self::SUCCESS;
}
}
+417
View File
@@ -0,0 +1,417 @@
<?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;
}
}