Deploy Ladill Merchant / deploy (push) Successful in 53s
After importing, optionally mark the platform copy inactive so Merchant is the sole live owner and the platform stops double-counting. Public scans still forward here (the monolith's MerchantQrForwarder routes shop/menu by type regardless of is_active). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
241 lines
8.4 KiB
PHP
241 lines
8.4 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
/**
|
|
* One-time importer: copy legacy menu/shop storefronts (and their products,
|
|
* which live inline in qr_codes.payload.content.sections) from the shared
|
|
* platform database (ladilldb, the `platform` connection) into this app's
|
|
* own database, so owners who created them before the Merchant extraction
|
|
* can see and manage them again.
|
|
*
|
|
* Owners are matched to local users by their platform public_id (the OIDC
|
|
* `sub` used at SSO login); a local user stub is created when missing.
|
|
* Product images are copied from the `platform_qr` disk to the local `qr`
|
|
* disk (set PLATFORM_QR_ROOT to the monolith's storage/app/private/qr).
|
|
*
|
|
* Idempotent: existing local codes (matched by short_code) are skipped
|
|
* unless --refresh is given.
|
|
*/
|
|
class ImportLegacyStorefronts extends Command
|
|
{
|
|
protected $signature = 'storefronts:import-legacy
|
|
{--user= : Only import for this owner (platform public_id or email)}
|
|
{--refresh : Overwrite local codes that already exist (matched by short_code)}
|
|
{--skip-images : Do not copy image files, only the database rows}
|
|
{--deactivate-source : After a successful import, mark the platform copy inactive so Merchant is the single source of truth (public scans still forward here via type)}
|
|
{--dry-run : Report what would happen without writing anything}';
|
|
|
|
protected $description = 'Import legacy menu/shop storefronts and their products from the platform database.';
|
|
|
|
/** Storefront QR types that hold products in their payload. */
|
|
private array $types = ['shop', 'menu'];
|
|
|
|
public function handle(): int
|
|
{
|
|
$dry = (bool) $this->option('dry-run');
|
|
$refresh = (bool) $this->option('refresh');
|
|
$skipImages = (bool) $this->option('skip-images');
|
|
$deactivateSource = (bool) $this->option('deactivate-source');
|
|
|
|
$platform = DB::connection('platform');
|
|
|
|
$query = $platform->table('qr_codes')->whereIn('type', $this->types);
|
|
|
|
if ($owner = $this->option('user')) {
|
|
$platformOwner = $platform->table('users')
|
|
->where('public_id', $owner)
|
|
->orWhere('email', $owner)
|
|
->first();
|
|
|
|
if (! $platformOwner) {
|
|
$this->error("No platform user matches '{$owner}'.");
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$query->where('user_id', $platformOwner->id);
|
|
}
|
|
|
|
$rows = $query->orderBy('id')->get();
|
|
$this->info("Found {$rows->count()} legacy storefront code(s) on the platform.");
|
|
|
|
if ($rows->isEmpty()) {
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$platformUsers = $platform->table('users')
|
|
->whereIn('id', $rows->pluck('user_id')->unique()->all())
|
|
->get()
|
|
->keyBy('id');
|
|
|
|
$imported = 0;
|
|
$skipped = 0;
|
|
$usersCreated = 0;
|
|
$imagesCopied = 0;
|
|
$deactivated = 0;
|
|
$failed = 0;
|
|
|
|
foreach ($rows as $row) {
|
|
$platformUser = $platformUsers->get($row->user_id);
|
|
|
|
if (! $platformUser) {
|
|
$this->warn(" {$row->short_code}: platform owner #{$row->user_id} not found — skipped.");
|
|
$failed++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$localUser = User::query()
|
|
->when($platformUser->public_id, fn ($q) => $q->where('public_id', $platformUser->public_id))
|
|
->when(! $platformUser->public_id, fn ($q) => $q->where('email', $platformUser->email))
|
|
->first();
|
|
|
|
if (! $localUser && $platformUser->public_id) {
|
|
$localUser = User::where('email', $platformUser->email)->first();
|
|
}
|
|
|
|
if (! $localUser) {
|
|
if (! $platformUser->public_id) {
|
|
$this->warn(" {$row->short_code}: owner has no public_id and no local match — skipped.");
|
|
$failed++;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($dry) {
|
|
$this->line(" would create local user {$platformUser->email}");
|
|
} else {
|
|
$localUser = User::create([
|
|
'public_id' => $platformUser->public_id,
|
|
'name' => $platformUser->name,
|
|
'email' => $platformUser->email ?: $platformUser->public_id.'@users.ladill.com',
|
|
'avatar_url' => $platformUser->avatar_url ?? null,
|
|
]);
|
|
}
|
|
|
|
$usersCreated++;
|
|
}
|
|
|
|
$existing = DB::table('qr_codes')->where('short_code', $row->short_code)->first();
|
|
|
|
if ($existing && ! $refresh) {
|
|
$skipped++;
|
|
$this->line(" {$row->short_code}: already exists — skipped.");
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($dry) {
|
|
$this->line(" would import {$row->short_code} ({$row->type}) for {$platformUser->email}");
|
|
$imported++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$attrs = [
|
|
'user_id' => $localUser->id,
|
|
'short_code' => $row->short_code,
|
|
'type' => $row->type,
|
|
'label' => $row->label,
|
|
'destination_url' => $row->destination_url,
|
|
'qr_document_id' => null,
|
|
'payload' => $row->payload,
|
|
'is_active' => $row->is_active,
|
|
'png_path' => $row->png_path,
|
|
'svg_path' => $row->svg_path,
|
|
'scans_total' => $row->scans_total ?? 0,
|
|
'unique_scans_total' => $row->unique_scans_total ?? 0,
|
|
'last_scanned_at' => $row->last_scanned_at,
|
|
'destination_updated_at' => $row->destination_updated_at,
|
|
'created_at' => $row->created_at,
|
|
'updated_at' => $row->updated_at,
|
|
];
|
|
|
|
if ($existing) {
|
|
DB::table('qr_codes')->where('id', $existing->id)->update($attrs);
|
|
} else {
|
|
DB::table('qr_codes')->insert($attrs);
|
|
}
|
|
|
|
$imported++;
|
|
|
|
if (! $skipImages) {
|
|
$imagesCopied += $this->copyImages($row->png_path, $row->svg_path, $row->payload);
|
|
}
|
|
|
|
if ($deactivateSource && $row->is_active) {
|
|
$platform->table('qr_codes')->where('id', $row->id)->update(['is_active' => 0]);
|
|
$deactivated++;
|
|
}
|
|
}
|
|
|
|
$this->newLine();
|
|
$this->info("Imported: {$imported} · Skipped: {$skipped} · Local users created: {$usersCreated} · Images copied: {$imagesCopied} · Source deactivated: {$deactivated} · Failed: {$failed}");
|
|
|
|
if ($dry) {
|
|
$this->comment('Dry run — no changes were written.');
|
|
}
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* Copy a code's image files (QR PNG/SVG + every *_path / image_path in the
|
|
* payload, e.g. logo_path, cover_path, sections[][items][].image_path) from
|
|
* the platform QR storage to the local QR disk. Best-effort.
|
|
*/
|
|
private function copyImages(?string $png, ?string $svg, ?string $payloadJson): int
|
|
{
|
|
$paths = [$png, $svg];
|
|
$this->collectPaths(json_decode((string) $payloadJson, true) ?: [], $paths);
|
|
$paths = array_values(array_unique(array_filter($paths, fn ($p) => is_string($p) && $p !== '')));
|
|
|
|
$src = Storage::disk('platform_qr');
|
|
$dst = Storage::disk('qr');
|
|
$copied = 0;
|
|
|
|
foreach ($paths as $path) {
|
|
if ($dst->exists($path)) {
|
|
continue;
|
|
}
|
|
|
|
if (! $src->exists($path)) {
|
|
$this->warn(" image missing on source: {$path}");
|
|
|
|
continue;
|
|
}
|
|
|
|
$dst->put($path, $src->get($path));
|
|
$copied++;
|
|
}
|
|
|
|
return $copied;
|
|
}
|
|
|
|
/**
|
|
* @param array<mixed> $data
|
|
* @param array<int, mixed> $paths
|
|
*/
|
|
private function collectPaths(array $data, array &$paths): void
|
|
{
|
|
foreach ($data as $key => $value) {
|
|
if (is_array($value)) {
|
|
$this->collectPaths($value, $paths);
|
|
|
|
continue;
|
|
}
|
|
|
|
if (is_string($value) && $value !== '' && is_string($key)
|
|
&& (str_ends_with($key, '_path') || $key === 'path')) {
|
|
$paths[] = $value;
|
|
}
|
|
}
|
|
}
|
|
}
|