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>
418 lines
15 KiB
PHP
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;
|
|
}
|
|
}
|