Fix labor table grouping colspan + auto-import catalog on deploy
- LaborResource: hide category column from data rows by default (toggleable) so group headers span all columns cleanly; the group header already shows the localized category name - ImportLaborCatalogCommand: add --all flag to iterate every company, refactor into importForCompany() helper, keep idempotent (skips existing codes, updates only with --force) - entrypoint.sh: run catalog:import-labors --all after each deploy so all 386 operations + 45 categories are seeded automatically Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,67 +8,96 @@ use App\Models\Tenant\Labor;
|
|||||||
use App\Models\Tenant\LaborCategory;
|
use App\Models\Tenant\LaborCategory;
|
||||||
use App\Tenancy\TenantManager;
|
use App\Tenancy\TenantManager;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class ImportLaborCatalogCommand extends Command
|
class ImportLaborCatalogCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'catalog:import-labors {company : Company slug or numeric ID} {--file= : Path to JSON file (default: psauto-katalog-rabot-full.json)} {--force : Update existing records}';
|
protected $signature = 'catalog:import-labors
|
||||||
|
{company? : Company slug or numeric ID (omit with --all)}
|
||||||
|
{--all : Import for every company in the database}
|
||||||
|
{--file= : Path to JSON file (defaults to database/data/labor-catalog.json)}
|
||||||
|
{--force : Update existing records by code}';
|
||||||
|
|
||||||
protected $description = 'Import labor catalog from psauto-katalog-rabot-full.json into labor_categories and labors tables';
|
protected $description = 'Import labor catalog (categories + operations) from JSON into labor_categories and labors tables';
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
$companyArg = $this->argument('company');
|
$file = $this->resolveFile();
|
||||||
$company = is_numeric($companyArg)
|
if (! $file) {
|
||||||
? Company::find((int) $companyArg)
|
$this->error('JSON file not found. Use --file=/path/to/labor-catalog.json');
|
||||||
: Company::where('slug', $companyArg)->first();
|
|
||||||
|
|
||||||
if (! $company) {
|
|
||||||
$this->error("Company '{$companyArg}' not found.");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
app(TenantManager::class)->setCurrent($company);
|
|
||||||
|
|
||||||
$file = $this->option('file') ?: database_path('data/labor-catalog.json');
|
|
||||||
if (! file_exists($file)) {
|
|
||||||
foreach ([
|
|
||||||
'/tmp/service/todo/psauto-katalog-rabot-full.json',
|
|
||||||
storage_path('app/labor-catalog.json'),
|
|
||||||
] as $path) {
|
|
||||||
if (file_exists($path)) { $file = $path; break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! file_exists($file)) {
|
|
||||||
$this->error("JSON file not found. Pass --file=/path/to/psauto-katalog-rabot-full.json");
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = json_decode(file_get_contents($file), true);
|
$data = json_decode(file_get_contents($file), true);
|
||||||
|
if (! $data) {
|
||||||
|
$this->error('Failed to parse JSON file.');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('all')) {
|
||||||
|
$companies = Company::all();
|
||||||
|
foreach ($companies as $company) {
|
||||||
|
$this->importForCompany($company, $data);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$arg = $this->argument('company');
|
||||||
|
if (! $arg) {
|
||||||
|
$this->error('Provide a company slug/id, or use --all.');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$company = is_numeric($arg)
|
||||||
|
? Company::find((int) $arg)
|
||||||
|
: Company::where('slug', $arg)->first();
|
||||||
|
|
||||||
|
if (! $company) {
|
||||||
|
$this->error("Company '{$arg}' not found.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->importForCompany($company, $data);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveFile(): ?string
|
||||||
|
{
|
||||||
|
$file = $this->option('file');
|
||||||
|
if ($file && file_exists($file)) return $file;
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
database_path('data/labor-catalog.json'),
|
||||||
|
'/tmp/service/todo/psauto-katalog-rabot-full.json',
|
||||||
|
storage_path('app/labor-catalog.json'),
|
||||||
|
] as $path) {
|
||||||
|
if (file_exists($path)) return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function importForCompany(Company $company, array $data): void
|
||||||
|
{
|
||||||
|
app(TenantManager::class)->setCurrent($company);
|
||||||
|
$force = $this->option('force');
|
||||||
|
|
||||||
$allCats = $data['categories'] ?? [];
|
$allCats = $data['categories'] ?? [];
|
||||||
$operations = $data['operations'] ?? [];
|
$operations = $data['operations'] ?? [];
|
||||||
|
|
||||||
$catMap = collect($allCats)->keyBy('id');
|
$catMap = collect($allCats)->keyBy('id');
|
||||||
|
|
||||||
// Identify leaf categories (those that have operations assigned to them)
|
|
||||||
$usedCatIds = collect($operations)->pluck('service_category_id')->unique();
|
$usedCatIds = collect($operations)->pluck('service_category_id')->unique();
|
||||||
$leafCats = $catMap->only($usedCatIds->all());
|
$leafCats = $catMap->only($usedCatIds->all());
|
||||||
|
|
||||||
$this->info("Company: {$company->name} (id={$company->id})");
|
$this->line("── {$company->name} (id={$company->id}) ── cats:{$leafCats->count()} ops:" . count($operations));
|
||||||
$this->info("Categories to import: {$leafCats->count()}");
|
|
||||||
$this->info("Operations to import: " . count($operations));
|
|
||||||
$force = $this->option('force');
|
|
||||||
|
|
||||||
// ── 1. Upsert labor categories ────────────────────────────────────
|
// ── 1. Labor categories ───────────────────────────────────────────
|
||||||
$this->info('Importing categories...');
|
$catCodeToId = [];
|
||||||
$catCodeToName = []; // leaf code → canonical name (= code itself)
|
|
||||||
$sortBase = 100;
|
$sortBase = 100;
|
||||||
|
|
||||||
foreach ($leafCats as $leaf) {
|
foreach ($leafCats as $leaf) {
|
||||||
$parent = $catMap->get($leaf['parent_id']);
|
$parent = $catMap->get($leaf['parent_id']);
|
||||||
$nameRu = ($parent ? $parent['name'] . ' · ' : '') . $leaf['name'];
|
$nameRu = ($parent ? $parent['name'] . ' · ' : '') . $leaf['name'];
|
||||||
$code = $leaf['code']; // canonical key
|
$code = $leaf['code'];
|
||||||
|
|
||||||
$existing = LaborCategory::withoutGlobalScope(TenantScope::class)
|
$existing = LaborCategory::withoutGlobalScope(TenantScope::class)
|
||||||
->where('company_id', $company->id)
|
->where('company_id', $company->id)
|
||||||
@@ -78,75 +107,63 @@ class ImportLaborCatalogCommand extends Command
|
|||||||
if ($existing) {
|
if ($existing) {
|
||||||
if ($force) {
|
if ($force) {
|
||||||
$existing->update(['name_ru' => $nameRu]);
|
$existing->update(['name_ru' => $nameRu]);
|
||||||
$this->line(" Updated cat: {$code}");
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LaborCategory::withoutGlobalScope(TenantScope::class)->create([
|
$existing = LaborCategory::withoutGlobalScope(TenantScope::class)->create([
|
||||||
'company_id' => $company->id,
|
'company_id' => $company->id,
|
||||||
'name' => $code,
|
'name' => $code,
|
||||||
'name_ro' => $nameRu,
|
'name_ro' => $nameRu,
|
||||||
'name_ru' => $nameRu,
|
'name_ru' => $nameRu,
|
||||||
'sort_order' => $sortBase + ($leaf['sort_order'] ?? 0),
|
'sort_order' => $sortBase + ($leaf['sort_order'] ?? 0),
|
||||||
]);
|
]);
|
||||||
$this->line(" Created cat: {$code} ({$nameRu})");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$catCodeToName[$leaf['id']] = $code;
|
$catCodeToId[$leaf['id']] = $code;
|
||||||
$sortBase += 5;
|
$sortBase += 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. Upsert labors ─────────────────────────────────────────────
|
// ── 2. Labors ─────────────────────────────────────────────────────
|
||||||
$this->info('Importing operations...');
|
|
||||||
$created = 0;
|
$created = 0;
|
||||||
$updated = 0;
|
|
||||||
$skipped = 0;
|
$skipped = 0;
|
||||||
|
|
||||||
foreach ($operations as $op) {
|
foreach ($operations as $op) {
|
||||||
$catId = $op['service_category_id'];
|
$catKey = $catCodeToId[$op['service_category_id']] ?? null;
|
||||||
$catKey = $catCodeToName[$catId] ?? null;
|
if (! $catKey) { $skipped++; continue; }
|
||||||
if (! $catKey) {
|
|
||||||
|
$existing = Labor::withoutGlobalScope(TenantScope::class)
|
||||||
|
->where('company_id', $company->id)
|
||||||
|
->where('code', $op['code'])
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
if ($force) {
|
||||||
|
Labor::withoutGlobalScope(TenantScope::class)
|
||||||
|
->where('company_id', $company->id)
|
||||||
|
->where('code', $op['code'])
|
||||||
|
->update([
|
||||||
|
'name_ru' => $op['name'],
|
||||||
|
'hours' => round($op['norm_minutes'] / 60, 2),
|
||||||
|
'price' => round($op['base_price_minor'] / 100, 2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
$skipped++;
|
$skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$hours = round(($op['norm_minutes'] ?? 0) / 60, 2);
|
|
||||||
$price = round(($op['base_price_minor'] ?? 0) / 100, 2);
|
|
||||||
$nameRu = $op['name'];
|
|
||||||
$code = $op['code'];
|
|
||||||
|
|
||||||
$existing = Labor::withoutGlobalScope(TenantScope::class)
|
|
||||||
->where('company_id', $company->id)
|
|
||||||
->where('code', $code)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($existing) {
|
|
||||||
if ($force) {
|
|
||||||
$existing->update([
|
|
||||||
'name_ru' => $nameRu,
|
|
||||||
'hours' => $hours,
|
|
||||||
'price' => $price,
|
|
||||||
]);
|
|
||||||
$updated++;
|
|
||||||
} else {
|
|
||||||
$skipped++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Labor::withoutGlobalScope(TenantScope::class)->create([
|
Labor::withoutGlobalScope(TenantScope::class)->create([
|
||||||
'company_id' => $company->id,
|
'company_id' => $company->id,
|
||||||
'category' => $catKey,
|
'category' => $catKey,
|
||||||
'code' => $code,
|
'code' => $op['code'],
|
||||||
'name_ro' => $nameRu,
|
'name_ro' => $op['name'],
|
||||||
'name_ru' => $nameRu,
|
'name_ru' => $op['name'],
|
||||||
'pricing_mode' => 'hourly',
|
'pricing_mode' => 'hourly',
|
||||||
'hours' => $hours,
|
'hours' => round($op['norm_minutes'] / 60, 2),
|
||||||
'price' => $price,
|
'price' => round($op['base_price_minor'] / 100, 2),
|
||||||
'is_active' => (bool) ($op['is_active'] ?? true),
|
'is_active' => (bool) ($op['is_active'] ?? true),
|
||||||
]);
|
]);
|
||||||
$created++;
|
$created++;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$this->info("Done. Created: {$created}, Updated: {$updated}, Skipped: {$skipped}");
|
$this->line(" Created: {$created} Skipped/existing: {$skipped}");
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,9 +85,9 @@ class LaborResource extends Resource
|
|||||||
->label(__('Categorie'))
|
->label(__('Categorie'))
|
||||||
->badge()
|
->badge()
|
||||||
->sortable()
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
->formatStateUsing(function ($state) {
|
->formatStateUsing(function ($state) {
|
||||||
static $opts = null;
|
$opts = \App\Models\Tenant\LaborCategory::optionsForSelect();
|
||||||
$opts ??= \App\Models\Tenant\LaborCategory::optionsForSelect();
|
|
||||||
return $opts[$state] ?? $state;
|
return $opts[$state] ?? $state;
|
||||||
}),
|
}),
|
||||||
Tables\Columns\TextColumn::make('name_ro')
|
Tables\Columns\TextColumn::make('name_ro')
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ elif [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
|
|||||||
php artisan migrate --force --no-interaction || echo "[entrypoint] migrate failed (non-fatal)"
|
php artisan migrate --force --no-interaction || echo "[entrypoint] migrate failed (non-fatal)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Import labor catalog for all companies (idempotent — skips existing records by code).
|
||||||
|
php artisan catalog:import-labors --all --no-interaction 2>&1 | tail -5 || echo "[entrypoint] catalog import failed (non-fatal)"
|
||||||
|
|
||||||
# Run seeders if requested. Uses firstOrCreate so it's idempotent.
|
# Run seeders if requested. Uses firstOrCreate so it's idempotent.
|
||||||
if [ "${RUN_SEED:-false}" = "true" ] && [ "${RUN_FRESH_MIGRATE:-false}" != "true" ]; then
|
if [ "${RUN_SEED:-false}" = "true" ] && [ "${RUN_FRESH_MIGRATE:-false}" != "true" ]; then
|
||||||
echo "[entrypoint] Running database seed..."
|
echo "[entrypoint] Running database seed..."
|
||||||
|
|||||||
Reference in New Issue
Block a user