Files
isaaccladandCursor 022f18084a
Deploy Ladill POS / deploy (push) Successful in 56s
Seed DemoWorld staff and customer continuity for POS demo data
Replace ad-hoc demo-cashier-{id} accounts with DemoWorld staff who hold
a pos role, mirrored locally by email until SSO remaps to public_id.
Sales for the first named customers now use DemoWorld people (name +
phone) instead of generic "Cash Customer N" labels, and staff logins
skip the login reseed so mid-demo state survives.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:33:29 +00:00

441 lines
16 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 App\Support\DemoWorld;
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'],
);
}
$this->seedTeam($user, $plan);
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;
}
$people = DemoWorld::people();
$namedCustomerCount = min(10, count($people));
$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];
}
$person = $i < $namedCustomerCount ? $people[$i] : null;
$customerName = $person ? DemoWorld::fullName($person) : 'Cash Customer '.($i + 1);
$customerPhone = $person ? DemoWorld::phoneLocal($person['phone']) : null;
$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' => $customerName,
'customer_phone' => $customerPhone,
'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,
]);
}
}
/**
* Mirror DemoWorld staff with a `pos` role as local PosMember rows, keyed
* by staff email until SSO login remaps user_ref to the real public_id
* (see TeamMemberProvisioner::sync()).
*/
private function seedTeam(User $owner, string $plan): void
{
$location = PosLocation::owned($owner->public_id)->orderBy('id')->first();
foreach (DemoWorld::staff($plan) as $staff) {
$role = $staff['roles']['pos'] ?? null;
if (! is_string($role) || $role === '') {
continue;
}
$email = strtolower(trim($staff['email']));
User::query()->firstOrCreate(
['email' => $email],
[
'public_id' => (string) Str::uuid(),
'name' => $staff['name'],
'password' => bcrypt(config('demo_accounts.password', DemoWorld::DEFAULT_PASSWORD)),
'email_verified_at' => now(),
],
);
PosMember::query()->updateOrCreate(
[
'owner_ref' => $owner->public_id,
'user_ref' => $email,
],
[
'role' => in_array($role, [PosMember::ROLE_ADMIN, PosMember::ROLE_MANAGER, PosMember::ROLE_CASHIER], true)
? $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;
}
}