diff --git a/app/Models/Commons/Category.php b/app/Models/Commons/Category.php index 8a95ef3..504bbdb 100644 --- a/app/Models/Commons/Category.php +++ b/app/Models/Commons/Category.php @@ -3,44 +3,37 @@ namespace App\Models\Commons; use App\Traits\BelongsToTenant; +use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; -/** - * @mixin IdeHelperCategory - */ class Category extends Model { - use SoftDeletes, BelongsToTenant; - - protected $table = 'common_codes'; + use SoftDeletes, BelongsToTenant, ModelTrait; protected $fillable = [ - 'tenant_id', - 'code_group', - 'code', - 'name', - 'parent_id', - 'attributes', - 'description', - 'is_active', - 'sort_order' + 'tenant_id','parent_id','code_group','code','name', + 'profile_code', // capability_profile 연결 + 'is_active','sort_order','description', + 'created_by','updated_by','deleted_by' ]; protected $casts = [ - 'attributes' => 'array', - 'is_active' => 'boolean', + 'is_active' => 'boolean', + 'sort_order' => 'integer', ]; - // 관계: 상위 코드 - public function parent() - { - return $this->belongsTo(self::class, 'parent_id'); - } + // 계층 + public function parent() { return $this->belongsTo(self::class, 'parent_id'); } + public function children() { return $this->hasMany(self::class, 'parent_id'); } - // 관계: 하위 코드들 - public function children() - { - return $this->hasMany(self::class, 'parent_id'); - } + // 카테고리의 제품들 + public function products() { return $this->hasMany(\App\Models\Products\Product::class, 'category_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); } } diff --git a/app/Models/Commons/CategoryField.php b/app/Models/Commons/CategoryField.php new file mode 100644 index 0000000..baddc99 --- /dev/null +++ b/app/Models/Commons/CategoryField.php @@ -0,0 +1,36 @@ + 'boolean', + 'sort_order' => 'integer', + 'options' => 'array', + ]; + + public function category() + { + return $this->belongsTo(Category::class); + } + + // 편의 스코프 + public function scopeRequired($q) { return $q->where('is_required', 1); } +} diff --git a/app/Models/Commons/CategoryLog.php b/app/Models/Commons/CategoryLog.php new file mode 100644 index 0000000..874aae5 --- /dev/null +++ b/app/Models/Commons/CategoryLog.php @@ -0,0 +1,31 @@ + 'datetime', + 'before_json' => 'array', + 'after_json' => 'array', + ]; + + public function category() + { + return $this->belongsTo(Category::class); + } +} diff --git a/app/Models/Commons/CategoryTemplate.php b/app/Models/Commons/CategoryTemplate.php new file mode 100644 index 0000000..00d3655 --- /dev/null +++ b/app/Models/Commons/CategoryTemplate.php @@ -0,0 +1,30 @@ + 'integer', + 'template_json' => 'array', + 'applied_at' => 'datetime', + ]; + + public function category() + { + return $this->belongsTo(Category::class); + } +} diff --git a/app/Models/Products/CommonCode.php b/app/Models/Products/CommonCode.php index 35e9c86..3456b98 100644 --- a/app/Models/Products/CommonCode.php +++ b/app/Models/Products/CommonCode.php @@ -5,12 +5,13 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use App\Traits\BelongsToTenant; +use App\Traits\ModelTrait; /** * @mixin IdeHelperCommonCode */ class CommonCode extends Model { - use SoftDeletes, BelongsToTenant; + use SoftDeletes, BelongsToTenant, ModelTrait; protected $table = 'common_codes'; diff --git a/app/Models/Products/Product.php b/app/Models/Products/Product.php index ddd138a..e070613 100644 --- a/app/Models/Products/Product.php +++ b/app/Models/Products/Product.php @@ -2,35 +2,65 @@ namespace App\Models\Products; +use App\Models\Commons\Category; use App\Models\Commons\File; use App\Models\Commons\Tag; +use App\Traits\BelongsToTenant; +use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; -/** - * @mixin IdeHelperProduct - */ class Product extends Model { - use SoftDeletes; - protected $fillable = ['tenant_id','code','name','category_id','attributes','description','is_active']; + use SoftDeletes, BelongsToTenant, ModelTrait; - public function category() { - return $this->belongsTo(CommonCode::class, 'category_id'); - } - public function boms() { - return $this->hasMany(Bom::class); - } + protected $fillable = [ + 'tenant_id','code','name','category_id', + 'product_type', // 라벨/분류용 + 'attributes','description','is_active', + 'is_sellable','is_purchasable','is_producible', + 'created_by','updated_by' + ]; - // 파일 목록 (N:M, 폴리모픽) - public function files() + protected $casts = [ + '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 tags() + public function parents() { - 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); } } diff --git a/app/Models/Products/ProductComponent.php b/app/Models/Products/ProductComponent.php new file mode 100644 index 0000000..e2f92e9 --- /dev/null +++ b/app/Models/Products/ProductComponent.php @@ -0,0 +1,30 @@ + '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'); } +} diff --git a/database/migrations/2025_08_22_001500_finalize_categories_products_profile_and_bom.php b/database/migrations/2025_08_22_001500_finalize_categories_products_profile_and_bom.php new file mode 100644 index 0000000..85035a5 --- /dev/null +++ b/database/migrations/2025_08_22_001500_finalize_categories_products_profile_and_bom.php @@ -0,0 +1,274 @@ +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) {} + } +}; diff --git a/database/seeders/CapabilityProfileSeeder.php b/database/seeders/CapabilityProfileSeeder.php new file mode 100644 index 0000000..096e97c --- /dev/null +++ b/database/seeders/CapabilityProfileSeeder.php @@ -0,0 +1,75 @@ + '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, + ] + ); + } + } +} diff --git a/database/seeders/CategorySeeder.php b/database/seeders/CategorySeeder.php new file mode 100644 index 0000000..466e3cf --- /dev/null +++ b/database/seeders/CategorySeeder.php @@ -0,0 +1,24 @@ + 1, + 'parent_id' => null, + 'code_group' => 'product', + 'code' => 'DEFAULT', + 'name' => '기본 카테고리', + 'profile_code'=> 'FINISHED_GOOD', // capability_profile 참조 + 'is_active' => 1, + 'sort_order' => 1, + 'description' => '시스템 기본 카테고리', + ]); + } +}