Add storefronts:import-legacy command
One-time importer that copies legacy menu/shop storefronts (and their products, stored inline in qr_codes.payload.content.sections) from the shared platform DB into this app's database, so owners who created them before the Merchant extraction can see/manage them again. - Maps platform owners to local users by public_id (SSO sub), creating a local user stub when missing - Copies product images from a new read-only `platform_qr` disk (PLATFORM_QR_ROOT) to the local `qr` disk - Idempotent (skips existing short_codes unless --refresh); supports --user, --dry-run, --skip-images Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a939abc2de
commit
37425f5ad1
@@ -0,0 +1,232 @@
|
|||||||
|
<?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}
|
||||||
|
{--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');
|
||||||
|
|
||||||
|
$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;
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info("Imported: {$imported} · Skipped: {$skipped} · Local users created: {$usersCreated} · Images copied: {$imagesCopied} · 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,16 @@ return [
|
|||||||
'report' => false,
|
'report' => false,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Read-only view of the monolith's QR storage, used by the
|
||||||
|
// storefronts:import-legacy command to copy legacy product images.
|
||||||
|
// Set PLATFORM_QR_ROOT to the monolith's storage/app/private/qr path.
|
||||||
|
'platform_qr' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => env('PLATFORM_QR_ROOT', storage_path('app/private/qr')),
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
Reference in New Issue
Block a user