fix : Category, Product 관련 테이블 정리 (Models, DB, seeder)

This commit is contained in:
2025-08-22 15:57:14 +09:00
parent 5f62179473
commit 189bdbfd80
10 changed files with 569 additions and 45 deletions

View File

@@ -3,44 +3,37 @@
namespace App\Models\Commons; namespace App\Models\Commons;
use App\Traits\BelongsToTenant; use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @mixin IdeHelperCategory
*/
class Category extends Model class Category extends Model
{ {
use SoftDeletes, BelongsToTenant; use SoftDeletes, BelongsToTenant, ModelTrait;
protected $table = 'common_codes';
protected $fillable = [ protected $fillable = [
'tenant_id', 'tenant_id','parent_id','code_group','code','name',
'code_group', 'profile_code', // capability_profile 연결
'code', 'is_active','sort_order','description',
'name', 'created_by','updated_by','deleted_by'
'parent_id',
'attributes',
'description',
'is_active',
'sort_order'
]; ];
protected $casts = [ protected $casts = [
'attributes' => 'array', 'is_active' => 'boolean',
'is_active' => 'boolean', 'sort_order' => 'integer',
]; ];
// 관계: 상위 코드 // 계층
public function parent() public function parent() { return $this->belongsTo(self::class, 'parent_id'); }
{ public function children() { return $this->hasMany(self::class, 'parent_id'); }
return $this->belongsTo(self::class, 'parent_id');
}
// 관계: 하위 코드 // 카테고리의 제품
public function children() public function products() { return $this->hasMany(\App\Models\Products\Product::class, 'category_id'); }
{
return $this->hasMany(self::class, 'parent_id'); // 태그(폴리모픽) — 이미 taggables 존재
} public function tags() { return $this->morphToMany(\App\Models\Commons\Tag::class, 'taggable'); }
// 스코프
public function scopeGroup($q, string $group) { return $q->where('code_group', $group); }
public function scopeCode($q, string $code) { return $q->where('code', $code); }
} }

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models\Commons;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class CategoryField extends Model
{
use SoftDeletes, BelongsToTenant, ModelTrait;
protected $table = 'category_fields';
protected $fillable = [
'tenant_id','category_id',
'field_key','field_name','field_type',
'is_required','sort_order','default_value','options','description',
'created_by','updated_by','deleted_by',
];
protected $casts = [
'is_required' => 'boolean',
'sort_order' => 'integer',
'options' => 'array',
];
public function category()
{
return $this->belongsTo(Category::class);
}
// 편의 스코프
public function scopeRequired($q) { return $q->where('is_required', 1); }
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models\Commons;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
class CategoryLog extends Model
{
use BelongsToTenant, ModelTrait;
protected $table = 'category_logs';
public $timestamps = false; // changed_at 컬럼 단일 사용
protected $fillable = [
'category_id','tenant_id','action','changed_by','changed_at',
'before_json','after_json','remarks',
];
protected $casts = [
'changed_at' => 'datetime',
'before_json' => 'array',
'after_json' => 'array',
];
public function category()
{
return $this->belongsTo(Category::class);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Models\Commons;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
class CategoryTemplate extends Model
{
use BelongsToTenant, ModelTrait;
protected $table = 'category_templates';
protected $fillable = [
'tenant_id','category_id','version_no','template_json','applied_at',
'created_by','updated_by','deleted_by','remarks',
];
protected $casts = [
'version_no' => 'integer',
'template_json' => 'array',
'applied_at' => 'datetime',
];
public function category()
{
return $this->belongsTo(Category::class);
}
}

View File

@@ -5,12 +5,13 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\BelongsToTenant; use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
/** /**
* @mixin IdeHelperCommonCode * @mixin IdeHelperCommonCode
*/ */
class CommonCode extends Model class CommonCode extends Model
{ {
use SoftDeletes, BelongsToTenant; use SoftDeletes, BelongsToTenant, ModelTrait;
protected $table = 'common_codes'; protected $table = 'common_codes';

View File

@@ -2,35 +2,65 @@
namespace App\Models\Products; namespace App\Models\Products;
use App\Models\Commons\Category;
use App\Models\Commons\File; use App\Models\Commons\File;
use App\Models\Commons\Tag; use App\Models\Commons\Tag;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @mixin IdeHelperProduct
*/
class Product extends Model class Product extends Model
{ {
use SoftDeletes; use SoftDeletes, BelongsToTenant, ModelTrait;
protected $fillable = ['tenant_id','code','name','category_id','attributes','description','is_active'];
public function category() { protected $fillable = [
return $this->belongsTo(CommonCode::class, 'category_id'); 'tenant_id','code','name','category_id',
} 'product_type', // 라벨/분류용
public function boms() { 'attributes','description','is_active',
return $this->hasMany(Bom::class); 'is_sellable','is_purchasable','is_producible',
} 'created_by','updated_by'
];
// 파일 목록 (N:M, 폴리모픽) protected $casts = [
public function files() 'attributes' => 'array',
'is_active' => 'boolean',
'is_sellable' => 'boolean',
'is_purchasable' => 'boolean',
'is_producible' => 'boolean',
];
// 분류
public function category() { return $this->belongsTo(Category::class, 'category_id'); }
// BOM (자기참조) — 라인 모델 경유
public function componentLines() { return $this->hasMany(ProductComponent::class, 'parent_product_id'); } // 라인들
public function parentLines() { return $this->hasMany(ProductComponent::class, 'child_product_id'); } // 나를 쓰는 상위 라인들
// 편의: 직접 children/parents 제품에 접근
public function children()
{ {
return $this->morphMany(File::class, 'fileable'); return $this->belongsToMany(
self::class, 'product_components', 'parent_product_id', 'child_product_id'
)->withPivot(['quantity','sort_order','is_default'])
->withTimestamps();
} }
// 태그 목록 (N:M, 폴리모픽) public function parents()
public function tags()
{ {
return $this->morphToMany(Tag::class, 'taggable'); return $this->belongsToMany(
self::class, 'product_components', 'child_product_id', 'parent_product_id'
)->withPivot(['quantity','sort_order','is_default'])
->withTimestamps();
} }
// 파일 / 태그 (폴리모픽)
public function files() { return $this->morphMany(File::class, 'fileable'); }
public function tags() { return $this->morphToMany(Tag::class, 'taggable'); }
// 스코프
public function scopeType($q, string $type) { return $q->where('product_type', $type); }
public function scopeSellable($q) { return $q->where('is_sellable', 1); }
public function scopePurchasable($q) { return $q->where('is_purchasable', 1); }
public function scopeProducible($q) { return $q->where('is_producible', 1); }
} }

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Models\Products;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class ProductComponent extends Model
{
use SoftDeletes, BelongsToTenant, ModelTrait;
protected $table = 'product_components';
protected $fillable = [
'tenant_id','parent_product_id','child_product_id',
'quantity','sort_order','is_default',
'created_by','updated_by'
];
protected $casts = [
'quantity' => 'decimal:4',
'sort_order' => 'integer',
'is_default' => 'boolean',
];
public function parent() { return $this->belongsTo(Product::class, 'parent_product_id'); }
public function child() { return $this->belongsTo(Product::class, 'child_product_id'); }
}

View File

@@ -0,0 +1,274 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/** 유니크 인덱스가 정확히 $columns 조합이면 드롭 */
private function dropUniqueIfColumns(string $table, array $columns): void
{
$indexes = collect(DB::select("SHOW INDEX FROM `{$table}`"))->groupBy('Key_name');
foreach ($indexes as $name => $rows) {
$nonUnique = (int)($rows->first()->Non_unique ?? 1);
if ($nonUnique !== 0) continue;
$cols = collect($rows)->sortBy('Seq_in_index')->pluck('Column_name')->values()->all();
if ($cols === $columns) {
DB::statement("ALTER TABLE `{$table}` DROP INDEX `{$name}`");
}
}
}
/** products.category_id 의 기존 FK 이름을 찾아 드롭 */
private function dropProductsCategoryFk(): void
{
$row = DB::selectOne("
SELECT CONSTRAINT_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'products'
AND COLUMN_NAME = 'category_id'
AND REFERENCED_TABLE_NAME IS NOT NULL
LIMIT 1
");
if ($row && isset($row->CONSTRAINT_NAME)) {
$name = $row->CONSTRAINT_NAME;
DB::statement("ALTER TABLE `products` DROP FOREIGN KEY `{$name}`");
}
}
public function up(): void
{
/**
* A) categories 정리
* - is_active: TINYINT(1) 통일 (Y/N → 1/0 안전 변환)
* - 유니크키: (tenant_id, code_group, code) 표준화
* - profile_code 컬럼 추가(+ 인덱스)
*/
// A-1. 값 변환(Y/N → 1/0) 시도 (이미 숫자여도 무해)
try { DB::statement("UPDATE categories SET is_active = 1 WHERE is_active = 'Y'"); } catch (\Throwable $e) {}
try { DB::statement("UPDATE categories SET is_active = 0 WHERE is_active = 'N'"); } catch (\Throwable $e) {}
// A-2. 타입 변경
DB::statement("ALTER TABLE categories
MODIFY COLUMN is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '활성여부(1=활성,0=비활성)'");
// A-3. 유니크 (tenant_id, code) → (tenant_id, code_group, code)
$this->dropUniqueIfColumns('categories', ['tenant_id', 'code']);
$idx = collect(DB::select("SHOW INDEX FROM `categories`"))->groupBy('Key_name');
$hasTarget = false;
foreach ($idx as $name => $rows) {
$nonUnique = (int)($rows->first()->Non_unique ?? 1);
$cols = collect($rows)->sortBy('Seq_in_index')->pluck('Column_name')->values()->all();
if ($nonUnique === 0 && $cols === ['tenant_id', 'code_group', 'code']) { $hasTarget = true; break; }
}
if (!$hasTarget) {
DB::statement("ALTER TABLE `categories`
ADD UNIQUE KEY `uq_tenant_codegroup_code` (`tenant_id`,`code_group`,`code`)");
}
// A-4. profile_code 추가 (common_codes.code, code_group='capability_profile')
Schema::table('categories', function (Blueprint $table) {
if (!Schema::hasColumn('categories', 'profile_code')) {
$table->string('profile_code', 30)
->nullable()
->after('code_group')
->comment("역할 프로필 코드 (common_codes.code, code_group='capability_profile')");
}
});
// 인덱스 (tenant_id, profile_code)
$hasIdx = collect(DB::select("SHOW INDEX FROM `categories`"))
->contains(fn($r) => $r->Key_name === 'idx_categories_tenant_profilecode');
if (!$hasIdx) {
DB::statement("CREATE INDEX `idx_categories_tenant_profilecode` ON `categories` (`tenant_id`, `profile_code`)");
}
/**
* B) products 정리
* - category_id FK: common_codes → categories 로 전환
* - 역할/라벨 컬럼 추가: product_type + is_sellable/is_purchasable/is_producible
*/
$this->dropProductsCategoryFk();
Schema::table('products', function (Blueprint $table) {
$table->foreign('category_id', 'products_category_id_foreign')
->references('id')->on('categories')
->onUpdate('cascade')->onDelete('restrict');
if (!Schema::hasColumn('products', 'product_type')) {
$table->string('product_type', 30)
->default('PRODUCT')
->comment("제품유형: PRODUCT/PART/SUBASSEMBLY 등 (common_codes.code_group='product_type')")
->after('category_id');
}
if (!Schema::hasColumn('products', 'is_sellable')) {
$table->tinyInteger('is_sellable')->default(1)->comment('판매가능(1/0)')->after('description');
}
if (!Schema::hasColumn('products', 'is_purchasable')) {
$table->tinyInteger('is_purchasable')->default(0)->comment('구매가능(1/0)')->after('is_sellable');
}
if (!Schema::hasColumn('products', 'is_producible')) {
$table->tinyInteger('is_producible')->default(1)->comment('제조가능(1/0)')->after('is_purchasable');
}
});
/**
* C) BOM 자기참조 테이블 신설 (product_components)
* - parts/boms/bom_items 레거시 제거
*/
if (!Schema::hasTable('product_components')) {
Schema::create('product_components', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->unsignedBigInteger('parent_product_id')->comment('상위 제품 ID');
$table->unsignedBigInteger('child_product_id')->comment('하위 제품/부품 ID');
$table->decimal('quantity', 18, 4)->default(1.0000);
$table->integer('sort_order')->default(0);
$table->tinyInteger('is_default')->default(0)->comment('기본 BOM 여부(1/0)');
$table->unsignedBigInteger('created_by')->nullable();
$table->unsignedBigInteger('updated_by')->nullable();
$table->softDeletes();
$table->timestamps();
$table->index(['tenant_id', 'parent_product_id']);
$table->index(['tenant_id', 'child_product_id']);
$table->unique(['tenant_id', 'parent_product_id', 'child_product_id', 'sort_order'], 'uq_component_row');
$table->foreign('parent_product_id')->references('id')->on('products')->onDelete('cascade');
$table->foreign('child_product_id')->references('id')->on('products')->onDelete('restrict');
});
}
foreach (['bom_items', 'boms', 'parts'] as $tbl) {
if (Schema::hasTable($tbl)) {
Schema::drop($tbl);
}
}
}
public function down(): void
{
/**
* C) product_components 제거 + 레거시 복구(최소 스키마)
*/
if (Schema::hasTable('product_components')) {
Schema::drop('product_components');
}
if (!Schema::hasTable('parts')) {
Schema::create('parts', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('tenant_id');
$table->string('code', 30);
$table->string('name', 100);
$table->unsignedBigInteger('category_id');
$table->unsignedBigInteger('part_type_id')->nullable();
$table->string('unit', 20)->nullable();
$table->json('attributes')->nullable();
$table->string('description', 255)->nullable();
$table->tinyInteger('is_active')->default(1);
$table->unsignedBigInteger('created_by');
$table->unsignedBigInteger('updated_by')->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['tenant_id','code']);
});
}
if (!Schema::hasTable('boms')) {
Schema::create('boms', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('tenant_id');
$table->unsignedBigInteger('product_id');
$table->string('code', 30);
$table->string('name', 100);
$table->unsignedBigInteger('category_id');
$table->json('attributes')->nullable();
$table->string('description', 255)->nullable();
$table->tinyInteger('is_default')->default(0);
$table->tinyInteger('is_active')->default(1);
$table->unsignedBigInteger('image_file_id')->nullable();
$table->unsignedBigInteger('created_by');
$table->unsignedBigInteger('updated_by')->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['tenant_id','product_id','code']);
});
}
if (!Schema::hasTable('bom_items')) {
Schema::create('bom_items', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('tenant_id');
$table->unsignedBigInteger('bom_id');
$table->unsignedBigInteger('parent_id')->nullable();
$table->string('item_type', 20);
$table->unsignedBigInteger('ref_id');
$table->decimal('quantity', 18, 4)->default(1.0000);
$table->json('attributes')->nullable();
$table->integer('sort_order')->default(0);
$table->unsignedBigInteger('created_by');
$table->unsignedBigInteger('updated_by')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
/**
* B) products 롤백
* - 추가 컬럼 제거
* - FK: categories → common_codes
*/
Schema::table('products', function (Blueprint $table) {
if (Schema::hasColumn('products', 'is_sellable')) $table->dropColumn('is_sellable');
if (Schema::hasColumn('products', 'is_purchasable')) $table->dropColumn('is_purchasable');
if (Schema::hasColumn('products', 'is_producible')) $table->dropColumn('is_producible');
if (Schema::hasColumn('products', 'product_type')) $table->dropColumn('product_type');
});
$this->dropProductsCategoryFk();
Schema::table('products', function (Blueprint $table) {
$table->foreign('category_id', 'products_category_id_foreign')
->references('id')->on('common_codes')
->onUpdate('cascade')->onDelete('restrict');
});
/**
* A) categories 롤백
* - profile_code 인덱스/컬럼 제거
* - 유니크: (tenant_id, code_group, code) → (tenant_id, code)
* - is_active: CHAR('Y'/'N') 복구 + 값 재변환
*/
$hasIdx = collect(DB::select("SHOW INDEX FROM `categories`"))
->contains(fn($r) => $r->Key_name === 'idx_categories_tenant_profilecode');
if ($hasIdx) {
DB::statement("DROP INDEX `idx_categories_tenant_profilecode` ON `categories`");
}
Schema::table('categories', function (Blueprint $table) {
if (Schema::hasColumn('categories', 'profile_code')) {
$table->dropColumn('profile_code');
}
});
$idx = collect(DB::select("SHOW INDEX FROM `categories`"))->groupBy('Key_name');
if ($idx->has('uq_tenant_codegroup_code')) {
DB::statement("ALTER TABLE `categories` DROP INDEX `uq_tenant_codegroup_code`");
}
// (tenant_id, code) 유니크 복구(없을 때만)
$idx = collect(DB::select("SHOW INDEX FROM `categories`"))->groupBy('Key_name');
$hasOld = false;
foreach ($idx as $name => $rows) {
$nonUnique = (int)($rows->first()->Non_unique ?? 1);
$cols = collect($rows)->sortBy('Seq_in_index')->pluck('Column_name')->values()->all();
if ($nonUnique === 0 && $cols === ['tenant_id', 'code']) { $hasOld = true; break; }
}
if (!$hasOld) {
DB::statement("ALTER TABLE `categories`
ADD UNIQUE KEY `categories_tenant_id_code_unique` (`tenant_id`,`code`)");
}
// 타입 복구 + 값 재변환
DB::statement("ALTER TABLE categories
MODIFY COLUMN is_active CHAR(1) NOT NULL DEFAULT 'Y' COMMENT '활성여부(Y/N)'");
try { DB::statement("UPDATE categories SET is_active = 'Y' WHERE is_active = 1"); } catch (\Throwable $e) {}
try { DB::statement("UPDATE categories SET is_active = 'N' WHERE is_active = 0"); } catch (\Throwable $e) {}
}
};

View File

@@ -0,0 +1,75 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class CapabilityProfileSeeder extends Seeder
{
public function run(): void
{
$tenantId = null; // null = 공통, 필요시 테넌트별로도 생성 가능
$group = 'capability_profile';
$profiles = [
[
'code' => 'FINISHED_GOOD',
'name' => '완제품',
'attributes' => [
'is_sellable' => 1,
'is_purchasable' => 0,
'is_producible' => 1,
'is_stock_managed' => 1,
],
],
[
'code' => 'SUB_ASSEMBLY',
'name' => '서브어셈블리',
'attributes' => [
'is_sellable' => 0,
'is_purchasable' => 0,
'is_producible' => 1,
'is_stock_managed' => 1,
],
],
[
'code' => 'PURCHASED_PART',
'name' => '구매부품',
'attributes' => [
'is_sellable' => 0,
'is_purchasable' => 1,
'is_producible' => 0,
'is_stock_managed' => 1,
],
],
[
'code' => 'PHANTOM',
'name' => '팬텀(가상)',
'attributes' => [
'is_sellable' => 0,
'is_purchasable' => 0,
'is_producible' => 1,
'is_stock_managed' => 0,
],
],
];
foreach ($profiles as $p) {
DB::table('common_codes')->updateOrInsert(
[
'tenant_id' => $tenantId,
'code_group' => $group,
'code' => $p['code'],
],
[
'name' => $p['name'],
'attributes' => json_encode($p['attributes']),
'description' => '기본 프로필',
'is_active' => 1,
'sort_order' => 0,
]
);
}
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\Commons\Category;
class CategorySeeder extends Seeder
{
public function run(): void
{
Category::create([
'tenant_id' => 1,
'parent_id' => null,
'code_group' => 'product',
'code' => 'DEFAULT',
'name' => '기본 카테고리',
'profile_code'=> 'FINISHED_GOOD', // capability_profile 참조
'is_active' => 1,
'sort_order' => 1,
'description' => '시스템 기본 카테고리',
]);
}
}