From eeb55d1c282a0ab60bda0eb0f4760aa93b04ecda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Mon, 26 Jan 2026 22:12:17 +0900 Subject: [PATCH 01/57] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B4=80?= =?UTF-8?q?=EA=B3=84=20=EB=AC=B8=EC=84=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - document_templates 테이블 마이그레이션 추가 - LOGICAL_RELATIONSHIPS.md 업데이트 Co-Authored-By: Claude Opus 4.5 --- LOGICAL_RELATIONSHIPS.md | 2 +- ...200000_create_document_templates_table.php | 117 ++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_01_26_200000_create_document_templates_table.php diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index aa05c5d..575494d 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-01-26 16:11:41 +> **자동 생성**: 2026-01-26 22:07:37 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 diff --git a/database/migrations/2026_01_26_200000_create_document_templates_table.php b/database/migrations/2026_01_26_200000_create_document_templates_table.php new file mode 100644 index 0000000..d239e03 --- /dev/null +++ b/database/migrations/2026_01_26_200000_create_document_templates_table.php @@ -0,0 +1,117 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('name', 100)->comment('양식명'); + $table->string('category', 50)->nullable()->comment('분류 (품질, 생산 등)'); + $table->string('title', 200)->nullable()->comment('문서 제목'); + $table->string('company_name', 100)->nullable()->comment('회사명'); + $table->string('company_address', 255)->nullable()->comment('회사 주소'); + $table->string('company_contact', 100)->nullable()->comment('회사 연락처'); + $table->string('footer_remark_label', 50)->default('부적합 내용')->comment('비고 라벨'); + $table->string('footer_judgement_label', 50)->default('종합판정')->comment('판정 라벨'); + $table->json('footer_judgement_options')->nullable()->comment('판정 옵션 (적합/부적합 등)'); + $table->boolean('is_active')->default(true)->comment('활성 여부'); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['tenant_id', 'category']); + $table->index(['tenant_id', 'is_active']); + }); + + // 결재라인 + Schema::create('document_template_approval_lines', function (Blueprint $table) { + $table->id(); + $table->foreignId('template_id')->constrained('document_templates')->cascadeOnDelete(); + $table->string('name', 50)->comment('결재 단계명 (작성, 검토, 승인)'); + $table->string('dept', 50)->nullable()->comment('부서'); + $table->string('role', 50)->nullable()->comment('직책/담당자'); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->timestamps(); + + $table->index(['template_id', 'sort_order']); + }); + + // 기본 필드 (품명, LOT NO 등) + Schema::create('document_template_basic_fields', function (Blueprint $table) { + $table->id(); + $table->foreignId('template_id')->constrained('document_templates')->cascadeOnDelete(); + $table->string('label', 50)->comment('필드 라벨'); + $table->string('field_type', 20)->default('text')->comment('필드 타입 (text, date 등)'); + $table->string('default_value', 255)->nullable()->comment('기본값'); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->timestamps(); + + $table->index(['template_id', 'sort_order']); + }); + + // 검사 기준서 섹션 + Schema::create('document_template_sections', function (Blueprint $table) { + $table->id(); + $table->foreignId('template_id')->constrained('document_templates')->cascadeOnDelete(); + $table->string('title', 100)->comment('섹션 제목 (가이드레일, 연기차단재 등)'); + $table->string('image_path', 255)->nullable()->comment('도해 이미지 경로'); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->timestamps(); + + $table->index(['template_id', 'sort_order']); + }); + + // 검사 기준서 섹션 항목 + Schema::create('document_template_section_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('section_id')->constrained('document_template_sections')->cascadeOnDelete(); + $table->string('category', 50)->nullable()->comment('구분 (겉모양, 치수 등)'); + $table->string('item', 100)->comment('검사항목'); + $table->string('standard', 255)->nullable()->comment('검사기준'); + $table->string('method', 50)->nullable()->comment('검사방법'); + $table->string('frequency', 50)->nullable()->comment('검사주기'); + $table->string('regulation', 100)->nullable()->comment('관련규정'); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->timestamps(); + + $table->index(['section_id', 'sort_order']); + }); + + // 검사 데이터 테이블 컬럼 설정 + Schema::create('document_template_columns', function (Blueprint $table) { + $table->id(); + $table->foreignId('template_id')->constrained('document_templates')->cascadeOnDelete(); + $table->string('label', 50)->comment('컬럼명'); + $table->string('width', 20)->default('100px')->comment('컬럼 너비'); + $table->string('column_type', 20)->default('text')->comment('타입 (text, check, measurement, select, complex)'); + $table->string('group_name', 50)->nullable()->comment('그룹명 (상단 헤더 병합용)'); + $table->json('sub_labels')->nullable()->comment('서브라벨 배열'); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->timestamps(); + + $table->index(['template_id', 'sort_order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('document_template_columns'); + Schema::dropIfExists('document_template_section_items'); + Schema::dropIfExists('document_template_sections'); + Schema::dropIfExists('document_template_basic_fields'); + Schema::dropIfExists('document_template_approval_lines'); + Schema::dropIfExists('document_templates'); + } +}; \ No newline at end of file From 3ff3c65ade5842d12760b47b5e4ee75a23744570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 27 Jan 2026 12:47:38 +0900 Subject: [PATCH 02/57] =?UTF-8?q?feat:=20=ED=92=88=EB=AA=A9=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=9C=20BOM=20=EC=9C=A0=EB=AC=B4=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EB=B0=8F=20=EC=BD=94=EB=93=9C+=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=20=ED=98=95=EC=8B=9D=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - has_bom 파라미터 추가 (1: BOM 있는 품목만, 0: BOM 없는 품목만) - JSON_LENGTH 활용한 BOM 유무 필터링 구현 - name 필드를 "코드 이름" 형식으로 반환 (일시적 변경) - FULLTEXT 인덱스 활용 검색 개선 (BOOLEAN MODE) Co-Authored-By: Claude Opus 4.5 --- .../Controllers/Api/V1/ItemsController.php | 1 + app/Services/ItemService.php | 29 ++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/Api/V1/ItemsController.php b/app/Http/Controllers/Api/V1/ItemsController.php index b35cfd8..f9d82fc 100644 --- a/app/Http/Controllers/Api/V1/ItemsController.php +++ b/app/Http/Controllers/Api/V1/ItemsController.php @@ -30,6 +30,7 @@ public function index(Request $request) 'item_category' => $request->input('item_category'), 'group_id' => $request->input('group_id'), 'active' => $request->input('is_active') ?? $request->input('active'), + 'has_bom' => $request->input('has_bom'), ]; return $this->service->index($params); diff --git a/app/Services/ItemService.php b/app/Services/ItemService.php index 4c6a64a..ff781ea 100644 --- a/app/Services/ItemService.php +++ b/app/Services/ItemService.php @@ -355,6 +355,7 @@ public function index(array $params): LengthAwarePaginator $itemCategory = $params['item_category'] ?? null; $groupId = $params['group_id'] ?? null; $active = $params['active'] ?? null; + $hasBom = $params['has_bom'] ?? null; // item_type 또는 group_id 없으면 group_id = 1 기본값 적용 if (! $itemType && ! $groupId) { @@ -385,11 +386,13 @@ public function index(array $params): LengthAwarePaginator } } - // 검색어 + // 검색어 (FULLTEXT 인덱스 활용) if ($q !== '') { - $query->where(function ($w) use ($q) { - $w->where('name', 'like', "%{$q}%") - ->orWhere('code', 'like', "%{$q}%") + // FULLTEXT 검색 (name, code) + LIKE 검색 (description) + // 한글 검색을 위해 BOOLEAN MODE 사용, 와일드카드(*) 추가 + $searchTerm = '+'.str_replace(' ', '* +', $q).'*'; + $query->where(function ($w) use ($q, $searchTerm) { + $w->whereRaw('MATCH(name, code) AGAINST(? IN BOOLEAN MODE)', [$searchTerm]) ->orWhere('description', 'like', "%{$q}%"); }); } @@ -409,6 +412,21 @@ public function index(array $params): LengthAwarePaginator $query->where('is_active', (bool) $active); } + // BOM 유무 필터 (has_bom=1: BOM 있는 품목만, has_bom=0: BOM 없는 품목만) + if ($hasBom !== null && $hasBom !== '') { + if (filter_var($hasBom, FILTER_VALIDATE_BOOLEAN)) { + // BOM이 있는 품목만 (bom이 null이 아니고 빈 배열이 아님) + $query->whereNotNull('bom') + ->whereRaw('JSON_LENGTH(bom) > 0'); + } else { + // BOM이 없는 품목만 + $query->where(function ($q) { + $q->whereNull('bom') + ->orWhereRaw('JSON_LENGTH(bom) = 0'); + }); + } + } + $paginator = $query->orderBy('id', 'desc')->paginate($size); // 날짜 형식 변환, files 그룹화, options 펼침, code → item_code @@ -434,6 +452,9 @@ public function index(array $params): LengthAwarePaginator // has_bom 계산 필드 추가 (BOM이 있는 품목 필터링에 사용) $arr['has_bom'] = ! empty($arr['bom']) && is_array($arr['bom']) && count($arr['bom']) > 0; + // name 필드를 "코드 이름" 형식으로 변경 (일시적) + $arr['name'] = trim(($arr['item_code'] ?? '').' '.($arr['name'] ?? '')); + return $arr; }) ); From 22f7e9d94affa4bdbe4bfa68dcaed8cfe637b5f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 27 Jan 2026 13:55:12 +0900 Subject: [PATCH 03/57] =?UTF-8?q?fix:=20FULLTEXT=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=EC=9D=84=20LIKE=20=EA=B2=80=EC=83=89=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=A1=A4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 개발 서버에 FULLTEXT 인덱스 미설치로 500 에러 발생 - 기존 LIKE 검색 방식으로 복원 Co-Authored-By: Claude Opus 4.5 --- app/Services/ItemService.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/app/Services/ItemService.php b/app/Services/ItemService.php index ff781ea..1f56075 100644 --- a/app/Services/ItemService.php +++ b/app/Services/ItemService.php @@ -386,13 +386,11 @@ public function index(array $params): LengthAwarePaginator } } - // 검색어 (FULLTEXT 인덱스 활용) + // 검색어 if ($q !== '') { - // FULLTEXT 검색 (name, code) + LIKE 검색 (description) - // 한글 검색을 위해 BOOLEAN MODE 사용, 와일드카드(*) 추가 - $searchTerm = '+'.str_replace(' ', '* +', $q).'*'; - $query->where(function ($w) use ($q, $searchTerm) { - $w->whereRaw('MATCH(name, code) AGAINST(? IN BOOLEAN MODE)', [$searchTerm]) + $query->where(function ($w) use ($q) { + $w->where('name', 'like', "%{$q}%") + ->orWhere('code', 'like', "%{$q}%") ->orWhere('description', 'like', "%{$q}%"); }); } From 4c22b74b273a5e4fe06a413d39ea2bad826e1990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Tue, 27 Jan 2026 14:47:39 +0900 Subject: [PATCH 04/57] =?UTF-8?q?feat:=20=EC=B6=9C=ED=87=B4=EA=B7=BC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=97=90=20=EC=9E=90=EB=8F=99=20=EC=B6=9C?= =?UTF-8?q?=ED=87=B4=EA=B7=BC=20=EC=82=AC=EC=9A=A9=20=EC=97=AC=EB=B6=80=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - attendance_settings 테이블에 use_auto 컬럼 추가 - AttendanceSetting 모델에 use_auto 필드 추가 (fillable, casts, attributes) - UpdateAttendanceSettingRequest에 use_auto 유효성 검사 추가 Co-Authored-By: Claude Opus 4.5 --- .../UpdateAttendanceSettingRequest.php | 1 + app/Models/Tenants/AttendanceSetting.php | 4 +++ ...10_add_use_auto_to_attendance_settings.php | 28 +++++++++++++++++++ 3 files changed, 33 insertions(+) create mode 100644 database/migrations/2026_01_27_144110_add_use_auto_to_attendance_settings.php diff --git a/app/Http/Requests/V1/WorkSetting/UpdateAttendanceSettingRequest.php b/app/Http/Requests/V1/WorkSetting/UpdateAttendanceSettingRequest.php index 25e323e..b0705e7 100644 --- a/app/Http/Requests/V1/WorkSetting/UpdateAttendanceSettingRequest.php +++ b/app/Http/Requests/V1/WorkSetting/UpdateAttendanceSettingRequest.php @@ -15,6 +15,7 @@ public function rules(): array { return [ 'use_gps' => ['sometimes', 'boolean'], + 'use_auto' => ['sometimes', 'boolean'], 'allowed_radius' => ['sometimes', 'integer', 'min:10', 'max:10000'], 'hq_address' => ['nullable', 'string', 'max:255'], 'hq_latitude' => ['nullable', 'numeric', 'between:-90,90'], diff --git a/app/Models/Tenants/AttendanceSetting.php b/app/Models/Tenants/AttendanceSetting.php index c7f3eda..2c7282a 100644 --- a/app/Models/Tenants/AttendanceSetting.php +++ b/app/Models/Tenants/AttendanceSetting.php @@ -11,6 +11,7 @@ * @property int $id * @property int $tenant_id * @property bool $use_gps + * @property bool $use_auto * @property int $allowed_radius * @property string|null $hq_address * @property float|null $hq_latitude @@ -25,6 +26,7 @@ class AttendanceSetting extends Model protected $fillable = [ 'tenant_id', 'use_gps', + 'use_auto', 'allowed_radius', 'hq_address', 'hq_latitude', @@ -33,6 +35,7 @@ class AttendanceSetting extends Model protected $casts = [ 'use_gps' => 'boolean', + 'use_auto' => 'boolean', 'allowed_radius' => 'integer', 'hq_latitude' => 'decimal:8', 'hq_longitude' => 'decimal:8', @@ -40,6 +43,7 @@ class AttendanceSetting extends Model protected $attributes = [ 'use_gps' => false, + 'use_auto' => false, 'allowed_radius' => 100, ]; diff --git a/database/migrations/2026_01_27_144110_add_use_auto_to_attendance_settings.php b/database/migrations/2026_01_27_144110_add_use_auto_to_attendance_settings.php new file mode 100644 index 0000000..878eff7 --- /dev/null +++ b/database/migrations/2026_01_27_144110_add_use_auto_to_attendance_settings.php @@ -0,0 +1,28 @@ +boolean('use_auto')->default(false)->after('use_gps')->comment('자동 출퇴근 사용 여부'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('attendance_settings', function (Blueprint $table) { + $table->dropColumn('use_auto'); + }); + } +}; From 8a1e78ec729292fff6dc4e8286bc591dec6edd02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 27 Jan 2026 15:17:30 +0900 Subject: [PATCH 05/57] =?UTF-8?q?feat:=20=EA=B2=AC=EC=A0=81=20V2=20?= =?UTF-8?q?=EB=8F=99=EC=A0=81=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CategoryService: tree 메서드에 code_group 필터 지원 추가 - FormulaEvaluatorService: 하드코딩된 process_type을 동적 카테고리로 변경 - groupItemsByProcess(): item_category 필드 기반 그룹화 - getItemCategoryTree(): DB에서 카테고리 트리 조회 - buildCategoryMapping(): BENDING 하위 카테고리 처리 - addProcessGroupToItems(): category_code 필드 추가 (레거시 호환 유지) - QuoteItemCategorySeeder: 품목 카테고리 초기 데이터 시더 추가 - BODY, BENDING(하위 3개), MOTOR_CTRL, ACCESSORY Co-Authored-By: Claude --- app/Services/CategoryService.php | 11 +- .../Quote/FormulaEvaluatorService.php | 154 ++++++++++++++---- database/seeders/QuoteItemCategorySeeder.php | 107 ++++++++++++ 3 files changed, 239 insertions(+), 33 deletions(-) create mode 100644 database/seeders/QuoteItemCategorySeeder.php diff --git a/app/Services/CategoryService.php b/app/Services/CategoryService.php index 46d045c..e256b6f 100644 --- a/app/Services/CategoryService.php +++ b/app/Services/CategoryService.php @@ -197,15 +197,22 @@ public function tree(array $params) { $tenantId = $this->tenantId(); $onlyActive = (bool) ($params['only_active'] ?? false); + $codeGroup = $params['code_group'] ?? null; $q = Category::where('tenant_id', $tenantId) ->when($onlyActive, fn ($qq) => $qq->where('is_active', 1)) + ->when($codeGroup, fn ($qq) => $qq->where('code_group', $codeGroup)) ->orderBy('parent_id')->orderBy('sort_order')->orderBy('id') - ->get(['id', 'parent_id', 'code', 'name', 'is_active', 'sort_order']); + ->get(['id', 'parent_id', 'code', 'code_group', 'name', 'is_active', 'sort_order']); + + // 최상위 카테고리 ID 수집 (해당 code_group의 parent_id가 null이거나, 다른 code_group의 카테고리를 가리키는 경우) + $categoryIds = $q->pluck('id')->toArray(); + $rootIds = $q->filter(fn ($c) => $c->parent_id === null || ! in_array($c->parent_id, $categoryIds))->pluck('id')->toArray(); $byParent = []; foreach ($q as $c) { - $byParent[$c->parent_id ?? 0][] = $c; + $parentKey = in_array($c->id, $rootIds) ? 0 : ($c->parent_id ?? 0); + $byParent[$parentKey][] = $c; } $build = function ($pid) use (&$build, &$byParent) { diff --git a/app/Services/Quote/FormulaEvaluatorService.php b/app/Services/Quote/FormulaEvaluatorService.php index 95d8836..2b30b1b 100644 --- a/app/Services/Quote/FormulaEvaluatorService.php +++ b/app/Services/Quote/FormulaEvaluatorService.php @@ -851,14 +851,19 @@ public function calculateCategoryPrice( } /** - * 공정별 품목 그룹화 + * 품목 카테고리 그룹화 (동적 카테고리 시스템) * - * 품목을 process_type에 따라 그룹화합니다: - * - screen: 스크린 공정 (원단, 패널, 도장 등) - * - bending: 절곡 공정 (알루미늄, 스테인리스 등) - * - steel: 철재 공정 (철재, 강판 등) - * - electric: 전기 공정 (모터, 제어반, 전선 등) - * - assembly: 조립 공정 (볼트, 너트, 브라켓 등) + * 품목을 item_category 필드 기준으로 그룹화합니다. + * 카테고리 정보는 categories 테이블에서 code_group='item_category'로 조회합니다. + * + * 카테고리 구조: + * - BODY: 본체 + * - BENDING 하위 카테고리: + * - BENDING_GUIDE: 절곡품 - 가이드레일 + * - BENDING_CASE: 절곡품 - 케이스 + * - BENDING_BOTTOM: 절곡품 - 하단마감재 + * - MOTOR_CTRL: 모터 & 제어기 + * - ACCESSORY: 부자재 */ public function groupItemsByProcess(array $items, ?int $tenantId = null): array { @@ -868,45 +873,51 @@ public function groupItemsByProcess(array $items, ?int $tenantId = null): array return ['ungrouped' => ['name' => '그룹없음', 'items' => $items, 'subtotal' => 0]]; } - // 품목 코드로 process_type 일괄 조회 + // 품목 코드로 item_category 일괄 조회 $itemCodes = array_unique(array_column($items, 'item_code')); if (empty($itemCodes)) { return ['ungrouped' => ['name' => '그룹없음', 'items' => $items, 'subtotal' => 0]]; } - $processTypes = DB::table('items') + // 품목별 item_category 조회 + $itemCategories = DB::table('items') ->where('tenant_id', $tenantId) ->whereIn('code', $itemCodes) ->whereNull('deleted_at') - ->pluck('process_type', 'code') + ->pluck('item_category', 'code') ->toArray(); - // 그룹별 분류 - $grouped = [ - 'screen' => ['name' => '스크린 공정', 'items' => [], 'subtotal' => 0], - 'bending' => ['name' => '절곡 공정', 'items' => [], 'subtotal' => 0], - 'steel' => ['name' => '철재 공정', 'items' => [], 'subtotal' => 0], - 'electric' => ['name' => '전기 공정', 'items' => [], 'subtotal' => 0], - 'assembly' => ['name' => '조립 공정', 'items' => [], 'subtotal' => 0], - 'other' => ['name' => '기타', 'items' => [], 'subtotal' => 0], - ]; + // 카테고리 트리 조회 (item_category 코드 그룹) + $categoryTree = $this->getItemCategoryTree($tenantId); + + // 카테고리 코드 → 정보 매핑 생성 (탭 구조에 맞게) + $categoryMapping = $this->buildCategoryMapping($categoryTree); + + // 그룹별 분류를 위한 빈 구조 생성 + $grouped = []; + foreach ($categoryMapping as $code => $info) { + $grouped[$code] = ['name' => $info['name'], 'items' => [], 'subtotal' => 0]; + } + // 기타 그룹 추가 + $grouped['OTHER'] = ['name' => '기타', 'items' => [], 'subtotal' => 0]; foreach ($items as $item) { - $processType = $processTypes[$item['item_code']] ?? 'other'; + $categoryCode = $itemCategories[$item['item_code']] ?? 'OTHER'; - if (! isset($grouped[$processType])) { - $processType = 'other'; + // 매핑에 없는 카테고리는 기타로 분류 + if (! isset($grouped[$categoryCode])) { + $categoryCode = 'OTHER'; } - $grouped[$processType]['items'][] = $item; - $grouped[$processType]['subtotal'] += $item['total_price'] ?? 0; + $grouped[$categoryCode]['items'][] = $item; + $grouped[$categoryCode]['subtotal'] += $item['total_price'] ?? 0; } // 빈 그룹 제거 $grouped = array_filter($grouped, fn ($g) => ! empty($g['items'])); - $this->addDebugStep(8, '공정그룹화', [ + $this->addDebugStep(8, '카테고리그룹화', [ 'total_items' => count($items), 'groups' => array_map(fn ($g) => [ 'name' => $g['name'], @@ -919,14 +930,94 @@ public function groupItemsByProcess(array $items, ?int $tenantId = null): array } /** - * items 배열에 process_group 필드 추가 + * item_category 카테고리 트리 조회 * - * groupedItems에서 각 아이템의 소속 그룹을 찾아 process_group 필드를 추가합니다. + * categories 테이블에서 code_group='item_category'인 카테고리를 트리 구조로 조회 + */ + private function getItemCategoryTree(int $tenantId): array + { + $categories = DB::table('categories') + ->where('tenant_id', $tenantId) + ->where('code_group', 'item_category') + ->where('is_active', 1) + ->whereNull('deleted_at') + ->orderBy('parent_id') + ->orderBy('sort_order') + ->orderBy('id') + ->get(['id', 'parent_id', 'code', 'name']) + ->toArray(); + + // 트리 구조로 변환 + $categoryIds = array_column($categories, 'id'); + $rootIds = array_filter($categories, fn ($c) => $c->parent_id === null || ! in_array($c->parent_id, $categoryIds)); + + $byParent = []; + foreach ($categories as $c) { + $parentKey = in_array($c->id, array_column($rootIds, 'id')) ? 0 : ($c->parent_id ?? 0); + $byParent[$parentKey][] = $c; + } + + $buildTree = function ($parentId) use (&$buildTree, &$byParent) { + $nodes = $byParent[$parentId] ?? []; + + return array_map(function ($n) use ($buildTree) { + return [ + 'id' => $n->id, + 'code' => $n->code, + 'name' => $n->name, + 'children' => $buildTree($n->id), + ]; + }, $nodes); + }; + + return $buildTree(0); + } + + /** + * 카테고리 트리를 탭 구조에 맞게 매핑 생성 + * + * BENDING 카테고리의 경우 하위 카테고리를 개별 탭으로, + * 나머지는 그대로 1depth 탭으로 매핑 + */ + private function buildCategoryMapping(array $categoryTree): array + { + $mapping = []; + + foreach ($categoryTree as $category) { + if ($category['code'] === 'BENDING' && ! empty($category['children'])) { + // BENDING: 하위 카테고리를 개별 탭으로 + foreach ($category['children'] as $subCategory) { + $mapping[$subCategory['code']] = [ + 'name' => '절곡품 - '.$subCategory['name'], + 'parentCode' => 'BENDING', + ]; + } + } else { + // 나머지: 1depth 탭 + $mapping[$category['code']] = [ + 'name' => $category['name'], + 'parentCode' => null, + ]; + } + } + + return $mapping; + } + + /** + * items 배열에 카테고리 정보 필드 추가 + * + * groupedItems에서 각 아이템의 소속 그룹을 찾아 카테고리 관련 필드를 추가합니다. * 프론트엔드에서 탭별 분류에 사용됩니다. + * + * 추가되는 필드: + * - process_group: 그룹명 (레거시 호환) + * - process_group_key: 그룹키 (레거시 호환) + * - category_code: 동적 카테고리 코드 (신규 시스템) */ private function addProcessGroupToItems(array $items, array $groupedItems): array { - // 각 그룹의 아이템 코드 → 그룹명 매핑 생성 + // 각 그룹의 아이템 코드 → 그룹정보 매핑 생성 $itemCodeToGroup = []; foreach ($groupedItems as $groupKey => $group) { if (! isset($group['items']) || ! is_array($group['items'])) { @@ -940,11 +1031,12 @@ private function addProcessGroupToItems(array $items, array $groupedItems): arra } } - // items 배열에 process_group 추가 + // items 배열에 카테고리 정보 추가 return array_map(function ($item) use ($itemCodeToGroup) { - $groupInfo = $itemCodeToGroup[$item['item_code']] ?? ['key' => 'other', 'name' => '기타']; + $groupInfo = $itemCodeToGroup[$item['item_code']] ?? ['key' => 'OTHER', 'name' => '기타']; $item['process_group'] = $groupInfo['name']; - $item['process_group_key'] = $groupInfo['key']; + $item['process_group_key'] = $groupInfo['key']; // 레거시 호환 + $item['category_code'] = $groupInfo['key']; // 신규 동적 카테고리 시스템 return $item; }, $items); diff --git a/database/seeders/QuoteItemCategorySeeder.php b/database/seeders/QuoteItemCategorySeeder.php new file mode 100644 index 0000000..d8f5093 --- /dev/null +++ b/database/seeders/QuoteItemCategorySeeder.php @@ -0,0 +1,107 @@ +가이드레일,케이스,하단마감재), 모터&제어기, 부자재 + */ +class QuoteItemCategorySeeder extends Seeder +{ + public function run(): void + { + $tenantId = 1; + $codeGroup = 'item_category'; + $now = now(); + + // 1. 상위 카테고리 추가 + $parentCategories = [ + ['code' => 'BODY', 'name' => '본체', 'sort_order' => 1], + ['code' => 'BENDING', 'name' => '절곡품', 'sort_order' => 2], + ['code' => 'MOTOR_CTRL', 'name' => '모터 & 제어기', 'sort_order' => 3], + ['code' => 'ACCESSORY', 'name' => '부자재', 'sort_order' => 4], + ]; + + $parentIds = []; + foreach ($parentCategories as $cat) { + $parentIds[$cat['code']] = DB::table('categories')->insertGetId([ + 'tenant_id' => $tenantId, + 'parent_id' => null, + 'code_group' => $codeGroup, + 'code' => $cat['code'], + 'name' => $cat['name'], + 'sort_order' => $cat['sort_order'], + 'is_active' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + // 2. 절곡품 하위 3개 중간 카테고리 추가 + $bendingSubCategories = [ + ['code' => 'BENDING_GUIDE', 'name' => '가이드레일', 'sort_order' => 1], + ['code' => 'BENDING_CASE', 'name' => '케이스', 'sort_order' => 2], + ['code' => 'BENDING_BOTTOM', 'name' => '하단마감재', 'sort_order' => 3], + ]; + + $bendingSubIds = []; + foreach ($bendingSubCategories as $cat) { + $bendingSubIds[$cat['code']] = DB::table('categories')->insertGetId([ + 'tenant_id' => $tenantId, + 'parent_id' => $parentIds['BENDING'], + 'code_group' => $codeGroup, + 'code' => $cat['code'], + 'name' => $cat['name'], + 'sort_order' => $cat['sort_order'], + 'is_active' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + // 3. 기존 세부 항목들의 parent_id 업데이트 + $mappings = [ + // 본체 + 'BODY' => ['SILICA_BODY', 'WIRE_BODY', 'FIBER_BODY', 'COLUMNLESS_BODY', 'SLAT_BODY'], + + // 절곡품 > 가이드레일 + 'BENDING_GUIDE' => ['GUIDE_RAIL', 'SMOKE_SEAL'], + + // 절곡품 > 케이스 + 'BENDING_CASE' => ['SHUTTER_BOX', 'TOP_COVER', 'END_PLATE'], + + // 절곡품 > 하단마감재 + 'BENDING_BOTTOM' => ['BOTTOM_TRIM', 'HAJANG_BAR', 'SPECIAL_TRIM', 'FLOOR_CUT_PLATE'], + + // 모터 & 제어기 + 'MOTOR_CTRL' => ['MOTOR_SET', 'INTERLOCK_CTRL', 'EMBED_BACK_BOX'], + + // 부자재 + 'ACCESSORY' => ['JOINT_BAR', 'SQUARE_PIPE', 'WINDING_SHAFT', 'ANGLE', 'ROUND_BAR', 'L_BAR', 'REINF_FLAT_BAR', 'WEIGHT_FLAT_BAR'], + ]; + + foreach ($mappings as $parentCode => $childCodes) { + // 상위 카테고리 ID 찾기 + $parentId = $parentIds[$parentCode] ?? $bendingSubIds[$parentCode] ?? null; + + if ($parentId) { + DB::table('categories') + ->where('tenant_id', $tenantId) + ->where('code_group', $codeGroup) + ->whereIn('code', $childCodes) + ->whereNull('deleted_at') + ->update(['parent_id' => $parentId, 'updated_at' => $now]); + } + } + + $this->command->info('견적 품목 카테고리 시딩 완료!'); + $this->command->info('- 상위 카테고리 4개 추가'); + $this->command->info('- 절곡품 하위 카테고리 3개 추가'); + $this->command->info('- 기존 세부 항목 parent_id 연결 완료'); + } +} \ No newline at end of file From dd8a744d12b8f0fd0e6956472528f468feb356a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 27 Jan 2026 15:23:02 +0900 Subject: [PATCH 06/57] =?UTF-8?q?fix:=20QuoteItemCategorySeeder=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=8B=A4=ED=96=89=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - insertGetId → updateOrInsert로 변경 - 이미 존재하는 카테고리는 업데이트, 없으면 생성 Co-Authored-By: Claude --- database/seeders/QuoteItemCategorySeeder.php | 68 +++++++++++++------- 1 file changed, 46 insertions(+), 22 deletions(-) diff --git a/database/seeders/QuoteItemCategorySeeder.php b/database/seeders/QuoteItemCategorySeeder.php index d8f5093..f07ec99 100644 --- a/database/seeders/QuoteItemCategorySeeder.php +++ b/database/seeders/QuoteItemCategorySeeder.php @@ -29,17 +29,29 @@ public function run(): void $parentIds = []; foreach ($parentCategories as $cat) { - $parentIds[$cat['code']] = DB::table('categories')->insertGetId([ - 'tenant_id' => $tenantId, - 'parent_id' => null, - 'code_group' => $codeGroup, - 'code' => $cat['code'], - 'name' => $cat['name'], - 'sort_order' => $cat['sort_order'], - 'is_active' => true, - 'created_at' => $now, - 'updated_at' => $now, - ]); + // 이미 존재하면 업데이트, 없으면 생성 + DB::table('categories')->updateOrInsert( + [ + 'tenant_id' => $tenantId, + 'code_group' => $codeGroup, + 'code' => $cat['code'], + ], + [ + 'parent_id' => null, + 'name' => $cat['name'], + 'sort_order' => $cat['sort_order'], + 'is_active' => true, + 'updated_at' => $now, + 'created_at' => $now, + ] + ); + + // ID 조회 + $parentIds[$cat['code']] = DB::table('categories') + ->where('tenant_id', $tenantId) + ->where('code_group', $codeGroup) + ->where('code', $cat['code']) + ->value('id'); } // 2. 절곡품 하위 3개 중간 카테고리 추가 @@ -51,17 +63,29 @@ public function run(): void $bendingSubIds = []; foreach ($bendingSubCategories as $cat) { - $bendingSubIds[$cat['code']] = DB::table('categories')->insertGetId([ - 'tenant_id' => $tenantId, - 'parent_id' => $parentIds['BENDING'], - 'code_group' => $codeGroup, - 'code' => $cat['code'], - 'name' => $cat['name'], - 'sort_order' => $cat['sort_order'], - 'is_active' => true, - 'created_at' => $now, - 'updated_at' => $now, - ]); + // 이미 존재하면 업데이트, 없으면 생성 + DB::table('categories')->updateOrInsert( + [ + 'tenant_id' => $tenantId, + 'code_group' => $codeGroup, + 'code' => $cat['code'], + ], + [ + 'parent_id' => $parentIds['BENDING'], + 'name' => $cat['name'], + 'sort_order' => $cat['sort_order'], + 'is_active' => true, + 'updated_at' => $now, + 'created_at' => $now, + ] + ); + + // ID 조회 + $bendingSubIds[$cat['code']] = DB::table('categories') + ->where('tenant_id', $tenantId) + ->where('code_group', $codeGroup) + ->where('code', $cat['code']) + ->value('id'); } // 3. 기존 세부 항목들의 parent_id 업데이트 From a9cdf004e3a725d2bd9c8dd8dcd494d6e8188804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 27 Jan 2026 15:24:35 +0900 Subject: [PATCH 07/57] =?UTF-8?q?fix:=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EA=B8=B0=EB=B0=98=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BoardController에 showByCode(string $code) 메서드 추가 - GET /api/v1/boards/{code} 라우트 등록 - 기존 ID 기반 조회와 코드 기반 조회 분리 Co-Authored-By: Claude --- app/Http/Controllers/Api/V1/BoardController.php | 16 ++++++++++++++++ routes/api.php | 3 +++ 2 files changed, 19 insertions(+) diff --git a/app/Http/Controllers/Api/V1/BoardController.php b/app/Http/Controllers/Api/V1/BoardController.php index 64d3d41..0f054d0 100644 --- a/app/Http/Controllers/Api/V1/BoardController.php +++ b/app/Http/Controllers/Api/V1/BoardController.php @@ -106,6 +106,22 @@ public function tenantBoards() }, __('message.fetched')); } + /** + * 게시판 상세 조회 (코드 기반) + */ + public function showByCode(string $code) + { + return ApiResponse::handle(function () use ($code) { + $board = $this->boardService->getBoardByCode($code); + + if (! $board) { + abort(404, __('error.board.not_found')); + } + + return $board; + }, __('message.fetched')); + } + /** * 게시판 필드 목록 조회 */ diff --git a/routes/api.php b/routes/api.php index eba164e..6c9fb58 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1462,6 +1462,9 @@ Route::post('/{code}/posts/{postId}/comments', [PostController::class, 'storeComment'])->name('v1.boards.posts.comments.store'); // 댓글 작성 Route::put('/{code}/posts/{postId}/comments/{commentId}', [PostController::class, 'updateComment'])->name('v1.boards.posts.comments.update'); // 댓글 수정 Route::delete('/{code}/posts/{postId}/comments/{commentId}', [PostController::class, 'destroyComment'])->name('v1.boards.posts.comments.destroy'); // 댓글 삭제 + + // 게시판 상세 (코드 기반) - 가장 마지막에 배치 (catch-all) + Route::get('/{code}', [BoardController::class, 'showByCode'])->name('v1.boards.show_by_code'); }); // 게시글 API (사용자 중심) From 7bd296b2fac7157c34e59de0d8bf02d6a1a5685b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 27 Jan 2026 15:51:16 +0900 Subject: [PATCH 08/57] =?UTF-8?q?fix:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EC=88=9C=EC=84=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(/tree=20=E2=86=92=20/{id}=20=EC=95=9E?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /tree, /reorder 라우트를 /{id} 와일드카드 라우트보다 먼저 정의 - 500 에러 해결: "tree"가 id 파라미터로 잘못 매칭되던 문제 Co-Authored-By: Claude --- routes/api.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/routes/api.php b/routes/api.php index 6c9fb58..2b2cfd7 100644 --- a/routes/api.php +++ b/routes/api.php @@ -951,16 +951,16 @@ // Category API (통합) Route::prefix('categories')->group(function () { + // === 확장 기능 (와일드카드 라우트보다 먼저 정의) === + Route::get('/tree', [CategoryController::class, 'tree'])->name('v1.categories.tree'); // 트리 + Route::post('/reorder', [CategoryController::class, 'reorder'])->name('v1.categories.reorder'); // 정렬 일괄 + // === 기본 Category CRUD === Route::get('', [CategoryController::class, 'index'])->name('v1.categories.index'); // 목록(페이징) Route::post('', [CategoryController::class, 'store'])->name('v1.categories.store'); // 생성 Route::get('/{id}', [CategoryController::class, 'show'])->name('v1.categories.show'); // 단건 Route::patch('/{id}', [CategoryController::class, 'update'])->name('v1.categories.update'); // 수정 Route::delete('/{id}', [CategoryController::class, 'destroy'])->name('v1.categories.destroy'); // 삭제(soft) - - // === 확장 기능 === - Route::get('/tree', [CategoryController::class, 'tree'])->name('v1.categories.tree'); // 트리 - Route::post('/reorder', [CategoryController::class, 'reorder'])->name('v1.categories.reorder'); // 정렬 일괄 Route::post('/{id}/toggle', [CategoryController::class, 'toggle'])->name('v1.categories.toggle'); // 활성 토글 Route::patch('/{id}/move', [CategoryController::class, 'move'])->name('v1.categories.move'); // 부모/순서 이동 From 51b23adcfe252609713415d8665c7e2804fb9dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 27 Jan 2026 21:10:50 +0900 Subject: [PATCH 09/57] =?UTF-8?q?fix:=20=EC=98=A4=EB=8A=98=EC=9D=98=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=88=20=EC=98=A4=EB=8A=98=20=EB=82=A0=EC=A7=9C?= =?UTF-8?q?=EB=A7=8C=20=ED=91=9C=EC=8B=9C=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TodayIssue 모델에 scopeToday() 스코프 추가 - TodayIssueService::summary()에 오늘 날짜 필터 적용 - 전체 개수 계산에도 오늘 날짜 필터 적용 Co-Authored-By: Claude --- app/Models/Tenants/TodayIssue.php | 8 ++++++++ app/Services/TodayIssueService.php | 6 ++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/Models/Tenants/TodayIssue.php b/app/Models/Tenants/TodayIssue.php index 4aa2165..9b9dc00 100644 --- a/app/Models/Tenants/TodayIssue.php +++ b/app/Models/Tenants/TodayIssue.php @@ -128,6 +128,14 @@ public function scopeActive($query) }); } + /** + * 오늘 날짜 이슈 스코프 + */ + public function scopeToday($query) + { + return $query->whereDate('created_at', today()); + } + /** * 뱃지별 필터 스코프 */ diff --git a/app/Services/TodayIssueService.php b/app/Services/TodayIssueService.php index 92bf15e..d703bc1 100644 --- a/app/Services/TodayIssueService.php +++ b/app/Services/TodayIssueService.php @@ -25,6 +25,7 @@ public function summary(int $limit = 30, ?string $badge = null): array $query = TodayIssue::query() ->where('tenant_id', $tenantId) ->active() // 만료되지 않은 이슈만 + ->today() // 오늘 날짜 이슈만 ->orderByDesc('created_at'); // 뱃지 필터 @@ -32,10 +33,11 @@ public function summary(int $limit = 30, ?string $badge = null): array $query->byBadge($badge); } - // 전체 개수 (필터 적용 전) + // 전체 개수 (필터 적용 전, 오늘 날짜만) $totalQuery = TodayIssue::query() ->where('tenant_id', $tenantId) - ->active(); + ->active() + ->today(); $totalCount = $totalQuery->count(); // 결과 조회 From 3917ea383141995dd7bd6dee68e06166e27973ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 27 Jan 2026 22:39:04 +0900 Subject: [PATCH 10/57] =?UTF-8?q?fix:=20=EA=B1=B0=EB=9E=98=EC=B2=98=20?= =?UTF-8?q?=EC=97=B0=EC=B2=B4/=EC=95=85=EC=84=B1=EC=B1=84=EA=B6=8C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ClientUpdateRequest, ClientStoreRequest에 is_overdue 필드 추가 - FormRequest rules에 누락되어 프론트엔드 값이 필터링됨 - ClientService.update()에 bad_debt 토글 연동 로직 추가 - bad_debt=true → BadDebt 레코드 생성 (status: collecting) - bad_debt=false → BadDebt 레코드 종료 (status: recovered) - ClientService의 has_bad_debt 판단 로직 수정 - 기존: sum(debt_amount) > 0 - 변경: exists() - 금액과 무관하게 레코드 존재 여부로 판단 Co-Authored-By: Claude Opus 4.5 --- .../Requests/Client/ClientStoreRequest.php | 1 + .../Requests/Client/ClientUpdateRequest.php | 1 + app/Services/ClientService.php | 72 +++++++++++++++++-- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/app/Http/Requests/Client/ClientStoreRequest.php b/app/Http/Requests/Client/ClientStoreRequest.php index b53e45b..09aa304 100644 --- a/app/Http/Requests/Client/ClientStoreRequest.php +++ b/app/Http/Requests/Client/ClientStoreRequest.php @@ -96,6 +96,7 @@ public function rules(): array // 기타 'memo' => 'nullable|string', 'is_active' => 'nullable|boolean', + 'is_overdue' => 'nullable|boolean', ]; } } diff --git a/app/Http/Requests/Client/ClientUpdateRequest.php b/app/Http/Requests/Client/ClientUpdateRequest.php index 5995bdb..98c90ed 100644 --- a/app/Http/Requests/Client/ClientUpdateRequest.php +++ b/app/Http/Requests/Client/ClientUpdateRequest.php @@ -96,6 +96,7 @@ public function rules(): array // 기타 'memo' => 'nullable|string', 'is_active' => 'nullable|boolean', + 'is_overdue' => 'nullable|boolean', ]; } } diff --git a/app/Services/ClientService.php b/app/Services/ClientService.php index e3f16da..6f44428 100644 --- a/app/Services/ClientService.php +++ b/app/Services/ClientService.php @@ -70,13 +70,24 @@ public function index(array $params) ->select('client_id', DB::raw('SUM(debt_amount) as total_bad_debt')) ->pluck('total_bad_debt', 'client_id'); + // 활성 악성채권이 있는 거래처 ID 목록 (금액과 무관하게 레코드 존재 여부) + $clientsWithBadDebt = BadDebt::where('tenant_id', $tenantId) + ->whereIn('client_id', $clientIds) + ->whereIn('status', [BadDebt::STATUS_COLLECTING, BadDebt::STATUS_LEGAL_ACTION]) + ->where('is_active', true) + ->whereNull('deleted_at') + ->distinct() + ->pluck('client_id') + ->flip() + ->toArray(); + // 각 거래처에 미수금/악성채권 정보 추가 - $paginator->getCollection()->transform(function ($client) use ($salesByClient, $depositsByClient, $badDebtsByClient) { + $paginator->getCollection()->transform(function ($client) use ($salesByClient, $depositsByClient, $badDebtsByClient, $clientsWithBadDebt) { $totalSales = $salesByClient[$client->id] ?? 0; $totalDeposits = $depositsByClient[$client->id] ?? 0; $client->outstanding_amount = max(0, $totalSales - $totalDeposits); $client->bad_debt_total = $badDebtsByClient[$client->id] ?? 0; - $client->has_bad_debt = ($badDebtsByClient[$client->id] ?? 0) > 0; + $client->has_bad_debt = isset($clientsWithBadDebt[$client->id]); return $client; }); @@ -108,15 +119,14 @@ public function show(int $id) $client->outstanding_amount = max(0, $totalSales - $totalDeposits); // 악성채권 정보 - $badDebtTotal = BadDebt::where('tenant_id', $tenantId) + $activeBadDebtQuery = BadDebt::where('tenant_id', $tenantId) ->where('client_id', $id) ->whereIn('status', [BadDebt::STATUS_COLLECTING, BadDebt::STATUS_LEGAL_ACTION]) ->where('is_active', true) - ->whereNull('deleted_at') - ->sum('debt_amount'); + ->whereNull('deleted_at'); - $client->bad_debt_total = $badDebtTotal; - $client->has_bad_debt = $badDebtTotal > 0; + $client->bad_debt_total = (clone $activeBadDebtQuery)->sum('debt_amount'); + $client->has_bad_debt = (clone $activeBadDebtQuery)->exists(); return $client; } @@ -173,6 +183,7 @@ private function generateClientCode(int $tenantId): string public function update(int $id, array $data) { $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); $client = Client::where('tenant_id', $tenantId)->find($id); if (! $client) { @@ -182,11 +193,58 @@ public function update(int $id, array $data) // client_code 변경 불가 (프론트에서 보내도 무시) unset($data['client_code']); + // bad_debt 토글 처리 (bad_debts 테이블과 연동) + $badDebtToggle = $data['bad_debt'] ?? null; + unset($data['bad_debt']); // Client 모델에는 bad_debt 컬럼이 없으므로 제거 + $client->update($data); + // bad_debt 토글이 명시적으로 전달된 경우에만 처리 + if ($badDebtToggle !== null) { + $this->syncBadDebtStatus($client, (bool) $badDebtToggle, $userId); + } + return $client->refresh(); } + /** + * 악성채권 상태 동기화 + * 프론트엔드의 bad_debt 토글에 따라 bad_debts 테이블 연동 + */ + private function syncBadDebtStatus(Client $client, bool $hasBadDebt, ?int $userId): void + { + $tenantId = $client->tenant_id; + + // 현재 활성 악성채권 조회 + $activeBadDebt = BadDebt::where('tenant_id', $tenantId) + ->where('client_id', $client->id) + ->where('is_active', true) + ->whereIn('status', [BadDebt::STATUS_COLLECTING, BadDebt::STATUS_LEGAL_ACTION]) + ->first(); + + if ($hasBadDebt && ! $activeBadDebt) { + // 악성채권 활성화: 새 레코드 생성 + BadDebt::create([ + 'tenant_id' => $tenantId, + 'client_id' => $client->id, + 'debt_amount' => $client->outstanding_balance ?? 0, + 'status' => BadDebt::STATUS_COLLECTING, + 'overdue_days' => 0, + 'occurred_at' => now(), + 'is_active' => true, + 'created_by' => $userId, + ]); + } elseif (! $hasBadDebt && $activeBadDebt) { + // 악성채권 비활성화: 기존 레코드 종료 처리 + $activeBadDebt->update([ + 'is_active' => false, + 'status' => BadDebt::STATUS_RECOVERED, + 'closed_at' => now(), + 'updated_by' => $userId, + ]); + } + } + /** 삭제 */ public function destroy(int $id) { From 518ae4657ea5836d14bda294b632368b701405f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 27 Jan 2026 22:40:54 +0900 Subject: [PATCH 11/57] =?UTF-8?q?fix:=20=EC=98=A4=EB=8A=98=EC=9D=98=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=88=20=EB=B1=83=EC=A7=80=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?source=5Ftype=20=EA=B8=B0=EB=B0=98=20=EB=A7=A4=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TodayIssue 모델에 SOURCE_TO_BADGE 매핑 상수 추가 - TodayIssueService에서 source_type 기반 badge 매핑 적용 - 입금/출금 소스 타입 및 뱃지 상수 추가 Co-Authored-By: Claude Opus 4.5 --- app/Models/Tenants/TodayIssue.php | 23 ++++++++++++++++++----- app/Services/TodayIssueService.php | 10 ++++++++-- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/app/Models/Tenants/TodayIssue.php b/app/Models/Tenants/TodayIssue.php index 9b9dc00..31f7f94 100644 --- a/app/Models/Tenants/TodayIssue.php +++ b/app/Models/Tenants/TodayIssue.php @@ -61,25 +61,38 @@ class TodayIssue extends Model public const SOURCE_WITHDRAWAL = 'withdrawal'; - // 뱃지 타입 상수 + // 뱃지 타입 상수 (최대 4자, 띄어쓰기 없음) public const BADGE_ORDER_REGISTER = '수주등록'; public const BADGE_COLLECTION_ISSUE = '추심이슈'; public const BADGE_SAFETY_STOCK = '안전재고'; - public const BADGE_EXPENSE_PENDING = '지출 승인대기'; + public const BADGE_EXPENSE_PENDING = '지출승인'; - public const BADGE_TAX_REPORT = '세금 신고'; + public const BADGE_TAX_REPORT = '세금신고'; - public const BADGE_APPROVAL_REQUEST = '결재 요청'; + public const BADGE_APPROVAL_REQUEST = '결재요청'; - public const BADGE_NEW_CLIENT = '신규거래처'; + public const BADGE_NEW_CLIENT = '신규업체'; public const BADGE_DEPOSIT = '입금'; public const BADGE_WITHDRAWAL = '출금'; + // source_type → badge 매핑 + public const SOURCE_TO_BADGE = [ + self::SOURCE_ORDER => self::BADGE_ORDER_REGISTER, + self::SOURCE_BAD_DEBT => self::BADGE_COLLECTION_ISSUE, + self::SOURCE_STOCK => self::BADGE_SAFETY_STOCK, + self::SOURCE_EXPENSE => self::BADGE_EXPENSE_PENDING, + self::SOURCE_TAX => self::BADGE_TAX_REPORT, + self::SOURCE_APPROVAL => self::BADGE_APPROVAL_REQUEST, + self::SOURCE_CLIENT => self::BADGE_NEW_CLIENT, + self::SOURCE_DEPOSIT => self::BADGE_DEPOSIT, + self::SOURCE_WITHDRAWAL => self::BADGE_WITHDRAWAL, + ]; + // 뱃지 → notification_type 매핑 public const BADGE_TO_NOTIFICATION_TYPE = [ self::BADGE_ORDER_REGISTER => 'sales_order', diff --git a/app/Services/TodayIssueService.php b/app/Services/TodayIssueService.php index d703bc1..a094546 100644 --- a/app/Services/TodayIssueService.php +++ b/app/Services/TodayIssueService.php @@ -44,9 +44,12 @@ public function summary(int $limit = 30, ?string $badge = null): array $issues = $query->limit($limit)->get(); $items = $issues->map(function (TodayIssue $issue) { + // source_type 기반으로 badge 매핑 (DB 값보다 우선) + $badge = TodayIssue::SOURCE_TO_BADGE[$issue->source_type] ?? $issue->badge ?? '기타'; + return [ 'id' => $issue->source_type.'_'.$issue->source_id, - 'badge' => $issue->badge, + 'badge' => $badge, 'content' => $issue->content, 'time' => $this->formatRelativeTime($issue->created_at), 'date' => $issue->created_at?->toDateString(), @@ -85,9 +88,12 @@ public function getUnreadList(int $limit = 10): array ->count(); $items = $issues->map(function (TodayIssue $issue) { + // source_type 기반으로 badge 매핑 (DB 값보다 우선) + $badge = TodayIssue::SOURCE_TO_BADGE[$issue->source_type] ?? $issue->badge ?? '기타'; + return [ 'id' => $issue->id, - 'badge' => $issue->badge, + 'badge' => $badge, 'notification_type' => $issue->notification_type, 'content' => $issue->content, 'path' => $issue->path, From f74767563f35935ceb4626a6a11fa66586aaf013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 28 Jan 2026 13:52:43 +0900 Subject: [PATCH 12/57] =?UTF-8?q?feat:=20FCM=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EB=B3=84=20=ED=83=80=EA=B2=9F=20=EC=95=8C=EB=A6=BC=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - today_issues 테이블에 target_user_id 컬럼 추가 (마이그레이션) - TodayIssue 모델: target_user_id 필드, targetUser 관계, forUser/targetedTo 스코프 추가 - TodayIssue 모델: 기안 상태 뱃지 상수 추가 (BADGE_DRAFT_APPROVED/REJECTED/COMPLETED) - TodayIssueObserverService: createIssueWithFcm, sendFcmNotification, getEnabledUserTokens에 targetUserId 파라미터 추가 - TodayIssueObserverService: handleApprovalStepChange - 결재자에게만 발송 - TodayIssueObserverService: handleApprovalStatusChange 추가 - 기안자에게만 발송 - ApprovalIssueObserver 신규 생성 및 AppServiceProvider에 등록 - i18n: 기안 승인/반려/완료 알림 메시지 추가 결재요청은 결재자(ApprovalStep.user_id)에게만, 기안 승인/반려는 기안자(Approval.drafter_id)에게만 FCM 발송 Co-Authored-By: Claude Opus 4.5 --- app/Models/Tenants/TodayIssue.php | 53 +++++++++- .../TodayIssue/ApprovalIssueObserver.php | 43 ++++++++ app/Providers/AppServiceProvider.php | 3 + app/Services/TodayIssueObserverService.php | 97 +++++++++++++++++-- app/Services/TodayIssueService.php | 12 +++ ...d_target_user_id_to_today_issues_table.php | 40 ++++++++ lang/ko/message.php | 5 + 7 files changed, 244 insertions(+), 9 deletions(-) create mode 100644 app/Observers/TodayIssue/ApprovalIssueObserver.php create mode 100644 database/migrations/2026_01_28_132426_add_target_user_id_to_today_issues_table.php diff --git a/app/Models/Tenants/TodayIssue.php b/app/Models/Tenants/TodayIssue.php index 31f7f94..9a208f2 100644 --- a/app/Models/Tenants/TodayIssue.php +++ b/app/Models/Tenants/TodayIssue.php @@ -24,6 +24,7 @@ class TodayIssue extends Model 'tenant_id', 'source_type', 'source_id', + 'target_user_id', 'badge', 'notification_type', 'content', @@ -80,6 +81,13 @@ class TodayIssue extends Model public const BADGE_WITHDRAWAL = '출금'; + // 기안 상태 변경 알림 뱃지 + public const BADGE_DRAFT_APPROVED = '기안승인'; + + public const BADGE_DRAFT_REJECTED = '기안반려'; + + public const BADGE_DRAFT_COMPLETED = '기안완료'; + // source_type → badge 매핑 public const SOURCE_TO_BADGE = [ self::SOURCE_ORDER => self::BADGE_ORDER_REGISTER, @@ -98,6 +106,9 @@ class TodayIssue extends Model self::BADGE_ORDER_REGISTER => 'sales_order', self::BADGE_NEW_CLIENT => 'new_vendor', self::BADGE_APPROVAL_REQUEST => 'approval_request', + self::BADGE_DRAFT_APPROVED => 'approval_request', + self::BADGE_DRAFT_REJECTED => 'approval_request', + self::BADGE_DRAFT_COMPLETED => 'approval_request', self::BADGE_COLLECTION_ISSUE => 'bad_debt', self::BADGE_SAFETY_STOCK => 'safety_stock', self::BADGE_EXPENSE_PENDING => 'expected_expense', @@ -122,6 +133,14 @@ public function reader(): BelongsTo return $this->belongsTo(User::class, 'read_by'); } + /** + * 대상 사용자 (특정 사용자에게만 발송할 경우) + */ + public function targetUser(): BelongsTo + { + return $this->belongsTo(User::class, 'target_user_id'); + } + /** * 읽지 않은 이슈 스코프 */ @@ -171,6 +190,26 @@ public function scopeBySource($query, string $sourceType, ?int $sourceId = null) return $query; } + /** + * 특정 사용자 대상 이슈 스코프 + * target_user_id가 null이거나 지정된 사용자인 경우 + */ + public function scopeForUser($query, int $userId) + { + return $query->where(function ($q) use ($userId) { + $q->whereNull('target_user_id') + ->orWhere('target_user_id', $userId); + }); + } + + /** + * 특정 사용자만 대상인 이슈 스코프 + */ + public function scopeTargetedTo($query, int $userId) + { + return $query->where('target_user_id', $userId); + } + /** * 이슈 확인 처리 */ @@ -185,6 +224,16 @@ public function markAsRead(int $userId): bool /** * 이슈 생성 헬퍼 (정적 메서드) + * + * @param int $tenantId 테넌트 ID + * @param string $sourceType 소스 타입 (order, approval, etc.) + * @param int|null $sourceId 소스 ID + * @param string $badge 뱃지 타입 + * @param string $content 알림 내용 + * @param string|null $path 이동 경로 + * @param bool $needsApproval 승인 필요 여부 + * @param \DateTime|null $expiresAt 만료 시간 + * @param int|null $targetUserId 특정 대상 사용자 ID (null이면 테넌트 전체) */ public static function createIssue( int $tenantId, @@ -194,7 +243,8 @@ public static function createIssue( string $content, ?string $path = null, bool $needsApproval = false, - ?\DateTime $expiresAt = null + ?\DateTime $expiresAt = null, + ?int $targetUserId = null ): self { // badge에서 notification_type 자동 매핑 $notificationType = self::BADGE_TO_NOTIFICATION_TYPE[$badge] ?? null; @@ -204,6 +254,7 @@ public static function createIssue( 'tenant_id' => $tenantId, 'source_type' => $sourceType, 'source_id' => $sourceId, + 'target_user_id' => $targetUserId, ], [ 'badge' => $badge, diff --git a/app/Observers/TodayIssue/ApprovalIssueObserver.php b/app/Observers/TodayIssue/ApprovalIssueObserver.php new file mode 100644 index 0000000..242e869 --- /dev/null +++ b/app/Observers/TodayIssue/ApprovalIssueObserver.php @@ -0,0 +1,43 @@ +isDirty('status')) { + $newStatus = $approval->status; + + // 승인 또는 반려 상태로 변경된 경우에만 알림 + if (in_array($newStatus, [Approval::STATUS_APPROVED, Approval::STATUS_REJECTED])) { + $this->safeExecute(fn () => $this->service->handleApprovalStatusChange($approval)); + } + } + } + + protected function safeExecute(callable $callback): void + { + try { + $callback(); + } catch (\Throwable $e) { + Log::warning('TodayIssue ApprovalObserver failed', [ + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f5f97d9..d083f5e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,6 +8,7 @@ use App\Models\Members\User; use App\Models\Orders\Client; use App\Models\Orders\Order; +use App\Models\Tenants\Approval; use App\Models\Tenants\ApprovalStep; use App\Models\Tenants\Bill; use App\Models\Tenants\Deposit; @@ -21,6 +22,7 @@ use App\Observers\ExpenseSync\WithdrawalExpenseSyncObserver; use App\Observers\MenuObserver; use App\Observers\TenantObserver; +use App\Observers\TodayIssue\ApprovalIssueObserver; use App\Observers\TodayIssue\ApprovalStepIssueObserver; use App\Observers\TodayIssue\BadDebtIssueObserver; use App\Observers\TodayIssue\ClientIssueObserver; @@ -85,6 +87,7 @@ public function boot(): void Stock::observe(StockIssueObserver::class); ExpectedExpense::observe(ExpectedExpenseIssueObserver::class); ApprovalStep::observe(ApprovalStepIssueObserver::class); + Approval::observe(ApprovalIssueObserver::class); Client::observe(ClientIssueObserver::class); Deposit::observe(DepositIssueObserver::class); Withdrawal::observe(WithdrawalIssueObserver::class); diff --git a/app/Services/TodayIssueObserverService.php b/app/Services/TodayIssueObserverService.php index 715430f..71e7cd3 100644 --- a/app/Services/TodayIssueObserverService.php +++ b/app/Services/TodayIssueObserverService.php @@ -7,6 +7,7 @@ use App\Models\Orders\Client; use App\Models\Orders\Order; use App\Models\PushDeviceToken; +use App\Models\Tenants\Approval; use App\Models\Tenants\ApprovalStep; use App\Models\Tenants\Deposit; use App\Models\Tenants\ExpectedExpense; @@ -202,6 +203,8 @@ public function handleExpectedExpenseDeleted(ExpectedExpense $expense): void /** * 결재 요청 이슈 생성/삭제 + * + * 결재자(ApprovalStep.user_id)에게만 알림 발송 */ public function handleApprovalStepChange(ApprovalStep $step): void { @@ -223,7 +226,8 @@ public function handleApprovalStepChange(ApprovalStep $step): void ]), path: '/approval/inbox', needsApproval: true, - expiresAt: null // 결재 완료 시까지 유지 + expiresAt: null, // 결재 완료 시까지 유지 + targetUserId: $step->user_id // 결재자에게만 발송 ); } else { if ($approval) { @@ -242,6 +246,52 @@ public function handleApprovalStepDeleted(ApprovalStep $step): void } } + /** + * 결재 상태 변경 시 기안자에게 알림 발송 + * + * 승인(approved), 반려(rejected), 완료(approved 최종) 시 기안자에게만 발송 + */ + public function handleApprovalStatusChange(Approval $approval): void + { + // 상태에 따른 뱃지 결정 + $badge = match ($approval->status) { + Approval::STATUS_APPROVED => TodayIssue::BADGE_DRAFT_APPROVED, + Approval::STATUS_REJECTED => TodayIssue::BADGE_DRAFT_REJECTED, + default => null, + }; + + if (! $badge) { + return; + } + + // 메시지 키 결정 + $messageKey = match ($approval->status) { + Approval::STATUS_APPROVED => 'message.today_issue.draft_approved', + Approval::STATUS_REJECTED => 'message.today_issue.draft_rejected', + default => null, + }; + + if (! $messageKey) { + return; + } + + $title = $approval->title ?? __('message.today_issue.approval_request'); + + $this->createIssueWithFcm( + tenantId: $approval->tenant_id, + sourceType: TodayIssue::SOURCE_APPROVAL, + sourceId: $approval->id, + badge: $badge, + content: __($messageKey, [ + 'title' => $title, + ]), + path: '/approval/draft', + needsApproval: false, + expiresAt: Carbon::now()->addDays(7), + targetUserId: $approval->drafter_id // 기안자에게만 발송 + ); + } + /** * 신규 거래처 이슈 생성 */ @@ -425,6 +475,7 @@ public function updateTaxIssues(int $tenantId): void /** * TodayIssue 생성 후 FCM 푸시 알림 발송 * + * target_user_id가 있으면 해당 사용자에게만, 없으면 테넌트 전체에게 발송 * 알림 설정이 활성화된 사용자에게만 발송 * 중요 알림(수주등록, 신규거래처, 결재요청)은 알림음 다르게 발송 */ @@ -436,12 +487,18 @@ public function sendFcmNotification(TodayIssue $issue): void try { // 해당 테넌트의 활성 토큰 조회 (알림 설정 활성화된 사용자만) - $tokens = $this->getEnabledUserTokens($issue->tenant_id, $issue->notification_type); + // target_user_id가 있으면 해당 사용자만, 없으면 테넌트 전체 + $tokens = $this->getEnabledUserTokens( + $issue->tenant_id, + $issue->notification_type, + $issue->target_user_id + ); if (empty($tokens)) { Log::info('[TodayIssue] No enabled tokens found for FCM', [ 'tenant_id' => $issue->tenant_id, 'notification_type' => $issue->notification_type, + 'target_user_id' => $issue->target_user_id, ]); return; @@ -466,6 +523,7 @@ public function sendFcmNotification(TodayIssue $issue): void Log::info('[TodayIssue] FCM notification sent', [ 'issue_id' => $issue->id, 'notification_type' => $issue->notification_type, + 'target_user_id' => $issue->target_user_id, 'channel_id' => $channelId, 'token_count' => count($tokens), 'success_count' => $result->getSuccessCount(), @@ -476,6 +534,7 @@ public function sendFcmNotification(TodayIssue $issue): void Log::error('[TodayIssue] FCM notification failed', [ 'issue_id' => $issue->id, 'notification_type' => $issue->notification_type, + 'target_user_id' => $issue->target_user_id, 'error' => $e->getMessage(), ]); } @@ -483,15 +542,25 @@ public function sendFcmNotification(TodayIssue $issue): void /** * 알림 설정이 활성화된 사용자들의 FCM 토큰 조회 + * + * @param int $tenantId 테넌트 ID + * @param string $notificationType 알림 타입 + * @param int|null $targetUserId 특정 대상 사용자 ID (null이면 테넌트 전체) */ - private function getEnabledUserTokens(int $tenantId, string $notificationType): array + private function getEnabledUserTokens(int $tenantId, string $notificationType, ?int $targetUserId = null): array { // 해당 테넌트의 활성 토큰 조회 - $tokens = PushDeviceToken::withoutGlobalScopes() + $query = PushDeviceToken::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('is_active', true) - ->whereNull('deleted_at') - ->get(); + ->whereNull('deleted_at'); + + // 특정 대상자가 지정된 경우 해당 사용자만 조회 + if ($targetUserId !== null) { + $query->where('user_id', $targetUserId); + } + + $tokens = $query->get(); if ($tokens->isEmpty()) { return []; @@ -534,6 +603,16 @@ private function isNotificationEnabledForUser(int $tenantId, int $userId, string * TodayIssue 생성 시 FCM 발송 포함 (래퍼 메서드) * * createIssue 호출 후 자동으로 FCM 발송 + * + * @param int $tenantId 테넌트 ID + * @param string $sourceType 소스 타입 + * @param int|null $sourceId 소스 ID + * @param string $badge 뱃지 타입 + * @param string $content 알림 내용 + * @param string|null $path 이동 경로 + * @param bool $needsApproval 승인 필요 여부 + * @param \DateTime|null $expiresAt 만료 시간 + * @param int|null $targetUserId 특정 대상 사용자 ID (null이면 테넌트 전체) */ public function createIssueWithFcm( int $tenantId, @@ -543,7 +622,8 @@ public function createIssueWithFcm( string $content, ?string $path = null, bool $needsApproval = false, - ?\DateTime $expiresAt = null + ?\DateTime $expiresAt = null, + ?int $targetUserId = null ): TodayIssue { $issue = TodayIssue::createIssue( tenantId: $tenantId, @@ -553,7 +633,8 @@ public function createIssueWithFcm( content: $content, path: $path, needsApproval: $needsApproval, - expiresAt: $expiresAt + expiresAt: $expiresAt, + targetUserId: $targetUserId ); // FCM 발송 diff --git a/app/Services/TodayIssueService.php b/app/Services/TodayIssueService.php index a094546..6a801fd 100644 --- a/app/Services/TodayIssueService.php +++ b/app/Services/TodayIssueService.php @@ -21,9 +21,11 @@ class TodayIssueService extends Service public function summary(int $limit = 30, ?string $badge = null): array { $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); $query = TodayIssue::query() ->where('tenant_id', $tenantId) + ->forUser($userId) // 본인 대상 또는 전체 브로드캐스트 ->active() // 만료되지 않은 이슈만 ->today() // 오늘 날짜 이슈만 ->orderByDesc('created_at'); @@ -36,6 +38,7 @@ public function summary(int $limit = 30, ?string $badge = null): array // 전체 개수 (필터 적용 전, 오늘 날짜만) $totalQuery = TodayIssue::query() ->where('tenant_id', $tenantId) + ->forUser($userId) ->active() ->today(); $totalCount = $totalQuery->count(); @@ -72,9 +75,11 @@ public function summary(int $limit = 30, ?string $badge = null): array public function getUnreadList(int $limit = 10): array { $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); $issues = TodayIssue::query() ->where('tenant_id', $tenantId) + ->forUser($userId) // 본인 대상 또는 전체 브로드캐스트 ->unread() ->active() ->orderByDesc('created_at') @@ -83,6 +88,7 @@ public function getUnreadList(int $limit = 10): array $totalCount = TodayIssue::query() ->where('tenant_id', $tenantId) + ->forUser($userId) ->unread() ->active() ->count(); @@ -115,9 +121,11 @@ public function getUnreadList(int $limit = 10): array public function getUnreadCount(): int { $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); return TodayIssue::query() ->where('tenant_id', $tenantId) + ->forUser($userId) // 본인 대상 또는 전체 브로드캐스트 ->unread() ->active() ->count(); @@ -132,6 +140,7 @@ public function markAsRead(int $issueId): bool $userId = $this->apiUserId(); $issue = TodayIssue::where('tenant_id', $tenantId) + ->forUser($userId) // 본인 대상 또는 전체 브로드캐스트 ->where('id', $issueId) ->first(); @@ -152,6 +161,7 @@ public function markAllAsRead(): int return TodayIssue::query() ->where('tenant_id', $tenantId) + ->forUser($userId) // 본인 대상 또는 전체 브로드캐스트 ->unread() ->active() ->update([ @@ -177,9 +187,11 @@ public function dismiss(string $sourceType, int $sourceId): bool public function countByBadge(): array { $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); $counts = TodayIssue::query() ->where('tenant_id', $tenantId) + ->forUser($userId) // 본인 대상 또는 전체 브로드캐스트 ->active() ->selectRaw('badge, COUNT(*) as count') ->groupBy('badge') diff --git a/database/migrations/2026_01_28_132426_add_target_user_id_to_today_issues_table.php b/database/migrations/2026_01_28_132426_add_target_user_id_to_today_issues_table.php new file mode 100644 index 0000000..c92653f --- /dev/null +++ b/database/migrations/2026_01_28_132426_add_target_user_id_to_today_issues_table.php @@ -0,0 +1,40 @@ +unsignedBigInteger('target_user_id') + ->nullable() + ->after('source_id') + ->comment('특정 대상 사용자 ID (null이면 테넌트 전체)'); + + $table->foreign('target_user_id') + ->references('id') + ->on('users') + ->onDelete('cascade'); + + $table->index(['tenant_id', 'target_user_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('today_issues', function (Blueprint $table) { + $table->dropForeign(['target_user_id']); + $table->dropIndex(['tenant_id', 'target_user_id']); + $table->dropColumn('target_user_id'); + }); + } +}; diff --git a/lang/ko/message.php b/lang/ko/message.php index df98c74..5a31536 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -516,6 +516,11 @@ 'deposit_registered' => ':client 입금 :amount원', 'withdrawal_registered' => ':client 출금 :amount원', + // 기안 상태 변경 알림 + 'draft_approved' => ':title 결재가 승인되었습니다', + 'draft_rejected' => ':title 결재가 반려되었습니다', + 'draft_completed' => ':title 결재가 완료되었습니다', + // 하위 호환성 (deprecated) 'order_success' => ':client 신규 수주 :amount원 확정', 'receivable_overdue' => ':client 미수금 :amount원 연체 :days일', From a96499a66d13d4b9033957e69f15060dae0151f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 28 Jan 2026 17:30:19 +0900 Subject: [PATCH 13/57] =?UTF-8?q?feat:=20API=20=EB=9D=BC=EC=9A=B0=ED=84=B0?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=ED=8F=B4=EB=B0=B1=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api.php를 13개 도메인별 파일로 분리 (1,479줄 → 61줄) - ApiVersionMiddleware 생성 (헤더/쿼리 기반 버전 선택) - v2 요청 시 v2 없으면 v1으로 자동 폴백 - 지원 헤더: Accept-Version, X-API-Version, api_version 쿼리 분리된 도메인: auth, admin, users, tenants, hr, finance, sales, inventory, production, design, files, boards, common Co-Authored-By: Claude Opus 4.5 --- app/Http/Middleware/ApiVersionMiddleware.php | 171 ++ bootstrap/app.php | 9 +- routes/api.php | 1520 +----------------- routes/api/v1/admin.php | 63 + routes/api/v1/auth.php | 32 + routes/api/v1/boards.php | 170 ++ routes/api/v1/common.php | 228 +++ routes/api/v1/design.php | 84 + routes/api/v1/files.php | 44 + routes/api/v1/finance.php | 298 ++++ routes/api/v1/hr.php | 215 +++ routes/api/v1/inventory.php | 117 ++ routes/api/v1/production.php | 84 + routes/api/v1/sales.php | 161 ++ routes/api/v1/tenants.php | 46 + routes/api/v1/users.php | 75 + 16 files changed, 1845 insertions(+), 1472 deletions(-) create mode 100644 app/Http/Middleware/ApiVersionMiddleware.php create mode 100644 routes/api/v1/admin.php create mode 100644 routes/api/v1/auth.php create mode 100644 routes/api/v1/boards.php create mode 100644 routes/api/v1/common.php create mode 100644 routes/api/v1/design.php create mode 100644 routes/api/v1/files.php create mode 100644 routes/api/v1/finance.php create mode 100644 routes/api/v1/hr.php create mode 100644 routes/api/v1/inventory.php create mode 100644 routes/api/v1/production.php create mode 100644 routes/api/v1/sales.php create mode 100644 routes/api/v1/tenants.php create mode 100644 routes/api/v1/users.php diff --git a/app/Http/Middleware/ApiVersionMiddleware.php b/app/Http/Middleware/ApiVersionMiddleware.php new file mode 100644 index 0000000..3dad8bf --- /dev/null +++ b/app/Http/Middleware/ApiVersionMiddleware.php @@ -0,0 +1,171 @@ + 쿼리 파라미터 > 기본값) + $requestedVersion = $this->getRequestedVersion($request); + + // 2. 실제 사용할 버전 결정 (fallback 적용) + $actualVersion = $this->resolveVersion($request, $requestedVersion); + + // 3. 요청에 버전 정보 저장 (컨트롤러에서 사용 가능) + $request->attributes->set('api_version', $actualVersion); + $request->attributes->set('api_version_requested', $requestedVersion); + $request->attributes->set('api_version_fallback', $actualVersion !== $requestedVersion); + + // 4. 요청 처리 + $response = $next($request); + + // 5. 응답 헤더에 버전 정보 추가 + $response->headers->set('X-API-Version', $actualVersion); + if ($actualVersion !== $requestedVersion) { + $response->headers->set('X-API-Version-Fallback', 'true'); + $response->headers->set('X-API-Version-Requested', $requestedVersion); + } + + return $response; + } + + /** + * 요청에서 버전 정보 추출 + */ + protected function getRequestedVersion(Request $request): string + { + // 1. Accept-Version 헤더 (권장) + $version = $request->header('Accept-Version'); + if ($version && $this->isValidVersion($version)) { + return $version; + } + + // 2. X-API-Version 헤더 (대안) + $version = $request->header('X-API-Version'); + if ($version && $this->isValidVersion($version)) { + return $version; + } + + // 3. 쿼리 파라미터 (테스트용) + $version = $request->query('api_version'); + if ($version && $this->isValidVersion($version)) { + return $version; + } + + return $this->defaultVersion; + } + + /** + * 유효한 버전인지 확인 + */ + protected function isValidVersion(string $version): bool + { + return in_array($version, $this->supportedVersions, true); + } + + /** + * 실제 사용할 버전 결정 (fallback 로직) + */ + protected function resolveVersion(Request $request, string $requestedVersion): string + { + // 요청된 버전부터 하위 버전까지 순차 확인 + $startIndex = array_search($requestedVersion, $this->supportedVersions, true); + + if ($startIndex === false) { + return $this->defaultVersion; + } + + // 요청된 버전부터 하위 버전까지 체크 + for ($i = $startIndex; $i < count($this->supportedVersions); $i++) { + $version = $this->supportedVersions[$i]; + + if ($this->versionRouteExists($request, $version)) { + return $version; + } + } + + // 모든 버전에서 라우트를 찾지 못하면 기본값 반환 + return $this->defaultVersion; + } + + /** + * 해당 버전의 라우트가 존재하는지 확인 + */ + protected function versionRouteExists(Request $request, string $version): bool + { + $path = $request->path(); + + // URL에서 버전 부분 교체 + // /api/v1/users → /api/v2/users + $versionedPath = preg_replace('/^api\/v\d+/', "api/{$version}", $path); + + // 해당 경로의 라우트가 존재하는지 확인 + $routes = Route::getRoutes(); + + foreach ($routes as $route) { + $routeUri = $route->uri(); + + // 정확히 일치하거나 파라미터 패턴 매칭 + if ($this->matchesRoute($versionedPath, $routeUri, $request->method())) { + return true; + } + } + + return false; + } + + /** + * 경로가 라우트와 일치하는지 확인 + */ + protected function matchesRoute(string $path, string $routeUri, string $method): bool + { + // 라우트 URI의 파라미터를 정규식으로 변환 + // {id} → [^/]+ + $pattern = preg_replace('/\{[^}]+\}/', '[^/]+', $routeUri); + $pattern = '#^'.$pattern.'$#'; + + return (bool) preg_match($pattern, $path); + } + + /** + * 지원 버전 목록 반환 (외부에서 사용) + */ + public function getSupportedVersions(): array + { + return $this->supportedVersions; + } + + /** + * 기본 버전 반환 + */ + public function getDefaultVersion(): string + { + return $this->defaultVersion; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 5319a29..9091600 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ use App\Exceptions\Handler; use App\Http\Middleware\ApiKeyMiddleware; use App\Http\Middleware\ApiRateLimiter; +use App\Http\Middleware\ApiVersionMiddleware; use App\Http\Middleware\CheckPermission; use App\Http\Middleware\CheckSwaggerAuth; use App\Http\Middleware\CorsMiddleware; @@ -23,14 +24,16 @@ ->withMiddleware(function (Middleware $middleware) { // 글로벌 미들웨어 (모든 요청에 적용, 순서 중요) $middleware->append(CorsMiddleware::class); - $middleware->append(ApiRateLimiter::class); // 1. Rate Limiting 먼저 체크 - $middleware->append(ApiKeyMiddleware::class); // 2. API Key 검증 + $middleware->append(ApiRateLimiter::class); // 1. Rate Limiting 먼저 체크 + $middleware->append(ApiKeyMiddleware::class); // 2. API Key 검증 + $middleware->append(ApiVersionMiddleware::class); // 3. API 버전 해석 및 폴백 // API 미들웨어 그룹에 로깅 추가 $middleware->appendToGroup('api', LogApiRequest::class); $middleware->alias([ - 'auth.apikey' => ApiKeyMiddleware::class, // 인증: apikey + basic auth (alias 유지) + 'auth.apikey' => ApiKeyMiddleware::class, // 인증: apikey + basic auth (alias 유지) + 'api.version' => ApiVersionMiddleware::class, // API 버전 해석 및 폴백 'swagger.auth' => CheckSwaggerAuth::class, 'perm.map' => PermMapper::class, // 전처리: 라우트명 → perm 키 생성/주입 'permission' => CheckPermission::class, // 검사: perm 키로 접근 허용/차단 diff --git a/routes/api.php b/routes/api.php index 2b2cfd7..269f423 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,1479 +1,61 @@ group(function () { - - // 내부 서버간 통신 (API Key, Bearer 인증 제외 - HMAC 인증 사용) - Route::prefix('internal')->group(function () { - Route::post('/exchange-token', [InternalController::class, 'exchangeToken'])->name('v1.internal.exchange-token'); - }); - - // API KEY 인증 (글로벌 미들웨어로 이미 적용됨) - Route::get('/debug-apikey', [ApiController::class, 'debugApikey']); - - // SAM API (글로벌 미들웨어로 이미 적용됨) - Route::group([], function () { - - // Auth API - Route::post('login', [ApiController::class, 'login'])->name('v1.users.login'); - Route::middleware('auth:sanctum')->post('logout', [ApiController::class, 'logout'])->name('v1.users.logout'); - Route::post('signup', [ApiController::class, 'signup'])->name('v1.users.signup'); - Route::post('token-login', [ApiController::class, 'tokenLogin'])->name('v1.auth.token-login'); // MNG → DEV 자동 로그인 - Route::post('refresh', [RefreshController::class, 'refresh'])->name('v1.token.refresh'); - Route::post('register', [RegisterController::class, 'register'])->name('v1.register'); - - // Tenant Admin API - Route::prefix('admin')->group(function () { - // 목록/생성 - Route::get('users', [AdminController::class, 'index'])->name('v1.admin.users.index'); // 테넌트 사용자 목록 조회 - Route::post('users', [AdminController::class, 'store'])->name('v1.admin.users.store'); // 테넌트 사용자 생성 - - // 단건 - Route::get('users/{id}', [AdminController::class, 'show'])->name('v1.admin.users.show'); // 테넌트 사용자 단건 조회 - Route::put('users/{id}', [AdminController::class, 'update'])->name('v1.admin.users.update'); // 테넌트 사용자 수정 - - // 소프트 삭제 복구 - Route::delete('users/{id}', [AdminController::class, 'destroy'])->name('v1.admin.users.destroy'); // 테넌트 사용자 삭제(연결 삭제) - Route::post('users/{id}/restore', [AdminController::class, 'restore'])->name('v1.admin.users.restore'); // 테넌트 사용자 삭제 복구 - - // 상태 토글 - Route::patch('users/{id}/status', [AdminController::class, 'toggle'])->name('v1.admin.users.status.toggle'); // 테넌트 사용자 활성/비활성 - - // 역할 부여/해제 - Route::post('users/{id}/roles', [AdminController::class, 'attach'])->name('v1.admin.users.roles.attach'); // 테넌트 사용자 역할 부여 - Route::delete('users/{id}/roles/{role}', [AdminController::class, 'detach'])->name('v1.admin.users.roles.detach'); // 테넌트 사용자 역할 해제 - - // 비밀번호 초기화 - Route::post('users/{id}/reset-password', [AdminController::class, 'reset'])->name('v1.admin.users.password.reset'); // 테넌트 사용자 비밀번호 초기화 - - // 글로벌 메뉴 관리 (시스템 관리자용) - Route::prefix('global-menus')->group(function () { - Route::get('/', [GlobalMenuController::class, 'index'])->name('v1.admin.global-menus.index'); // 글로벌 메뉴 목록 - Route::post('/', [GlobalMenuController::class, 'store'])->name('v1.admin.global-menus.store'); // 글로벌 메뉴 생성 - Route::get('/tree', [GlobalMenuController::class, 'tree'])->name('v1.admin.global-menus.tree'); // 글로벌 메뉴 트리 - Route::get('/stats', [GlobalMenuController::class, 'stats'])->name('v1.admin.global-menus.stats'); // 글로벌 메뉴 통계 - Route::post('/reorder', [GlobalMenuController::class, 'reorder'])->name('v1.admin.global-menus.reorder'); // 글로벌 메뉴 순서 변경 - Route::get('/{id}', [GlobalMenuController::class, 'show'])->name('v1.admin.global-menus.show'); // 글로벌 메뉴 단건 조회 - Route::put('/{id}', [GlobalMenuController::class, 'update'])->name('v1.admin.global-menus.update'); // 글로벌 메뉴 수정 - Route::delete('/{id}', [GlobalMenuController::class, 'destroy'])->name('v1.admin.global-menus.destroy'); // 글로벌 메뉴 삭제 - Route::post('/{id}/sync-to-tenants', [GlobalMenuController::class, 'syncToTenants'])->name('v1.admin.global-menus.sync-to-tenants'); // 테넌트에 동기화 - }); - }); - - // Member API - Route::prefix('users')->group(function () { - Route::get('index', [UserController::class, 'index'])->name('v1.users.index'); // 회원 목록 조회 - Route::get('show/{user_no}', [UserController::class, 'show'])->name('v1.users.show'); // 회원 상세 조회 - - Route::get('me', [UserController::class, 'me'])->name('v1.users.users.me'); // 내 정보 조회 - Route::put('me', [UserController::class, 'meUpdate'])->name('v1.users.me.update'); // 내 정보 수정 - Route::put('me/password', [UserController::class, 'changePassword'])->name('v1.users.me.password'); // 비밀번호 변겅 - - Route::get('me/tenants', [UserController::class, 'tenants'])->name('v1.users.me.tenants.index'); // 내 테넌트 목록 - Route::patch('me/tenants/switch', [UserController::class, 'switchTenant'])->name('v1.users.me.tenants.switch'); // 활성 테넌트 전환 - - // 사용자 초대 API - Route::get('invitations', [UserInvitationController::class, 'index'])->name('v1.users.invitations.index'); // 초대 목록 - Route::post('invite', [UserInvitationController::class, 'invite'])->name('v1.users.invite'); // 초대 발송 - Route::post('invitations/{token}/accept', [UserInvitationController::class, 'accept'])->name('v1.users.invitations.accept'); // 초대 수락 - Route::delete('invitations/{id}', [UserInvitationController::class, 'cancel'])->whereNumber('id')->name('v1.users.invitations.cancel'); // 초대 취소 - Route::post('invitations/{id}/resend', [UserInvitationController::class, 'resend'])->whereNumber('id')->name('v1.users.invitations.resend'); // 초대 재발송 - - // 알림 설정 API (auth:sanctum 필수) - Route::middleware('auth:sanctum')->group(function () { - Route::get('me/notification-settings', [NotificationSettingController::class, 'index'])->name('v1.users.me.notification-settings.index'); // 알림 설정 조회 - Route::put('me/notification-settings', [NotificationSettingController::class, 'update'])->name('v1.users.me.notification-settings.update'); // 알림 설정 수정 - Route::put('me/notification-settings/bulk', [NotificationSettingController::class, 'bulkUpdate'])->name('v1.users.me.notification-settings.bulk'); // 알림 일괄 설정 - }); - }); - - // Account API (계정 관리 - 탈퇴, 사용중지, 약관동의) - Route::prefix('account')->middleware('auth:sanctum')->group(function () { - Route::post('withdraw', [AccountController::class, 'withdraw'])->name('v1.account.withdraw'); // 회원 탈퇴 - Route::post('suspend', [AccountController::class, 'suspend'])->name('v1.account.suspend'); // 사용 중지 (테넌트) - Route::get('agreements', [AccountController::class, 'getAgreements'])->name('v1.account.agreements.index'); // 약관 동의 조회 - Route::put('agreements', [AccountController::class, 'updateAgreements'])->name('v1.account.agreements.update'); // 약관 동의 수정 - }); - - // Tenant API - Route::prefix('tenants')->group(function () { - Route::get('list', [TenantController::class, 'index'])->name('v1.tenant.index'); // 테넌트 목록 조회 - Route::get('/', [TenantController::class, 'show'])->name('v1.tenant.show'); // 테넌트 정보 조회 - Route::put('/', [TenantController::class, 'update'])->name('v1.tenant.update'); // 테넌트 정보 수정 - Route::post('/', [TenantController::class, 'store'])->name('v1.tenant.store'); // 테넌트 등록 - Route::delete('/', [TenantController::class, 'destroy'])->name('v1.tenant.destroy'); // 테넌트 삭제(탈퇴) - Route::put('/restore/{tenant_id}', [TenantController::class, 'restore'])->name('v1.tenant.restore'); // 테넌트 복구 - Route::post('/logo', [TenantController::class, 'uploadLogo'])->name('v1.tenant.upload-logo'); // 로고 업로드 - }); - - // Tenant Statistics Field API - Route::prefix('tenant-stat-fields')->group(function () { - Route::get('/', [TenantStatFieldController::class, 'index'])->name('v1.tenant-stat-fields.index'); // 목록 조회 - Route::post('/', [TenantStatFieldController::class, 'store'])->name('v1.tenant-stat-fields.store'); // 생성 - Route::get('/{id}', [TenantStatFieldController::class, 'show'])->name('v1.tenant-stat-fields.show'); // 단건 조회 - Route::patch('/{id}', [TenantStatFieldController::class, 'update'])->name('v1.tenant-stat-fields.update'); // 수정 - Route::delete('/{id}', [TenantStatFieldController::class, 'destroy'])->name('v1.tenant-stat-fields.destroy'); // 삭제 - Route::post('/reorder', [TenantStatFieldController::class, 'reorder'])->name('v1.tenant-stat-fields.reorder'); // 정렬 변경 - Route::put('/bulk-upsert', [TenantStatFieldController::class, 'bulkUpsert'])->name('v1.tenant-stat-fields.bulk-upsert'); // 일괄 저장 - }); - - // Tenant Settings API (테넌트별 설정) - Route::prefix('tenant-settings')->group(function () { - Route::get('/', [TenantSettingController::class, 'index'])->name('v1.tenant-settings.index'); // 전체 설정 조회 - Route::post('/', [TenantSettingController::class, 'store'])->name('v1.tenant-settings.store'); // 설정 저장 - Route::put('/bulk', [TenantSettingController::class, 'bulkUpdate'])->name('v1.tenant-settings.bulk'); // 일괄 저장 - Route::post('/initialize', [TenantSettingController::class, 'initialize'])->name('v1.tenant-settings.initialize'); // 기본 설정 초기화 - Route::get('/{group}/{key}', [TenantSettingController::class, 'show'])->name('v1.tenant-settings.show'); // 단일 설정 조회 - Route::delete('/{group}/{key}', [TenantSettingController::class, 'destroy'])->name('v1.tenant-settings.destroy'); // 설정 삭제 - }); - - // Menu API - Route::middleware(['perm.map', 'permission'])->prefix('menus')->group(function () { - Route::get('/', [MenuController::class, 'index'])->name('v1.menus.index'); - Route::post('/', [MenuController::class, 'store'])->name('v1.menus.store'); - Route::post('/reorder', [MenuController::class, 'reorder'])->name('v1.menus.reorder'); - - // 동기화 관련 라우트 (/{id} 전에 위치해야 함) - Route::get('/trashed', [MenuController::class, 'trashed'])->name('v1.menus.trashed'); - Route::get('/available-global', [MenuController::class, 'availableGlobal'])->name('v1.menus.available-global'); - Route::get('/sync-status', [MenuController::class, 'syncStatus'])->name('v1.menus.sync-status'); - Route::post('/sync', [MenuController::class, 'sync'])->name('v1.menus.sync'); - Route::post('/sync-new', [MenuController::class, 'syncNew'])->name('v1.menus.sync-new'); - Route::post('/sync-updates', [MenuController::class, 'syncUpdates'])->name('v1.menus.sync-updates'); - - // 단일 메뉴 관련 라우트 - Route::get('/{id}', [MenuController::class, 'show'])->name('v1.menus.show'); - Route::patch('/{id}', [MenuController::class, 'update'])->name('v1.menus.update'); - Route::delete('/{id}', [MenuController::class, 'destroy'])->name('v1.menus.destroy'); - Route::post('/{id}/toggle', [MenuController::class, 'toggle'])->name('v1.menus.toggle'); - Route::post('/{id}/restore', [MenuController::class, 'restore'])->name('v1.menus.restore'); - }); - - // Role API - Route::prefix('roles')->group(function () { - Route::get('/', [RoleController::class, 'index'])->name('v1.roles.index'); // view - Route::post('/', [RoleController::class, 'store'])->name('v1.roles.store'); // create - Route::get('/stats', [RoleController::class, 'stats'])->name('v1.roles.stats'); // stats - Route::get('/active', [RoleController::class, 'active'])->name('v1.roles.active'); // active list - Route::get('/{id}', [RoleController::class, 'show'])->name('v1.roles.show'); // view - Route::patch('/{id}', [RoleController::class, 'update'])->name('v1.roles.update'); // update - Route::delete('/{id}', [RoleController::class, 'destroy'])->name('v1.roles.destroy'); // delete - }); - - // Role Permission API - 공통 - Route::get('/role-permissions/menus', [RolePermissionController::class, 'menus'])->name('v1.roles.perms.menus'); // 메뉴 트리 - - // Role Permission API - 역할별 - Route::prefix('roles/{id}/permissions')->group(function () { - Route::get('/', [RolePermissionController::class, 'index'])->name('v1.roles.perms.index'); // list - Route::post('/', [RolePermissionController::class, 'grant'])->name('v1.roles.perms.grant'); // grant - Route::delete('/', [RolePermissionController::class, 'revoke'])->name('v1.roles.perms.revoke'); // revoke - Route::put('/sync', [RolePermissionController::class, 'sync'])->name('v1.roles.perms.sync'); // sync - // 권한 매트릭스 API - Route::get('/matrix', [RolePermissionController::class, 'matrix'])->name('v1.roles.perms.matrix'); // 권한 매트릭스 조회 - Route::post('/toggle', [RolePermissionController::class, 'toggle'])->name('v1.roles.perms.toggle'); // 개별 권한 토글 - Route::post('/allow-all', [RolePermissionController::class, 'allowAll'])->name('v1.roles.perms.allowAll'); // 전체 허용 - Route::post('/deny-all', [RolePermissionController::class, 'denyAll'])->name('v1.roles.perms.denyAll'); // 전체 거부 - Route::post('/reset', [RolePermissionController::class, 'reset'])->name('v1.roles.perms.reset'); // 기본값 초기화 - }); - - // User Role API - Route::prefix('users/{id}/roles')->group(function () { - Route::get('/', [UserRoleController::class, 'index'])->name('v1.users.roles.index'); // list - Route::post('/', [UserRoleController::class, 'grant'])->name('v1.users.roles.grant'); // grant - Route::delete('/', [UserRoleController::class, 'revoke'])->name('v1.users.roles.revoke'); // revoke - Route::put('/sync', [UserRoleController::class, 'sync'])->name('v1.users.roles.sync'); // sync - }); - - // Department API - Route::prefix('departments')->group(function () { - Route::get('', [DepartmentController::class, 'index'])->name('v1.departments.index'); // 목록 - Route::post('', [DepartmentController::class, 'store'])->name('v1.departments.store'); // 생성 - Route::get('/tree', [DepartmentController::class, 'tree'])->name('v1.departments.tree'); // 트리 - Route::get('/{id}', [DepartmentController::class, 'show'])->name('v1.departments.show'); // 단건 - Route::patch('/{id}', [DepartmentController::class, 'update'])->name('v1.departments.update'); // 수정 - Route::delete('/{id}', [DepartmentController::class, 'destroy'])->name('v1.departments.destroy'); // 삭제(soft) - - // 부서-사용자 - Route::get('/{id}/users', [DepartmentController::class, 'listUsers'])->name('v1.departments.users.index'); // 부서 사용자 목록 - Route::post('/{id}/users', [DepartmentController::class, 'attachUser'])->name('v1.departments.users.attach'); // 사용자 배정(주/부서) - Route::delete('/{id}/users/{user}', [DepartmentController::class, 'detachUser'])->name('v1.departments.users.detach'); // 사용자 제거 - Route::patch('/{id}/users/{user}/primary', [DepartmentController::class, 'setPrimary'])->name('v1.departments.users.primary'); // 주부서 설정/해제 - - // 부서-권한 - Route::get('/{id}/permissions', [DepartmentController::class, 'listPermissions'])->name('v1.departments.permissions.index'); // 권한 목록 - Route::post('/{id}/permissions', [DepartmentController::class, 'upsertPermissions'])->name('v1.departments.permissions.upsert'); // 권한 부여/차단(메뉴별 가능) - Route::delete('/{id}/permissions/{permission}', [DepartmentController::class, 'revokePermissions'])->name('v1.departments.permissions.revoke'); // 권한 제거(해당 메뉴 범위까지) - }); - - // Position API (직급/직책 통합 관리) - Route::prefix('positions')->group(function () { - Route::get('', [PositionController::class, 'index'])->name('v1.positions.index'); - Route::post('', [PositionController::class, 'store'])->name('v1.positions.store'); - Route::put('/reorder', [PositionController::class, 'reorder'])->name('v1.positions.reorder'); - Route::get('/{id}', [PositionController::class, 'show'])->name('v1.positions.show'); - Route::put('/{id}', [PositionController::class, 'update'])->name('v1.positions.update'); - Route::delete('/{id}', [PositionController::class, 'destroy'])->name('v1.positions.destroy'); - }); - - // Employee API (사원 관리) - Route::prefix('employees')->group(function () { - Route::get('', [EmployeeController::class, 'index'])->name('v1.employees.index'); - Route::post('', [EmployeeController::class, 'store'])->name('v1.employees.store'); - Route::get('/stats', [EmployeeController::class, 'stats'])->name('v1.employees.stats'); - Route::get('/{id}', [EmployeeController::class, 'show'])->name('v1.employees.show'); - Route::patch('/{id}', [EmployeeController::class, 'update'])->name('v1.employees.update'); - Route::delete('/{id}', [EmployeeController::class, 'destroy'])->name('v1.employees.destroy'); - Route::post('/bulk-delete', [EmployeeController::class, 'bulkDelete'])->name('v1.employees.bulkDelete'); - Route::post('/{id}/create-account', [EmployeeController::class, 'createAccount'])->name('v1.employees.createAccount'); - Route::post('/{id}/revoke-account', [EmployeeController::class, 'revokeAccount'])->name('v1.employees.revokeAccount'); - }); - - // Attendance API (근태 관리) - Route::prefix('attendances')->group(function () { - Route::get('', [AttendanceController::class, 'index'])->name('v1.attendances.index'); - Route::post('', [AttendanceController::class, 'store'])->name('v1.attendances.store'); - Route::get('/monthly-stats', [AttendanceController::class, 'monthlyStats'])->name('v1.attendances.monthlyStats'); - Route::get('/export', [AttendanceController::class, 'export'])->name('v1.attendances.export'); - Route::post('/check-in', [AttendanceController::class, 'checkIn'])->name('v1.attendances.checkIn'); - Route::post('/check-out', [AttendanceController::class, 'checkOut'])->name('v1.attendances.checkOut'); - Route::get('/{id}', [AttendanceController::class, 'show'])->name('v1.attendances.show'); - Route::patch('/{id}', [AttendanceController::class, 'update'])->name('v1.attendances.update'); - Route::delete('/{id}', [AttendanceController::class, 'destroy'])->name('v1.attendances.destroy'); - Route::post('/bulk-delete', [AttendanceController::class, 'bulkDelete'])->name('v1.attendances.bulkDelete'); - }); - - // Leave API (휴가 관리) - Route::prefix('leaves')->group(function () { - Route::get('', [LeaveController::class, 'index'])->name('v1.leaves.index'); - Route::post('', [LeaveController::class, 'store'])->name('v1.leaves.store'); - Route::get('/balances', [LeaveController::class, 'balances'])->name('v1.leaves.balances'); - Route::get('/balance', [LeaveController::class, 'balance'])->name('v1.leaves.balance'); - Route::get('/balance/{userId}', [LeaveController::class, 'userBalance'])->name('v1.leaves.userBalance'); - Route::put('/balance', [LeaveController::class, 'setBalance'])->name('v1.leaves.setBalance'); - Route::get('/grants', [LeaveController::class, 'grants'])->name('v1.leaves.grants'); - Route::post('/grants', [LeaveController::class, 'storeGrant'])->name('v1.leaves.grants.store'); - Route::delete('/grants/{id}', [LeaveController::class, 'destroyGrant'])->name('v1.leaves.grants.destroy'); - Route::get('/{id}', [LeaveController::class, 'show'])->name('v1.leaves.show'); - Route::patch('/{id}', [LeaveController::class, 'update'])->name('v1.leaves.update'); - Route::delete('/{id}', [LeaveController::class, 'destroy'])->name('v1.leaves.destroy'); - Route::post('/{id}/approve', [LeaveController::class, 'approve'])->name('v1.leaves.approve'); - Route::post('/{id}/reject', [LeaveController::class, 'reject'])->name('v1.leaves.reject'); - Route::post('/{id}/cancel', [LeaveController::class, 'cancel'])->name('v1.leaves.cancel'); - }); - - // Leave Policy API (휴가 정책) - Route::get('/leave-policy', [LeavePolicyController::class, 'show'])->name('v1.leave-policy.show'); - Route::put('/leave-policy', [LeavePolicyController::class, 'update'])->name('v1.leave-policy.update'); - - // Approval Form API (결재 양식) - Route::prefix('approval-forms')->group(function () { - Route::get('', [ApprovalFormController::class, 'index'])->name('v1.approval-forms.index'); - Route::post('', [ApprovalFormController::class, 'store'])->name('v1.approval-forms.store'); - Route::get('/active', [ApprovalFormController::class, 'active'])->name('v1.approval-forms.active'); - Route::get('/{id}', [ApprovalFormController::class, 'show'])->whereNumber('id')->name('v1.approval-forms.show'); - Route::patch('/{id}', [ApprovalFormController::class, 'update'])->whereNumber('id')->name('v1.approval-forms.update'); - Route::delete('/{id}', [ApprovalFormController::class, 'destroy'])->whereNumber('id')->name('v1.approval-forms.destroy'); - }); - - // Approval Line API (결재선) - Route::prefix('approval-lines')->group(function () { - Route::get('', [ApprovalLineController::class, 'index'])->name('v1.approval-lines.index'); - Route::post('', [ApprovalLineController::class, 'store'])->name('v1.approval-lines.store'); - Route::get('/{id}', [ApprovalLineController::class, 'show'])->whereNumber('id')->name('v1.approval-lines.show'); - Route::patch('/{id}', [ApprovalLineController::class, 'update'])->whereNumber('id')->name('v1.approval-lines.update'); - Route::delete('/{id}', [ApprovalLineController::class, 'destroy'])->whereNumber('id')->name('v1.approval-lines.destroy'); - }); - - // Approval API (전자결재) - Route::prefix('approvals')->group(function () { - // 기안함 - Route::get('/drafts', [ApprovalController::class, 'drafts'])->name('v1.approvals.drafts'); - Route::get('/drafts/summary', [ApprovalController::class, 'draftsSummary'])->name('v1.approvals.drafts.summary'); - // 결재함 - Route::get('/inbox', [ApprovalController::class, 'inbox'])->name('v1.approvals.inbox'); - Route::get('/inbox/summary', [ApprovalController::class, 'inboxSummary'])->name('v1.approvals.inbox.summary'); - // 참조함 - Route::get('/reference', [ApprovalController::class, 'reference'])->name('v1.approvals.reference'); - // CRUD - Route::post('', [ApprovalController::class, 'store'])->name('v1.approvals.store'); - Route::get('/{id}', [ApprovalController::class, 'show'])->whereNumber('id')->name('v1.approvals.show'); - Route::patch('/{id}', [ApprovalController::class, 'update'])->whereNumber('id')->name('v1.approvals.update'); - Route::delete('/{id}', [ApprovalController::class, 'destroy'])->whereNumber('id')->name('v1.approvals.destroy'); - // 액션 - Route::post('/{id}/submit', [ApprovalController::class, 'submit'])->whereNumber('id')->name('v1.approvals.submit'); - Route::post('/{id}/approve', [ApprovalController::class, 'approve'])->whereNumber('id')->name('v1.approvals.approve'); - Route::post('/{id}/reject', [ApprovalController::class, 'reject'])->whereNumber('id')->name('v1.approvals.reject'); - Route::post('/{id}/cancel', [ApprovalController::class, 'cancel'])->whereNumber('id')->name('v1.approvals.cancel'); - // 참조 열람 - Route::post('/{id}/read', [ApprovalController::class, 'markRead'])->whereNumber('id')->name('v1.approvals.read'); - Route::post('/{id}/unread', [ApprovalController::class, 'markUnread'])->whereNumber('id')->name('v1.approvals.unread'); - }); - - // Site API (현장 관리) - Route::prefix('sites')->group(function () { - Route::get('', [SiteController::class, 'index'])->name('v1.sites.index'); - Route::post('', [SiteController::class, 'store'])->name('v1.sites.store'); - Route::get('/stats', [SiteController::class, 'stats'])->name('v1.sites.stats'); - Route::get('/active', [SiteController::class, 'active'])->name('v1.sites.active'); - Route::delete('/bulk', [SiteController::class, 'bulkDestroy'])->name('v1.sites.bulk-destroy'); - Route::get('/{id}', [SiteController::class, 'show'])->whereNumber('id')->name('v1.sites.show'); - Route::put('/{id}', [SiteController::class, 'update'])->whereNumber('id')->name('v1.sites.update'); - Route::delete('/{id}', [SiteController::class, 'destroy'])->whereNumber('id')->name('v1.sites.destroy'); - }); - - // Site Briefing API (현장설명회 관리) - Route::prefix('site-briefings')->group(function () { - Route::get('', [SiteBriefingController::class, 'index'])->name('v1.site-briefings.index'); - Route::post('', [SiteBriefingController::class, 'store'])->name('v1.site-briefings.store'); - Route::get('/stats', [SiteBriefingController::class, 'stats'])->name('v1.site-briefings.stats'); - Route::delete('/bulk', [SiteBriefingController::class, 'bulkDestroy'])->name('v1.site-briefings.bulk-destroy'); - Route::get('/{id}', [SiteBriefingController::class, 'show'])->whereNumber('id')->name('v1.site-briefings.show'); - Route::put('/{id}', [SiteBriefingController::class, 'update'])->whereNumber('id')->name('v1.site-briefings.update'); - Route::delete('/{id}', [SiteBriefingController::class, 'destroy'])->whereNumber('id')->name('v1.site-briefings.destroy'); - }); - - // Construction API (시공관리) - Route::prefix('construction')->group(function () { - // Contract API (계약관리) - Route::prefix('contracts')->group(function () { - Route::get('', [ContractController::class, 'index'])->name('v1.construction.contracts.index'); - Route::post('', [ContractController::class, 'store'])->name('v1.construction.contracts.store'); - Route::get('/stats', [ContractController::class, 'stats'])->name('v1.construction.contracts.stats'); - Route::get('/stage-counts', [ContractController::class, 'stageCounts'])->name('v1.construction.contracts.stage-counts'); - Route::delete('/bulk', [ContractController::class, 'bulkDestroy'])->name('v1.construction.contracts.bulk-destroy'); - Route::post('/from-bidding/{biddingId}', [ContractController::class, 'storeFromBidding'])->whereNumber('biddingId')->name('v1.construction.contracts.store-from-bidding'); - Route::get('/{id}', [ContractController::class, 'show'])->whereNumber('id')->name('v1.construction.contracts.show'); - Route::put('/{id}', [ContractController::class, 'update'])->whereNumber('id')->name('v1.construction.contracts.update'); - Route::delete('/{id}', [ContractController::class, 'destroy'])->whereNumber('id')->name('v1.construction.contracts.destroy'); - }); - - // HandoverReport API (인수인계보고서관리) - Route::prefix('handover-reports')->group(function () { - Route::get('', [HandoverReportController::class, 'index'])->name('v1.construction.handover-reports.index'); - Route::post('', [HandoverReportController::class, 'store'])->name('v1.construction.handover-reports.store'); - Route::get('/stats', [HandoverReportController::class, 'stats'])->name('v1.construction.handover-reports.stats'); - Route::delete('/bulk', [HandoverReportController::class, 'bulkDestroy'])->name('v1.construction.handover-reports.bulk-destroy'); - Route::get('/{id}', [HandoverReportController::class, 'show'])->whereNumber('id')->name('v1.construction.handover-reports.show'); - Route::put('/{id}', [HandoverReportController::class, 'update'])->whereNumber('id')->name('v1.construction.handover-reports.update'); - Route::delete('/{id}', [HandoverReportController::class, 'destroy'])->whereNumber('id')->name('v1.construction.handover-reports.destroy'); - }); - - // StructureReview API (구조검토관리) - Route::prefix('structure-reviews')->group(function () { - Route::get('', [StructureReviewController::class, 'index'])->name('v1.construction.structure-reviews.index'); - Route::post('', [StructureReviewController::class, 'store'])->name('v1.construction.structure-reviews.store'); - Route::get('/stats', [StructureReviewController::class, 'stats'])->name('v1.construction.structure-reviews.stats'); - Route::delete('/bulk', [StructureReviewController::class, 'bulkDestroy'])->name('v1.construction.structure-reviews.bulk-destroy'); - Route::get('/{id}', [StructureReviewController::class, 'show'])->whereNumber('id')->name('v1.construction.structure-reviews.show'); - Route::put('/{id}', [StructureReviewController::class, 'update'])->whereNumber('id')->name('v1.construction.structure-reviews.update'); - Route::delete('/{id}', [StructureReviewController::class, 'destroy'])->whereNumber('id')->name('v1.construction.structure-reviews.destroy'); - }); - }); - - // Card API (카드 관리) - Route::prefix('cards')->group(function () { - Route::get('', [CardController::class, 'index'])->name('v1.cards.index'); - Route::post('', [CardController::class, 'store'])->name('v1.cards.store'); - Route::get('/active', [CardController::class, 'active'])->name('v1.cards.active'); - Route::get('/{id}', [CardController::class, 'show'])->whereNumber('id')->name('v1.cards.show'); - Route::put('/{id}', [CardController::class, 'update'])->whereNumber('id')->name('v1.cards.update'); - Route::delete('/{id}', [CardController::class, 'destroy'])->whereNumber('id')->name('v1.cards.destroy'); - Route::patch('/{id}/toggle', [CardController::class, 'toggle'])->whereNumber('id')->name('v1.cards.toggle'); - }); - - // BankAccount API (계좌 관리) - Route::prefix('bank-accounts')->group(function () { - Route::get('', [BankAccountController::class, 'index'])->name('v1.bank-accounts.index'); - Route::post('', [BankAccountController::class, 'store'])->name('v1.bank-accounts.store'); - Route::get('/active', [BankAccountController::class, 'active'])->name('v1.bank-accounts.active'); - Route::get('/{id}', [BankAccountController::class, 'show'])->whereNumber('id')->name('v1.bank-accounts.show'); - Route::put('/{id}', [BankAccountController::class, 'update'])->whereNumber('id')->name('v1.bank-accounts.update'); - Route::delete('/{id}', [BankAccountController::class, 'destroy'])->whereNumber('id')->name('v1.bank-accounts.destroy'); - Route::patch('/{id}/toggle', [BankAccountController::class, 'toggle'])->whereNumber('id')->name('v1.bank-accounts.toggle'); - Route::patch('/{id}/set-primary', [BankAccountController::class, 'setPrimary'])->whereNumber('id')->name('v1.bank-accounts.set-primary'); - }); - - // Deposit API (입금 관리) - Route::prefix('deposits')->group(function () { - Route::get('', [DepositController::class, 'index'])->name('v1.deposits.index'); - Route::post('', [DepositController::class, 'store'])->name('v1.deposits.store'); - Route::get('/summary', [DepositController::class, 'summary'])->name('v1.deposits.summary'); - Route::post('/bulk-update-account-code', [DepositController::class, 'bulkUpdateAccountCode'])->name('v1.deposits.bulk-update-account-code'); - Route::get('/{id}', [DepositController::class, 'show'])->whereNumber('id')->name('v1.deposits.show'); - Route::put('/{id}', [DepositController::class, 'update'])->whereNumber('id')->name('v1.deposits.update'); - Route::delete('/{id}', [DepositController::class, 'destroy'])->whereNumber('id')->name('v1.deposits.destroy'); - }); - - // Withdrawal API (출금 관리) - Route::prefix('withdrawals')->group(function () { - Route::get('', [WithdrawalController::class, 'index'])->name('v1.withdrawals.index'); - Route::post('', [WithdrawalController::class, 'store'])->name('v1.withdrawals.store'); - Route::get('/summary', [WithdrawalController::class, 'summary'])->name('v1.withdrawals.summary'); - Route::post('/bulk-update-account-code', [WithdrawalController::class, 'bulkUpdateAccountCode'])->name('v1.withdrawals.bulk-update-account-code'); - Route::get('/{id}', [WithdrawalController::class, 'show'])->whereNumber('id')->name('v1.withdrawals.show'); - Route::put('/{id}', [WithdrawalController::class, 'update'])->whereNumber('id')->name('v1.withdrawals.update'); - Route::delete('/{id}', [WithdrawalController::class, 'destroy'])->whereNumber('id')->name('v1.withdrawals.destroy'); - }); - - // Payroll API (급여 관리) - Route::prefix('payrolls')->group(function () { - Route::get('', [PayrollController::class, 'index'])->name('v1.payrolls.index'); - Route::post('', [PayrollController::class, 'store'])->name('v1.payrolls.store'); - Route::get('/summary', [PayrollController::class, 'summary'])->name('v1.payrolls.summary'); - Route::post('/calculate', [PayrollController::class, 'calculate'])->name('v1.payrolls.calculate'); - Route::post('/bulk-confirm', [PayrollController::class, 'bulkConfirm'])->name('v1.payrolls.bulk-confirm'); - Route::get('/{id}', [PayrollController::class, 'show'])->whereNumber('id')->name('v1.payrolls.show'); - Route::put('/{id}', [PayrollController::class, 'update'])->whereNumber('id')->name('v1.payrolls.update'); - Route::delete('/{id}', [PayrollController::class, 'destroy'])->whereNumber('id')->name('v1.payrolls.destroy'); - Route::post('/{id}/confirm', [PayrollController::class, 'confirm'])->whereNumber('id')->name('v1.payrolls.confirm'); - Route::post('/{id}/pay', [PayrollController::class, 'pay'])->whereNumber('id')->name('v1.payrolls.pay'); - Route::get('/{id}/payslip', [PayrollController::class, 'payslip'])->whereNumber('id')->name('v1.payrolls.payslip'); - }); - - // Salary API (급여 관리 - React 연동) - Route::prefix('salaries')->group(function () { - Route::get('', [SalaryController::class, 'index'])->name('v1.salaries.index'); - Route::post('', [SalaryController::class, 'store'])->name('v1.salaries.store'); - Route::get('/statistics', [SalaryController::class, 'statistics'])->name('v1.salaries.statistics'); - Route::get('/export', [SalaryController::class, 'export'])->name('v1.salaries.export'); - Route::post('/bulk-update-status', [SalaryController::class, 'bulkUpdateStatus'])->name('v1.salaries.bulk-update-status'); - Route::get('/{id}', [SalaryController::class, 'show'])->whereNumber('id')->name('v1.salaries.show'); - Route::put('/{id}', [SalaryController::class, 'update'])->whereNumber('id')->name('v1.salaries.update'); - Route::delete('/{id}', [SalaryController::class, 'destroy'])->whereNumber('id')->name('v1.salaries.destroy'); - Route::patch('/{id}/status', [SalaryController::class, 'updateStatus'])->whereNumber('id')->name('v1.salaries.update-status'); - }); - - // Expected Expense API (미지급비용 관리) - Route::prefix('expected-expenses')->group(function () { - Route::get('', [ExpectedExpenseController::class, 'index'])->name('v1.expected-expenses.index'); - Route::post('', [ExpectedExpenseController::class, 'store'])->name('v1.expected-expenses.store'); - Route::get('/summary', [ExpectedExpenseController::class, 'summary'])->name('v1.expected-expenses.summary'); - Route::get('/dashboard-detail', [ExpectedExpenseController::class, 'dashboardDetail'])->name('v1.expected-expenses.dashboard-detail'); - Route::delete('', [ExpectedExpenseController::class, 'destroyMany'])->name('v1.expected-expenses.destroy-many'); - Route::put('/update-payment-date', [ExpectedExpenseController::class, 'updateExpectedPaymentDate'])->name('v1.expected-expenses.update-payment-date'); - Route::get('/{id}', [ExpectedExpenseController::class, 'show'])->whereNumber('id')->name('v1.expected-expenses.show'); - Route::put('/{id}', [ExpectedExpenseController::class, 'update'])->whereNumber('id')->name('v1.expected-expenses.update'); - Route::delete('/{id}', [ExpectedExpenseController::class, 'destroy'])->whereNumber('id')->name('v1.expected-expenses.destroy'); - }); - - // Loan API (가지급금 관리) - Route::prefix('loans')->group(function () { - Route::get('', [LoanController::class, 'index'])->name('v1.loans.index'); - Route::post('', [LoanController::class, 'store'])->name('v1.loans.store'); - Route::get('/summary', [LoanController::class, 'summary'])->name('v1.loans.summary'); - Route::get('/dashboard', [LoanController::class, 'dashboard'])->name('v1.loans.dashboard'); - Route::get('/tax-simulation', [LoanController::class, 'taxSimulation'])->name('v1.loans.tax-simulation'); - Route::post('/calculate-interest', [LoanController::class, 'calculateInterest'])->name('v1.loans.calculate-interest'); - Route::get('/interest-report/{year}', [LoanController::class, 'interestReport'])->whereNumber('year')->name('v1.loans.interest-report'); - Route::get('/{id}', [LoanController::class, 'show'])->whereNumber('id')->name('v1.loans.show'); - Route::put('/{id}', [LoanController::class, 'update'])->whereNumber('id')->name('v1.loans.update'); - Route::delete('/{id}', [LoanController::class, 'destroy'])->whereNumber('id')->name('v1.loans.destroy'); - Route::post('/{id}/settle', [LoanController::class, 'settle'])->whereNumber('id')->name('v1.loans.settle'); - }); - - // Vendor Ledger API (거래처원장) - Route::prefix('vendor-ledger')->group(function () { - Route::get('', [VendorLedgerController::class, 'index'])->name('v1.vendor-ledger.index'); - Route::get('/summary', [VendorLedgerController::class, 'summary'])->name('v1.vendor-ledger.summary'); - Route::get('/{clientId}', [VendorLedgerController::class, 'show'])->whereNumber('clientId')->name('v1.vendor-ledger.show'); - }); - - // Card Transaction API (카드 거래) - Route::prefix('card-transactions')->group(function () { - Route::get('', [CardTransactionController::class, 'index'])->name('v1.card-transactions.index'); - Route::get('/summary', [CardTransactionController::class, 'summary'])->name('v1.card-transactions.summary'); - Route::get('/dashboard', [CardTransactionController::class, 'dashboard'])->name('v1.card-transactions.dashboard'); - Route::post('', [CardTransactionController::class, 'store'])->name('v1.card-transactions.store'); - Route::put('/bulk-update-account', [CardTransactionController::class, 'bulkUpdateAccountCode'])->name('v1.card-transactions.bulk-update-account'); - Route::get('/{id}', [CardTransactionController::class, 'show'])->whereNumber('id')->name('v1.card-transactions.show'); - Route::put('/{id}', [CardTransactionController::class, 'update'])->whereNumber('id')->name('v1.card-transactions.update'); - Route::delete('/{id}', [CardTransactionController::class, 'destroy'])->whereNumber('id')->name('v1.card-transactions.destroy'); - }); - - // Bank Transaction API (은행 거래 조회) - Route::prefix('bank-transactions')->group(function () { - Route::get('', [BankTransactionController::class, 'index'])->name('v1.bank-transactions.index'); - Route::get('/summary', [BankTransactionController::class, 'summary'])->name('v1.bank-transactions.summary'); - Route::get('/accounts', [BankTransactionController::class, 'accounts'])->name('v1.bank-transactions.accounts'); - }); - - // Receivables API (채권 현황) - Route::prefix('receivables')->group(function () { - Route::get('', [ReceivablesController::class, 'index'])->name('v1.receivables.index'); - Route::get('/summary', [ReceivablesController::class, 'summary'])->name('v1.receivables.summary'); - Route::put('/overdue-status', [ReceivablesController::class, 'updateOverdueStatus'])->name('v1.receivables.update-overdue-status'); - Route::put('/memos', [ReceivablesController::class, 'updateMemos'])->name('v1.receivables.update-memos'); - }); - - // Daily Report API (일일 보고서) - Route::prefix('daily-report')->group(function () { - Route::get('/note-receivables', [DailyReportController::class, 'noteReceivables'])->name('v1.daily-report.note-receivables'); - Route::get('/daily-accounts', [DailyReportController::class, 'dailyAccounts'])->name('v1.daily-report.daily-accounts'); - Route::get('/summary', [DailyReportController::class, 'summary'])->name('v1.daily-report.summary'); - }); - - // Comprehensive Analysis API (종합 분석 보고서) - Route::get('/comprehensive-analysis', [ComprehensiveAnalysisController::class, 'index'])->name('v1.comprehensive-analysis.index'); - - // Status Board API (CEO 대시보드 현황판) - Route::get('/status-board/summary', [StatusBoardController::class, 'summary'])->name('v1.status-board.summary'); - - // Today Issue API (CEO 대시보드 오늘의 이슈 리스트) - Route::get('/today-issues/summary', [TodayIssueController::class, 'summary'])->name('v1.today-issues.summary'); - Route::get('/today-issues/unread', [TodayIssueController::class, 'unread'])->name('v1.today-issues.unread'); - Route::get('/today-issues/unread/count', [TodayIssueController::class, 'unreadCount'])->name('v1.today-issues.unread.count'); - Route::post('/today-issues/{id}/read', [TodayIssueController::class, 'markAsRead'])->whereNumber('id')->name('v1.today-issues.read'); - Route::post('/today-issues/read-all', [TodayIssueController::class, 'markAllAsRead'])->name('v1.today-issues.read-all'); - - // Calendar API (CEO 대시보드 캘린더) - Route::get('/calendar/schedules', [CalendarController::class, 'summary'])->name('v1.calendar.schedules'); - - // Vat API (CEO 대시보드 부가세 현황) - Route::get('/vat/summary', [VatController::class, 'summary'])->name('v1.vat.summary'); - - // Entertainment API (CEO 대시보드 접대비 현황) - Route::get('/entertainment/summary', [EntertainmentController::class, 'summary'])->name('v1.entertainment.summary'); - - // Welfare API (CEO 대시보드 복리후생비 현황) - Route::get('/welfare/summary', [WelfareController::class, 'summary'])->name('v1.welfare.summary'); - Route::get('/welfare/detail', [WelfareController::class, 'detail'])->name('v1.welfare.detail'); - - // Plan API (요금제 관리) - Route::prefix('plans')->group(function () { - Route::get('', [PlanController::class, 'index'])->name('v1.plans.index'); - Route::post('', [PlanController::class, 'store'])->name('v1.plans.store'); - Route::get('/active', [PlanController::class, 'active'])->name('v1.plans.active'); - Route::get('/{id}', [PlanController::class, 'show'])->whereNumber('id')->name('v1.plans.show'); - Route::put('/{id}', [PlanController::class, 'update'])->whereNumber('id')->name('v1.plans.update'); - Route::delete('/{id}', [PlanController::class, 'destroy'])->whereNumber('id')->name('v1.plans.destroy'); - Route::patch('/{id}/toggle', [PlanController::class, 'toggle'])->whereNumber('id')->name('v1.plans.toggle'); - }); - - // Subscription API (구독 관리) - Route::prefix('subscriptions')->group(function () { - Route::get('', [SubscriptionController::class, 'index'])->name('v1.subscriptions.index'); - Route::post('', [SubscriptionController::class, 'store'])->name('v1.subscriptions.store'); - Route::get('/current', [SubscriptionController::class, 'current'])->name('v1.subscriptions.current'); - Route::get('/usage', [SubscriptionController::class, 'usage'])->name('v1.subscriptions.usage'); - Route::post('/export', [SubscriptionController::class, 'export'])->name('v1.subscriptions.export'); - Route::get('/export/{id}', [SubscriptionController::class, 'exportStatus'])->whereNumber('id')->name('v1.subscriptions.export.status'); - Route::get('/{id}', [SubscriptionController::class, 'show'])->whereNumber('id')->name('v1.subscriptions.show'); - Route::post('/{id}/cancel', [SubscriptionController::class, 'cancel'])->whereNumber('id')->name('v1.subscriptions.cancel'); - Route::post('/{id}/renew', [SubscriptionController::class, 'renew'])->whereNumber('id')->name('v1.subscriptions.renew'); - Route::post('/{id}/suspend', [SubscriptionController::class, 'suspend'])->whereNumber('id')->name('v1.subscriptions.suspend'); - Route::post('/{id}/resume', [SubscriptionController::class, 'resume'])->whereNumber('id')->name('v1.subscriptions.resume'); - }); - - // Payment API (결제 관리) - Route::prefix('payments')->group(function () { - Route::get('', [PaymentController::class, 'index'])->name('v1.payments.index'); - Route::post('', [PaymentController::class, 'store'])->name('v1.payments.store'); - Route::get('/summary', [PaymentController::class, 'summary'])->name('v1.payments.summary'); - Route::get('/{id}', [PaymentController::class, 'show'])->whereNumber('id')->name('v1.payments.show'); - Route::post('/{id}/complete', [PaymentController::class, 'complete'])->whereNumber('id')->name('v1.payments.complete'); - Route::post('/{id}/cancel', [PaymentController::class, 'cancel'])->whereNumber('id')->name('v1.payments.cancel'); - Route::post('/{id}/refund', [PaymentController::class, 'refund'])->whereNumber('id')->name('v1.payments.refund'); - Route::get('/{id}/statement', [PaymentController::class, 'statement'])->whereNumber('id')->name('v1.payments.statement'); - }); - - // Company API (회사 추가 관리) - Route::prefix('companies')->group(function () { - Route::post('/check', [CompanyController::class, 'check'])->name('v1.companies.check'); // 사업자등록번호 검증 - Route::post('/request', [CompanyController::class, 'request'])->name('v1.companies.request'); // 회사 추가 신청 - Route::get('/requests', [CompanyController::class, 'requests'])->name('v1.companies.requests.index'); // 신청 목록 (관리자) - Route::get('/requests/{id}', [CompanyController::class, 'showRequest'])->whereNumber('id')->name('v1.companies.requests.show'); // 신청 상세 - Route::post('/requests/{id}/approve', [CompanyController::class, 'approve'])->whereNumber('id')->name('v1.companies.requests.approve'); // 승인 - Route::post('/requests/{id}/reject', [CompanyController::class, 'reject'])->whereNumber('id')->name('v1.companies.requests.reject'); // 반려 - Route::get('/my-requests', [CompanyController::class, 'myRequests'])->name('v1.companies.my-requests'); // 내 신청 목록 - }); - - // Sale API (매출 관리) - Route::prefix('sales')->group(function () { - Route::get('', [SaleController::class, 'index'])->name('v1.sales.index'); - Route::post('', [SaleController::class, 'store'])->name('v1.sales.store'); - Route::get('/summary', [SaleController::class, 'summary'])->name('v1.sales.summary'); - Route::get('/{id}', [SaleController::class, 'show'])->whereNumber('id')->name('v1.sales.show'); - Route::put('/{id}', [SaleController::class, 'update'])->whereNumber('id')->name('v1.sales.update'); - Route::delete('/{id}', [SaleController::class, 'destroy'])->whereNumber('id')->name('v1.sales.destroy'); - Route::post('/{id}/confirm', [SaleController::class, 'confirm'])->whereNumber('id')->name('v1.sales.confirm'); - Route::put('/bulk-update-account', [SaleController::class, 'bulkUpdateAccountCode'])->name('v1.sales.bulk-update-account'); - // 거래명세서 API - Route::get('/{id}/statement', [SaleController::class, 'getStatement'])->whereNumber('id')->name('v1.sales.statement.show'); - Route::post('/{id}/statement/issue', [SaleController::class, 'issueStatement'])->whereNumber('id')->name('v1.sales.statement.issue'); - Route::post('/{id}/statement/send', [SaleController::class, 'sendStatement'])->whereNumber('id')->name('v1.sales.statement.send'); - Route::post('/bulk-issue-statement', [SaleController::class, 'bulkIssueStatement'])->name('v1.sales.bulk-issue-statement'); - }); - - // Purchase API (매입 관리) - Route::prefix('purchases')->group(function () { - Route::get('', [PurchaseController::class, 'index'])->name('v1.purchases.index'); - Route::post('', [PurchaseController::class, 'store'])->name('v1.purchases.store'); - Route::get('/summary', [PurchaseController::class, 'summary'])->name('v1.purchases.summary'); - Route::get('/dashboard-detail', [PurchaseController::class, 'dashboardDetail'])->name('v1.purchases.dashboard-detail'); - Route::post('/bulk-update-type', [PurchaseController::class, 'bulkUpdatePurchaseType'])->name('v1.purchases.bulk-update-type'); - Route::post('/bulk-update-tax-received', [PurchaseController::class, 'bulkUpdateTaxReceived'])->name('v1.purchases.bulk-update-tax-received'); - Route::get('/{id}', [PurchaseController::class, 'show'])->whereNumber('id')->name('v1.purchases.show'); - Route::put('/{id}', [PurchaseController::class, 'update'])->whereNumber('id')->name('v1.purchases.update'); - Route::delete('/{id}', [PurchaseController::class, 'destroy'])->whereNumber('id')->name('v1.purchases.destroy'); - Route::post('/{id}/confirm', [PurchaseController::class, 'confirm'])->whereNumber('id')->name('v1.purchases.confirm'); - }); - - // Receiving API (입고 관리) - Route::prefix('receivings')->group(function () { - Route::get('', [ReceivingController::class, 'index'])->name('v1.receivings.index'); - Route::post('', [ReceivingController::class, 'store'])->name('v1.receivings.store'); - Route::get('/stats', [ReceivingController::class, 'stats'])->name('v1.receivings.stats'); - Route::get('/{id}', [ReceivingController::class, 'show'])->whereNumber('id')->name('v1.receivings.show'); - Route::put('/{id}', [ReceivingController::class, 'update'])->whereNumber('id')->name('v1.receivings.update'); - Route::delete('/{id}', [ReceivingController::class, 'destroy'])->whereNumber('id')->name('v1.receivings.destroy'); - Route::post('/{id}/process', [ReceivingController::class, 'process'])->whereNumber('id')->name('v1.receivings.process'); - }); - - // Stock API (재고 현황) - Route::prefix('stocks')->group(function () { - Route::get('', [StockController::class, 'index'])->name('v1.stocks.index'); - Route::get('/stats', [StockController::class, 'stats'])->name('v1.stocks.stats'); - Route::get('/stats-by-type', [StockController::class, 'statsByItemType'])->name('v1.stocks.stats-by-type'); - Route::get('/{id}', [StockController::class, 'show'])->whereNumber('id')->name('v1.stocks.show'); - }); - - // Shipment API (출하 관리) - Route::prefix('shipments')->group(function () { - Route::get('', [ShipmentController::class, 'index'])->name('v1.shipments.index'); - Route::get('/stats', [ShipmentController::class, 'stats'])->name('v1.shipments.stats'); - Route::get('/stats-by-status', [ShipmentController::class, 'statsByStatus'])->name('v1.shipments.stats-by-status'); - Route::get('/options/lots', [ShipmentController::class, 'lotOptions'])->name('v1.shipments.options.lots'); - Route::get('/options/logistics', [ShipmentController::class, 'logisticsOptions'])->name('v1.shipments.options.logistics'); - Route::get('/options/vehicle-tonnage', [ShipmentController::class, 'vehicleTonnageOptions'])->name('v1.shipments.options.vehicle-tonnage'); - Route::post('', [ShipmentController::class, 'store'])->name('v1.shipments.store'); - Route::get('/{id}', [ShipmentController::class, 'show'])->whereNumber('id')->name('v1.shipments.show'); - Route::put('/{id}', [ShipmentController::class, 'update'])->whereNumber('id')->name('v1.shipments.update'); - Route::patch('/{id}/status', [ShipmentController::class, 'updateStatus'])->whereNumber('id')->name('v1.shipments.status'); - Route::delete('/{id}', [ShipmentController::class, 'destroy'])->whereNumber('id')->name('v1.shipments.destroy'); - }); - - // Barobill Setting API (바로빌 설정) - Route::prefix('barobill-settings')->group(function () { - Route::get('', [BarobillSettingController::class, 'show'])->name('v1.barobill-settings.show'); - Route::put('', [BarobillSettingController::class, 'save'])->name('v1.barobill-settings.save'); - Route::post('/test-connection', [BarobillSettingController::class, 'testConnection'])->name('v1.barobill-settings.test-connection'); - }); - - // Tax Invoice API (세금계산서) - Route::prefix('tax-invoices')->group(function () { - Route::get('', [TaxInvoiceController::class, 'index'])->name('v1.tax-invoices.index'); - Route::post('', [TaxInvoiceController::class, 'store'])->name('v1.tax-invoices.store'); - Route::get('/summary', [TaxInvoiceController::class, 'summary'])->name('v1.tax-invoices.summary'); - Route::get('/{id}', [TaxInvoiceController::class, 'show'])->whereNumber('id')->name('v1.tax-invoices.show'); - Route::put('/{id}', [TaxInvoiceController::class, 'update'])->whereNumber('id')->name('v1.tax-invoices.update'); - Route::delete('/{id}', [TaxInvoiceController::class, 'destroy'])->whereNumber('id')->name('v1.tax-invoices.destroy'); - Route::post('/{id}/issue', [TaxInvoiceController::class, 'issue'])->whereNumber('id')->name('v1.tax-invoices.issue'); - Route::post('/{id}/cancel', [TaxInvoiceController::class, 'cancel'])->whereNumber('id')->name('v1.tax-invoices.cancel'); - Route::get('/{id}/check-status', [TaxInvoiceController::class, 'checkStatus'])->whereNumber('id')->name('v1.tax-invoices.check-status'); - Route::post('/bulk-issue', [TaxInvoiceController::class, 'bulkIssue'])->name('v1.tax-invoices.bulk-issue'); - }); - - // Bad Debt API (악성채권 추심관리) - Route::prefix('bad-debts')->group(function () { - Route::get('', [BadDebtController::class, 'index'])->name('v1.bad-debts.index'); - Route::post('', [BadDebtController::class, 'store'])->name('v1.bad-debts.store'); - Route::get('/summary', [BadDebtController::class, 'summary'])->name('v1.bad-debts.summary'); - Route::get('/{id}', [BadDebtController::class, 'show'])->whereNumber('id')->name('v1.bad-debts.show'); - Route::put('/{id}', [BadDebtController::class, 'update'])->whereNumber('id')->name('v1.bad-debts.update'); - Route::delete('/{id}', [BadDebtController::class, 'destroy'])->whereNumber('id')->name('v1.bad-debts.destroy'); - Route::patch('/{id}/toggle', [BadDebtController::class, 'toggle'])->whereNumber('id')->name('v1.bad-debts.toggle'); - // 서류 - Route::post('/{id}/documents', [BadDebtController::class, 'addDocument'])->whereNumber('id')->name('v1.bad-debts.documents.store'); - Route::delete('/{id}/documents/{documentId}', [BadDebtController::class, 'removeDocument'])->whereNumber(['id', 'documentId'])->name('v1.bad-debts.documents.destroy'); - // 메모 - Route::post('/{id}/memos', [BadDebtController::class, 'addMemo'])->whereNumber('id')->name('v1.bad-debts.memos.store'); - Route::delete('/{id}/memos/{memoId}', [BadDebtController::class, 'removeMemo'])->whereNumber(['id', 'memoId'])->name('v1.bad-debts.memos.destroy'); - }); - - // Bill API (어음관리) - Route::prefix('bills')->group(function () { - Route::get('', [BillController::class, 'index'])->name('v1.bills.index'); - Route::post('', [BillController::class, 'store'])->name('v1.bills.store'); - Route::get('/summary', [BillController::class, 'summary'])->name('v1.bills.summary'); - Route::get('/dashboard-detail', [BillController::class, 'dashboardDetail'])->name('v1.bills.dashboard-detail'); - Route::get('/{id}', [BillController::class, 'show'])->whereNumber('id')->name('v1.bills.show'); - Route::put('/{id}', [BillController::class, 'update'])->whereNumber('id')->name('v1.bills.update'); - Route::delete('/{id}', [BillController::class, 'destroy'])->whereNumber('id')->name('v1.bills.destroy'); - Route::patch('/{id}/status', [BillController::class, 'updateStatus'])->whereNumber('id')->name('v1.bills.update-status'); - }); - - // Popup API (팝업관리) - Route::prefix('popups')->group(function () { - Route::get('', [PopupController::class, 'index'])->name('v1.popups.index'); - Route::post('', [PopupController::class, 'store'])->name('v1.popups.store'); - Route::get('/active', [PopupController::class, 'active'])->name('v1.popups.active'); - Route::get('/{id}', [PopupController::class, 'show'])->whereNumber('id')->name('v1.popups.show'); - Route::put('/{id}', [PopupController::class, 'update'])->whereNumber('id')->name('v1.popups.update'); - Route::delete('/{id}', [PopupController::class, 'destroy'])->whereNumber('id')->name('v1.popups.destroy'); - }); - - // Report API (보고서) - Route::prefix('reports')->group(function () { - Route::get('/daily', [ReportController::class, 'daily'])->name('v1.reports.daily'); - Route::get('/daily/export', [ReportController::class, 'dailyExport'])->name('v1.reports.daily.export'); - Route::get('/expense-estimate', [ReportController::class, 'expenseEstimate'])->name('v1.reports.expense-estimate'); - Route::get('/expense-estimate/export', [ReportController::class, 'expenseEstimateExport'])->name('v1.reports.expense-estimate.export'); - - // AI Report API (AI 리포트) - Route::get('/ai', [AiReportController::class, 'index'])->name('v1.reports.ai.index'); - Route::post('/ai/generate', [AiReportController::class, 'generate'])->name('v1.reports.ai.generate'); - Route::get('/ai/{id}', [AiReportController::class, 'show'])->whereNumber('id')->name('v1.reports.ai.show'); - Route::delete('/ai/{id}', [AiReportController::class, 'destroy'])->whereNumber('id')->name('v1.reports.ai.destroy'); - }); - - // Dashboard API (대시보드) - Route::prefix('dashboard')->group(function () { - Route::get('/summary', [DashboardController::class, 'summary'])->name('v1.dashboard.summary'); - Route::get('/charts', [DashboardController::class, 'charts'])->name('v1.dashboard.charts'); - Route::get('/approvals', [DashboardController::class, 'approvals'])->name('v1.dashboard.approvals'); - }); - - // Permission API - Route::prefix('permissions')->group(function () { - Route::get('departments/{dept_id}/menu-matrix', [PermissionController::class, 'deptMenuMatrix'])->name('v1.permissions.deptMenuMatrix'); // 부서별 권한 메트릭스 - Route::get('roles/{role_id}/menu-matrix', [PermissionController::class, 'roleMenuMatrix'])->name('v1.permissions.roleMenuMatrix'); // 부서별 권한 메트릭스 - Route::get('users/{user_id}/menu-matrix', [PermissionController::class, 'userMenuMatrix'])->name('v1.permissions.userMenuMatrix'); // 부서별 권한 메트릭스 - }); - - // Settings & Configuration (설정 및 환경설정 통합 관리) - Route::prefix('settings')->group(function () { - - // 근무 설정 - Route::get('/work', [WorkSettingController::class, 'showWorkSetting'])->name('v1.settings.work.show'); - Route::put('/work', [WorkSettingController::class, 'updateWorkSetting'])->name('v1.settings.work.update'); - - // 출퇴근 설정 - Route::get('/attendance', [WorkSettingController::class, 'showAttendanceSetting'])->name('v1.settings.attendance.show'); - Route::put('/attendance', [WorkSettingController::class, 'updateAttendanceSetting'])->name('v1.settings.attendance.update'); - - // 급여 설정 - Route::get('/payroll', [PayrollController::class, 'getSettings'])->name('v1.settings.payroll.show'); - Route::put('/payroll', [PayrollController::class, 'updateSettings'])->name('v1.settings.payroll.update'); - - // 테넌트 필드 설정 (기존 fields에서 이동) - Route::get('/fields', [TenantFieldSettingController::class, 'index'])->name('v1.settings.fields.index'); // 필드 설정 목록(전역+테넌트 병합 효과값) - Route::put('/fields/bulk', [TenantFieldSettingController::class, 'bulkUpsert'])->name('v1.settings.fields.bulk'); // 필드 설정 대량 저장(트랜잭션 처리) - Route::patch('/fields/{key}', [TenantFieldSettingController::class, 'updateOne'])->name('v1.settings.fields.update'); // 필드 설정 단건 수정/업데이트 - - // 옵션 그룹/값 (기존 opt-groups에서 이동) - Route::get('/options', [TenantOptionGroupController::class, 'index'])->name('v1.settings.options.index'); // 옵션 그룹 목록 - Route::post('/options', [TenantOptionGroupController::class, 'store'])->name('v1.settings.options.store'); // 옵션 그룹 생성 - Route::get('/options/{id}', [TenantOptionGroupController::class, 'show'])->name('v1.settings.options.show'); // 옵션 그룹 단건 조회 - Route::patch('/options/{id}', [TenantOptionGroupController::class, 'update'])->name('v1.settings.options.update'); // 옵션 그룹 수정 - Route::delete('/options/{id}', [TenantOptionGroupController::class, 'destroy'])->name('v1.settings.options.destroy'); // 옵션 그룹 삭제 - Route::get('/options/{gid}/values', [TenantOptionValueController::class, 'index'])->name('v1.settings.options.values.index'); // 옵션 값 목록 - Route::post('/options/{gid}/values', [TenantOptionValueController::class, 'store'])->name('v1.settings.options.values.store'); // 옵션 값 생성 - Route::get('/options/{gid}/values/{id}', [TenantOptionValueController::class, 'show'])->name('v1.settings.options.values.show'); // 옵션 값 단건 조회 - Route::patch('/options/{gid}/values/{id}', [TenantOptionValueController::class, 'update'])->name('v1.settings.options.values.update'); // 옵션 값 수정 - Route::delete('/options/{gid}/values/{id}', [TenantOptionValueController::class, 'destroy'])->name('v1.settings.options.values.destroy'); // 옵션 값 삭제 - Route::patch('/options/{gid}/values/reorder', [TenantOptionValueController::class, 'reorder'])->name('v1.settings.options.values.reorder'); // 옵션 값 정렬순서 재배치 - - // 공통 코드 관리 (기존 common에서 이동) - Route::get('/common/code', [CommonController::class, 'getComeCode'])->name('v1.settings.common.code'); // 공통코드 조회 (기존 v1.common.code에서 이동) - Route::get('/common', [CommonController::class, 'list'])->name('v1.settings.common.list'); // 공통 코드 목록 - Route::get('/common/{group}', [CommonController::class, 'index'])->name('v1.settings.common.index'); // 특정 그룹 코드 목록 - Route::post('/common', [CommonController::class, 'store'])->name('v1.settings.common.store'); // 공통 코드 생성 - Route::patch('/common/{id}', [CommonController::class, 'update'])->name('v1.settings.common.update'); // 공통 코드 수정 - Route::delete('/common/{id}', [CommonController::class, 'destroy'])->name('v1.settings.common.destroy'); // 공통 코드 삭제 - - // 알림 설정 (그룹 기반, React 호환) - auth:sanctum 필수 - Route::middleware('auth:sanctum')->group(function () { - Route::get('/notifications', [NotificationSettingController::class, 'indexGrouped'])->name('v1.settings.notifications.index'); // 알림 설정 조회 (그룹 기반) - Route::put('/notifications', [NotificationSettingController::class, 'updateGrouped'])->name('v1.settings.notifications.update'); // 알림 설정 수정 (그룹 기반) - }); - }); - - // Push Notification API (FCM 푸시 알림) - auth:sanctum 필수 (tenantId, apiUserId 필요) - Route::prefix('push')->middleware('auth:sanctum')->group(function () { - Route::post('/register-token', [PushNotificationController::class, 'registerToken'])->name('v1.push.register-token'); // FCM 토큰 등록 - Route::post('/unregister-token', [PushNotificationController::class, 'unregisterToken'])->name('v1.push.unregister-token'); // FCM 토큰 해제 - Route::get('/tokens', [PushNotificationController::class, 'getTokens'])->name('v1.push.tokens'); // 등록된 토큰 목록 - Route::get('/settings', [PushNotificationController::class, 'getSettings'])->name('v1.push.settings'); // 알림 설정 조회 - Route::put('/settings', [PushNotificationController::class, 'updateSettings'])->name('v1.push.settings.update'); // 알림 설정 수정 - Route::get('/notification-types', [PushNotificationController::class, 'getNotificationTypes'])->name('v1.push.notification-types'); // 알림 유형 목록 - }); - - // Admin FCM API (MNG 관리자용 FCM 발송) - API Key 인증만 사용 - Route::prefix('admin/fcm')->group(function () { - Route::post('/send', [FcmController::class, 'send'])->name('v1.admin.fcm.send'); // FCM 발송 - Route::get('/preview-count', [FcmController::class, 'previewCount'])->name('v1.admin.fcm.preview'); // 대상 토큰 수 미리보기 - Route::get('/tokens', [FcmController::class, 'tokens'])->name('v1.admin.fcm.tokens'); // 토큰 목록 - Route::get('/tokens/stats', [FcmController::class, 'tokenStats'])->name('v1.admin.fcm.tokens.stats'); // 토큰 통계 - Route::patch('/tokens/{id}/toggle', [FcmController::class, 'toggleToken'])->name('v1.admin.fcm.tokens.toggle'); // 토큰 상태 토글 - Route::delete('/tokens/{id}', [FcmController::class, 'deleteToken'])->name('v1.admin.fcm.tokens.delete'); // 토큰 삭제 - Route::get('/history', [FcmController::class, 'history'])->name('v1.admin.fcm.history'); // 발송 이력 - }); - - // 회원 프로필(테넌트 기준) - Route::prefix('profiles')->group(function () { - Route::get('', [TenantUserProfileController::class, 'index'])->name('v1.profiles.index'); // 프로필 목록(테넌트 기준) - // /me 라우트는 /{userId} 와일드카드보다 먼저 정의해야 함 - // auth:sanctum 미들웨어로 Bearer 토큰 인증 필요 - Route::middleware('auth:sanctum')->group(function () { - Route::get('/me', [TenantUserProfileController::class, 'me'])->name('v1.profiles.me'); // 내 프로필 조회 - Route::patch('/me', [TenantUserProfileController::class, 'updateMe'])->name('v1.profiles.me.update'); // 내 프로필 수정 - }); - Route::get('/{userId}', [TenantUserProfileController::class, 'show'])->name('v1.profiles.show'); // 특정 사용자 프로필 조회 - Route::patch('/{userId}', [TenantUserProfileController::class, 'update'])->name('v1.profiles.update'); // 특정 사용자 프로필 수정(관리자) - }); - - // Category API (통합) - Route::prefix('categories')->group(function () { - - // === 확장 기능 (와일드카드 라우트보다 먼저 정의) === - Route::get('/tree', [CategoryController::class, 'tree'])->name('v1.categories.tree'); // 트리 - Route::post('/reorder', [CategoryController::class, 'reorder'])->name('v1.categories.reorder'); // 정렬 일괄 - - // === 기본 Category CRUD === - Route::get('', [CategoryController::class, 'index'])->name('v1.categories.index'); // 목록(페이징) - Route::post('', [CategoryController::class, 'store'])->name('v1.categories.store'); // 생성 - Route::get('/{id}', [CategoryController::class, 'show'])->name('v1.categories.show'); // 단건 - Route::patch('/{id}', [CategoryController::class, 'update'])->name('v1.categories.update'); // 수정 - Route::delete('/{id}', [CategoryController::class, 'destroy'])->name('v1.categories.destroy'); // 삭제(soft) - Route::post('/{id}/toggle', [CategoryController::class, 'toggle'])->name('v1.categories.toggle'); // 활성 토글 - Route::patch('/{id}/move', [CategoryController::class, 'move'])->name('v1.categories.move'); // 부모/순서 이동 - - // === Category Fields === - // 목록/생성 (카테고리 기준) - Route::get('/{id}/fields', [CategoryFieldController::class, 'index'])->name('v1.categories.fields.index'); // ?page&size&sort&order - Route::post('/{id}/fields', [CategoryFieldController::class, 'store'])->name('v1.categories.fields.store'); - // 단건 - Route::get('/fields/{field}', [CategoryFieldController::class, 'show'])->name('v1.categories.fields.show'); - Route::patch('/fields/{field}', [CategoryFieldController::class, 'update'])->name('v1.categories.fields.update'); - Route::delete('/fields/{field}', [CategoryFieldController::class, 'destroy'])->name('v1.categories.fields.destroy'); - // 일괄 정렬/업서트 - Route::post('/{id}/fields/reorder', [CategoryFieldController::class, 'reorder'])->name('v1.categories.fields.reorder'); // [{id,sort_order}] - Route::put('/{id}/fields/bulk-upsert', [CategoryFieldController::class, 'bulkUpsert'])->name('v1.categories.fields.bulk'); // [{id?,field_key,...}] - - // === Category Templates === - // 버전 목록/생성 (카테고리 기준) - Route::get('/{id}/templates', [CategoryTemplateController::class, 'index'])->name('v1.categories.templates.index'); // ?page&size - Route::post('/{id}/templates', [CategoryTemplateController::class, 'store'])->name('v1.categories.templates.store'); // 새 버전 등록 - // 단건 - Route::get('/templates/{tpl}', [CategoryTemplateController::class, 'show'])->name('v1.categories.templates.show'); - Route::patch('/templates/{tpl}', [CategoryTemplateController::class, 'update'])->name('v1.categories.templates.update'); // remarks 등 메타 수정 - Route::delete('/templates/{tpl}', [CategoryTemplateController::class, 'destroy'])->name('v1.categories.templates.destroy'); - // 운영 편의 - Route::post('/{id}/templates/{tpl}/apply', [CategoryTemplateController::class, 'apply'])->name('v1.categories.templates.apply'); // 해당 버전 활성화 - Route::get('/{id}/templates/{tpl}/preview', [CategoryTemplateController::class, 'preview'])->name('v1.categories.templates.preview'); // 렌더용 스냅샷 - // (선택) 버전 간 diff - Route::get('/{id}/templates/diff', [CategoryTemplateController::class, 'diff'])->name('v1.categories.templates.diff'); // ?a=ver&b=ver - - // === Category Logs === - Route::get('/{id}/logs', [CategoryLogController::class, 'index'])->name('v1.categories.logs.index'); // ?action=&from=&to=&page&size - Route::get('/logs/{log}', [CategoryLogController::class, 'show'])->name('v1.categories.logs.show'); - // (선택) 특정 변경 시점으로 카테고리 복구(템플릿/필드와 별개) - // Route::post('{id}/logs/{log}/restore', [CategoryLogController::class, 'restore'])->name('v1.categories.logs.restore'); - }); - - // Classifications API - Route::prefix('classifications')->group(function () { - Route::get('', [ClassificationController::class, 'index'])->name('v1.classifications.index'); // 목록 - Route::post('', [ClassificationController::class, 'store'])->name('v1.classifications.store'); // 생성 - Route::get('/{id}', [ClassificationController::class, 'show'])->whereNumber('id')->name('v1.classifications.show'); // 단건 - Route::patch('/{id}', [ClassificationController::class, 'update'])->whereNumber('id')->name('v1.classifications.update'); // 수정 - Route::delete('/{id}', [ClassificationController::class, 'destroy'])->whereNumber('id')->name('v1.classifications.destroy'); // 삭제 - }); - - // Clients (거래처 관리) - Route::prefix('clients')->group(function () { - Route::get('/stats', [ClientController::class, 'stats'])->name('v1.clients.stats'); // 통계 - Route::delete('/bulk', [ClientController::class, 'bulkDestroy'])->name('v1.clients.bulk-destroy'); // 일괄 삭제 - Route::get('', [ClientController::class, 'index'])->name('v1.clients.index'); // 목록 - Route::post('', [ClientController::class, 'store'])->name('v1.clients.store'); // 생성 - Route::get('/{id}', [ClientController::class, 'show'])->whereNumber('id')->name('v1.clients.show'); // 단건 - Route::put('/{id}', [ClientController::class, 'update'])->whereNumber('id')->name('v1.clients.update'); // 수정 - Route::delete('/{id}', [ClientController::class, 'destroy'])->whereNumber('id')->name('v1.clients.destroy'); // 삭제 - Route::patch('/{id}/toggle', [ClientController::class, 'toggle'])->whereNumber('id')->name('v1.clients.toggle'); // 활성/비활성 - }); - - // Client Groups (고객 그룹 관리) - Route::prefix('client-groups')->group(function () { - Route::get('', [ClientGroupController::class, 'index'])->name('v1.client-groups.index'); // 목록 - Route::post('', [ClientGroupController::class, 'store'])->name('v1.client-groups.store'); // 생성 - Route::get('/{id}', [ClientGroupController::class, 'show'])->whereNumber('id')->name('v1.client-groups.show'); // 단건 - Route::put('/{id}', [ClientGroupController::class, 'update'])->whereNumber('id')->name('v1.client-groups.update'); // 수정 - Route::delete('/{id}', [ClientGroupController::class, 'destroy'])->whereNumber('id')->name('v1.client-groups.destroy'); // 삭제 - Route::patch('/{id}/toggle', [ClientGroupController::class, 'toggle'])->whereNumber('id')->name('v1.client-groups.toggle'); // 활성/비활성 - }); - - // Quotes (견적 관리) - Route::prefix('quotes')->group(function () { - // 기본 CRUD - Route::get('', [QuoteController::class, 'index'])->name('v1.quotes.index'); // 목록 - Route::post('', [QuoteController::class, 'store'])->name('v1.quotes.store'); // 생성 - Route::get('/{id}', [QuoteController::class, 'show'])->whereNumber('id')->name('v1.quotes.show'); // 단건 - Route::put('/{id}', [QuoteController::class, 'update'])->whereNumber('id')->name('v1.quotes.update'); // 수정 - Route::delete('/{id}', [QuoteController::class, 'destroy'])->whereNumber('id')->name('v1.quotes.destroy'); // 삭제 - - // 일괄 삭제 - Route::delete('/bulk', [QuoteController::class, 'bulkDestroy'])->name('v1.quotes.bulk-destroy'); // 일괄 삭제 - - // 상태 관리 - Route::post('/{id}/finalize', [QuoteController::class, 'finalize'])->whereNumber('id')->name('v1.quotes.finalize'); // 확정 - Route::post('/{id}/cancel-finalize', [QuoteController::class, 'cancelFinalize'])->whereNumber('id')->name('v1.quotes.cancel-finalize'); // 확정 취소 - Route::post('/{id}/convert', [QuoteController::class, 'convertToOrder'])->whereNumber('id')->name('v1.quotes.convert'); // 수주 전환 - - // 견적번호 미리보기 - Route::get('/number/preview', [QuoteController::class, 'previewNumber'])->name('v1.quotes.number-preview'); // 번호 미리보기 - - // 자동산출 - Route::get('/calculation/schema', [QuoteController::class, 'calculationSchema'])->name('v1.quotes.calculation-schema'); // 입력 스키마 - Route::post('/calculate', [QuoteController::class, 'calculate'])->name('v1.quotes.calculate'); // 자동산출 실행 - Route::post('/calculate/bom', [QuoteController::class, 'calculateBom'])->name('v1.quotes.calculate-bom'); // BOM 기반 자동산출 - Route::post('/calculate/bom/bulk', [QuoteController::class, 'calculateBomBulk'])->name('v1.quotes.calculate-bom-bulk'); // 다건 BOM 자동산출 - - // 문서 관리 - Route::post('/{id}/pdf', [QuoteController::class, 'generatePdf'])->whereNumber('id')->name('v1.quotes.pdf'); // PDF 생성 - Route::post('/{id}/send/email', [QuoteController::class, 'sendEmail'])->whereNumber('id')->name('v1.quotes.send-email'); // 이메일 발송 - Route::post('/{id}/send/kakao', [QuoteController::class, 'sendKakao'])->whereNumber('id')->name('v1.quotes.send-kakao'); // 카카오톡 발송 - Route::get('/{id}/send/history', [QuoteController::class, 'sendHistory'])->whereNumber('id')->name('v1.quotes.send-history'); // 발송 이력 - - // 입찰 전환 - Route::post('/{id}/convert-to-bidding', [QuoteController::class, 'convertToBidding'])->whereNumber('id')->name('v1.quotes.convert-to-bidding'); // 입찰 전환 - }); - - // Biddings (입찰관리) - Route::prefix('biddings')->group(function () { - Route::get('', [BiddingController::class, 'index'])->name('v1.biddings.index'); // 목록 - Route::post('', [BiddingController::class, 'store'])->name('v1.biddings.store'); // 생성 - Route::get('/stats', [BiddingController::class, 'stats'])->name('v1.biddings.stats'); // 통계 - Route::delete('/bulk', [BiddingController::class, 'bulkDestroy'])->name('v1.biddings.bulk-destroy'); // 일괄 삭제 - Route::get('/{id}', [BiddingController::class, 'show'])->whereNumber('id')->name('v1.biddings.show'); // 단건 - Route::put('/{id}', [BiddingController::class, 'update'])->whereNumber('id')->name('v1.biddings.update'); // 수정 - Route::delete('/{id}', [BiddingController::class, 'destroy'])->whereNumber('id')->name('v1.biddings.destroy'); // 삭제 - Route::patch('/{id}/status', [BiddingController::class, 'updateStatus'])->whereNumber('id')->name('v1.biddings.status'); // 상태 변경 - }); - - // Pricing (단가 관리) - Route::prefix('pricing')->group(function () { - Route::get('', [PricingController::class, 'index'])->name('v1.pricing.index'); // 목록 - Route::get('/stats', [PricingController::class, 'stats'])->name('v1.pricing.stats'); // 통계 - Route::get('/cost', [PricingController::class, 'cost'])->name('v1.pricing.cost'); // 원가 조회 - Route::post('/by-items', [PricingController::class, 'byItems'])->name('v1.pricing.by-items'); // 품목별 단가 현황 - Route::delete('/bulk', [PricingController::class, 'bulkDestroy'])->name('v1.pricing.bulk-destroy'); // 일괄 삭제 - Route::post('', [PricingController::class, 'store'])->name('v1.pricing.store'); // 등록 - Route::get('/{id}', [PricingController::class, 'show'])->whereNumber('id')->name('v1.pricing.show'); // 상세 - Route::put('/{id}', [PricingController::class, 'update'])->whereNumber('id')->name('v1.pricing.update'); // 수정 - Route::delete('/{id}', [PricingController::class, 'destroy'])->whereNumber('id')->name('v1.pricing.destroy'); // 삭제 - Route::post('/{id}/finalize', [PricingController::class, 'finalize'])->whereNumber('id')->name('v1.pricing.finalize'); // 확정 - Route::get('/{id}/revisions', [PricingController::class, 'revisions'])->whereNumber('id')->name('v1.pricing.revisions'); // 변경이력 - }); - - // Labor (노임관리) - Route::prefix('labor')->group(function () { - Route::get('', [LaborController::class, 'index'])->name('v1.labor.index'); // 목록 - Route::get('/stats', [LaborController::class, 'stats'])->name('v1.labor.stats'); // 통계 - Route::delete('/bulk', [LaborController::class, 'bulkDestroy'])->name('v1.labor.bulk-destroy'); // 일괄 삭제 - Route::post('', [LaborController::class, 'store'])->name('v1.labor.store'); // 등록 - Route::get('/{id}', [LaborController::class, 'show'])->whereNumber('id')->name('v1.labor.show'); // 상세 - Route::put('/{id}', [LaborController::class, 'update'])->whereNumber('id')->name('v1.labor.update'); // 수정 - Route::delete('/{id}', [LaborController::class, 'destroy'])->whereNumber('id')->name('v1.labor.destroy'); // 삭제 - }); - - // REMOVED: Products & Materials 라우트 삭제됨 (products/materials 테이블 삭제) - // 모든 품목 관리는 /items 엔드포인트 사용 - - // Items (통합 품목 관리 - items 테이블) - Route::prefix('items')->group(function () { - Route::get('', [ItemsController::class, 'index'])->name('v1.items.index'); // 통합 목록 - Route::get('/stats', [ItemsController::class, 'stats'])->name('v1.items.stats'); // 통계 - Route::post('', [ItemsController::class, 'store'])->name('v1.items.store'); // 품목 생성 - Route::get('/code/{code}', [ItemsController::class, 'showByCode'])->name('v1.items.show_by_code'); // code 기반 조회 - Route::get('/{id}', [ItemsController::class, 'show'])->name('v1.items.show'); // 단건 (item_type 파라미터 필수) - Route::put('/{id}', [ItemsController::class, 'update'])->name('v1.items.update'); // 품목 수정 - Route::delete('/batch', [ItemsController::class, 'batchDestroy'])->name('v1.items.batch_destroy'); // 품목 일괄 삭제 - Route::delete('/{id}', [ItemsController::class, 'destroy'])->name('v1.items.destroy'); // 품목 삭제 - }); - - // Items BOM - 전체 BOM 목록 (item_id 없이) - // 주의: /items/{id}/bom 보다 먼저 정의해야 함 ('bom'이 {id}로 인식되지 않도록) - Route::get('items/bom', [ItemsBomController::class, 'listAll'])->name('v1.items.bom.list-all'); - - // Items BOM (ID-based BOM API) - Route::prefix('items/{id}/bom')->group(function () { - Route::get('', [ItemsBomController::class, 'index'])->name('v1.items.bom.index'); // BOM 목록 (flat) - Route::get('/tree', [ItemsBomController::class, 'tree'])->name('v1.items.bom.tree'); // BOM 트리 (계층) - Route::post('', [ItemsBomController::class, 'store'])->name('v1.items.bom.store'); // BOM 추가 (bulk) - Route::put('/{lineId}', [ItemsBomController::class, 'update'])->name('v1.items.bom.update'); // BOM 수정 - Route::delete('/{lineId}', [ItemsBomController::class, 'destroy'])->name('v1.items.bom.destroy'); // BOM 삭제 - Route::get('/summary', [ItemsBomController::class, 'summary'])->name('v1.items.bom.summary'); // BOM 요약 - Route::get('/validate', [ItemsBomController::class, 'validate'])->name('v1.items.bom.validate'); // BOM 검증 - Route::post('/replace', [ItemsBomController::class, 'replace'])->name('v1.items.bom.replace'); // BOM 전체 교체 - Route::post('/reorder', [ItemsBomController::class, 'reorder'])->name('v1.items.bom.reorder'); // BOM 정렬 - Route::get('/categories', [ItemsBomController::class, 'listCategories'])->name('v1.items.bom.categories'); // 카테고리 목록 - }); - - // Items Files (group_id 기반 파일 관리, 동적 field_key 지원) - Route::prefix('items/{id}/files')->group(function () { - Route::get('', [ItemsFileController::class, 'index'])->name('v1.items.files.index'); // 파일 조회 (field_key별 그룹핑) - Route::post('', [ItemsFileController::class, 'upload'])->name('v1.items.files.upload'); // 파일 업로드 (field_key 동적) - Route::delete('/{fileId}', [ItemsFileController::class, 'delete'])->name('v1.items.files.delete'); // 파일 삭제 (file_id) - }); - - // REMOVED: products/{id}/bom 라우트 삭제됨 (product_components 테이블 삭제) - // BOM 관리는 /items/{id}/bom 엔드포인트 사용 - - // 설계 전용 (Design) - 운영과 분리된 네임스페이스/경로 - Route::prefix('design')->group(function () { - Route::get('/models', [DesignModelController::class, 'index'])->name('v1.design.models.index'); - Route::post('/models', [DesignModelController::class, 'store'])->name('v1.design.models.store'); - Route::get('/models/{id}', [DesignModelController::class, 'show'])->name('v1.design.models.show'); - Route::put('/models/{id}', [DesignModelController::class, 'update'])->name('v1.design.models.update'); - Route::delete('/models/{id}', [DesignModelController::class, 'destroy'])->name('v1.design.models.destroy'); - - Route::get('/models/{modelId}/versions', [DesignModelVersionController::class, 'index'])->name('v1.design.models.versions.index'); - Route::post('/models/{modelId}/versions', [DesignModelVersionController::class, 'createDraft'])->name('v1.design.models.versions.store'); - Route::post('/versions/{versionId}/release', [DesignModelVersionController::class, 'release'])->name('v1.design.versions.release'); - - Route::get('/versions/{versionId}/bom-templates', [DesignBomTemplateController::class, 'listByVersion'])->name('v1.design.bom.templates.index'); - Route::post('/versions/{versionId}/bom-templates', [DesignBomTemplateController::class, 'upsertTemplate'])->name('v1.design.bom.templates.store'); - Route::get('/bom-templates/{templateId}', [DesignBomTemplateController::class, 'show'])->name('v1.design.bom.templates.show'); - Route::put('/bom-templates/{templateId}/items', [DesignBomTemplateController::class, 'replaceItems'])->name('v1.design.bom.templates.items.replace'); - Route::get('/bom-templates/{templateId}/diff', [DesignBomTemplateController::class, 'diff'])->name('v1.design.bom.templates.diff'); - Route::post('/bom-templates/{templateId}/clone', [DesignBomTemplateController::class, 'cloneTemplate'])->name('v1.design.bom.templates.clone'); - - // 감사 로그 조회 - Route::get('/audit-logs', [DesignAuditLogController::class, 'index'])->name('v1.design.audit-logs.index'); - - // BOM 계산 시스템 - Route::get('/models/{modelId}/estimate-parameters', [BomCalculationController::class, 'getEstimateParameters'])->name('v1.design.models.estimate-parameters'); - Route::post('/bom-templates/{bomTemplateId}/calculate-bom', [BomCalculationController::class, 'calculateBom'])->name('v1.design.bom-templates.calculate-bom'); - Route::get('/companies/{companyName}/formulas', [BomCalculationController::class, 'getCompanyFormulas'])->name('v1.design.companies.formulas'); - Route::post('/companies/{companyName}/formulas/{formulaType}', [BomCalculationController::class, 'saveCompanyFormula'])->name('v1.design.companies.formulas.save'); - Route::post('/formulas/test', [BomCalculationController::class, 'testFormula'])->name('v1.design.formulas.test'); - }); - - // 모델셋 관리 API (견적 시스템) - Route::prefix('model-sets')->group(function () { - Route::get('/', [ModelSetController::class, 'index'])->name('v1.model-sets.index'); // 모델셋 목록 - Route::post('/', [ModelSetController::class, 'store'])->name('v1.model-sets.store'); // 모델셋 생성 - Route::get('/{id}', [ModelSetController::class, 'show'])->name('v1.model-sets.show'); // 모델셋 상세 - Route::put('/{id}', [ModelSetController::class, 'update'])->name('v1.model-sets.update'); // 모델셋 수정 - Route::delete('/{id}', [ModelSetController::class, 'destroy'])->name('v1.model-sets.destroy'); // 모델셋 삭제 - Route::post('/{id}/clone', [ModelSetController::class, 'clone'])->name('v1.model-sets.clone'); // 모델셋 복제 - - // 모델셋 세부 기능 - Route::get('/{id}/fields', [ModelSetController::class, 'getCategoryFields'])->name('v1.model-sets.fields'); // 카테고리 필드 조회 - Route::get('/{id}/bom-templates', [ModelSetController::class, 'getBomTemplates'])->name('v1.model-sets.bom-templates'); // BOM 템플릿 조회 - Route::get('/{id}/estimate-parameters', [ModelSetController::class, 'getEstimateParameters'])->name('v1.model-sets.estimate-parameters'); // 견적 파라미터 - Route::post('/{id}/calculate-bom', [ModelSetController::class, 'calculateBom'])->name('v1.model-sets.calculate-bom'); // BOM 계산 - }); - - // 견적 관리 API - Route::prefix('estimates')->group(function () { - Route::get('/', [EstimateController::class, 'index'])->name('v1.estimates.index'); // 견적 목록 - Route::get('/stats', [EstimateController::class, 'stats'])->name('v1.estimates.stats'); // 견적 통계 - Route::post('/', [EstimateController::class, 'store'])->name('v1.estimates.store'); // 견적 생성 - Route::get('/{id}', [EstimateController::class, 'show'])->name('v1.estimates.show'); // 견적 상세 - Route::put('/{id}', [EstimateController::class, 'update'])->name('v1.estimates.update'); // 견적 수정 - Route::delete('/{id}', [EstimateController::class, 'destroy'])->name('v1.estimates.destroy'); // 견적 삭제 - Route::post('/{id}/clone', [EstimateController::class, 'clone'])->name('v1.estimates.clone'); // 견적 복제 - Route::put('/{id}/status', [EstimateController::class, 'changeStatus'])->name('v1.estimates.status'); // 견적 상태 변경 - - // 견적 폼 및 계산 기능 - Route::get('/form-schema/{model_set_id}', [EstimateController::class, 'getFormSchema'])->name('v1.estimates.form-schema'); // 견적 폼 스키마 - Route::post('/preview/{model_set_id}', [EstimateController::class, 'previewCalculation'])->name('v1.estimates.preview'); // 견적 계산 미리보기 - }); - - // 공정 관리 API (Process Management) - Route::prefix('processes')->group(function () { - Route::get('', [ProcessController::class, 'index'])->name('v1.processes.index'); - Route::get('/options', [ProcessController::class, 'options'])->name('v1.processes.options'); - Route::get('/stats', [ProcessController::class, 'stats'])->name('v1.processes.stats'); - Route::post('', [ProcessController::class, 'store'])->name('v1.processes.store'); - Route::delete('', [ProcessController::class, 'destroyMany'])->name('v1.processes.destroy-many'); - Route::get('/{id}', [ProcessController::class, 'show'])->whereNumber('id')->name('v1.processes.show'); - Route::put('/{id}', [ProcessController::class, 'update'])->whereNumber('id')->name('v1.processes.update'); - Route::delete('/{id}', [ProcessController::class, 'destroy'])->whereNumber('id')->name('v1.processes.destroy'); - Route::patch('/{id}/toggle', [ProcessController::class, 'toggleActive'])->whereNumber('id')->name('v1.processes.toggle'); - }); - - // 수주관리 API (Sales) - Route::prefix('orders')->group(function () { - // 기본 CRUD - Route::get('', [OrderController::class, 'index'])->name('v1.orders.index'); // 목록 - Route::get('/stats', [OrderController::class, 'stats'])->name('v1.orders.stats'); // 통계 - Route::post('', [OrderController::class, 'store'])->name('v1.orders.store'); // 생성 - Route::get('/{id}', [OrderController::class, 'show'])->whereNumber('id')->name('v1.orders.show'); // 상세 - Route::put('/{id}', [OrderController::class, 'update'])->whereNumber('id')->name('v1.orders.update'); // 수정 - Route::delete('/{id}', [OrderController::class, 'destroy'])->whereNumber('id')->name('v1.orders.destroy'); // 삭제 - - // 상태 관리 - Route::patch('/{id}/status', [OrderController::class, 'updateStatus'])->whereNumber('id')->name('v1.orders.status'); // 상태 변경 - - // 견적에서 수주 생성 - Route::post('/from-quote/{quoteId}', [OrderController::class, 'createFromQuote'])->whereNumber('quoteId')->name('v1.orders.from-quote'); - - // 생산지시 생성 - Route::post('/{id}/production-order', [OrderController::class, 'createProductionOrder'])->whereNumber('id')->name('v1.orders.production-order'); - - // 수주확정 되돌리기 - Route::post('/{id}/revert-confirmation', [OrderController::class, 'revertOrderConfirmation'])->whereNumber('id')->name('v1.orders.revert-confirmation'); - - // 생산지시 되돌리기 - Route::post('/{id}/revert-production', [OrderController::class, 'revertProductionOrder'])->whereNumber('id')->name('v1.orders.revert-production'); - }); - - // 작업지시 관리 API (Production) - Route::prefix('work-orders')->group(function () { - // 기본 CRUD - Route::get('', [WorkOrderController::class, 'index'])->name('v1.work-orders.index'); // 목록 - Route::get('/stats', [WorkOrderController::class, 'stats'])->name('v1.work-orders.stats'); // 통계 - Route::post('', [WorkOrderController::class, 'store'])->name('v1.work-orders.store'); // 생성 - Route::get('/{id}', [WorkOrderController::class, 'show'])->whereNumber('id')->name('v1.work-orders.show'); // 상세 - Route::put('/{id}', [WorkOrderController::class, 'update'])->whereNumber('id')->name('v1.work-orders.update'); // 수정 - Route::delete('/{id}', [WorkOrderController::class, 'destroy'])->whereNumber('id')->name('v1.work-orders.destroy'); // 삭제 - - // 상태 및 담당자 관리 - Route::patch('/{id}/status', [WorkOrderController::class, 'updateStatus'])->whereNumber('id')->name('v1.work-orders.status'); // 상태 변경 - Route::patch('/{id}/assign', [WorkOrderController::class, 'assign'])->whereNumber('id')->name('v1.work-orders.assign'); // 담당자 배정 - - // 벤딩 공정 상세 토글 - Route::patch('/{id}/bending/toggle', [WorkOrderController::class, 'toggleBendingField'])->whereNumber('id')->name('v1.work-orders.bending-toggle'); - - // 이슈 관리 - Route::post('/{id}/issues', [WorkOrderController::class, 'addIssue'])->whereNumber('id')->name('v1.work-orders.issues.store'); // 이슈 등록 - Route::patch('/{id}/issues/{issueId}/resolve', [WorkOrderController::class, 'resolveIssue'])->whereNumber('id')->name('v1.work-orders.issues.resolve'); // 이슈 해결 - - // 품목 상태 변경 - Route::patch('/{id}/items/{itemId}/status', [WorkOrderController::class, 'updateItemStatus'])->whereNumber('id')->whereNumber('itemId')->name('v1.work-orders.items.status'); - - // 자재 관리 - Route::get('/{id}/materials', [WorkOrderController::class, 'materials'])->whereNumber('id')->name('v1.work-orders.materials'); // 자재 목록 조회 - Route::post('/{id}/material-inputs', [WorkOrderController::class, 'registerMaterialInput'])->whereNumber('id')->name('v1.work-orders.material-inputs'); // 자재 투입 등록 - }); - - // 작업실적 관리 API (Production) - Route::prefix('work-results')->group(function () { - // 기본 CRUD - Route::get('', [WorkResultController::class, 'index'])->name('v1.work-results.index'); // 목록 - Route::get('/stats', [WorkResultController::class, 'stats'])->name('v1.work-results.stats'); // 통계 - Route::post('', [WorkResultController::class, 'store'])->name('v1.work-results.store'); // 생성 - Route::get('/{id}', [WorkResultController::class, 'show'])->whereNumber('id')->name('v1.work-results.show'); // 상세 - Route::put('/{id}', [WorkResultController::class, 'update'])->whereNumber('id')->name('v1.work-results.update'); // 수정 - Route::delete('/{id}', [WorkResultController::class, 'destroy'])->whereNumber('id')->name('v1.work-results.destroy'); // 삭제 - - // 상태 토글 - Route::patch('/{id}/inspection', [WorkResultController::class, 'toggleInspection'])->whereNumber('id')->name('v1.work-results.inspection'); // 검사 상태 토글 - Route::patch('/{id}/packaging', [WorkResultController::class, 'togglePackaging'])->whereNumber('id')->name('v1.work-results.packaging'); // 포장 상태 토글 - }); - - // 검사 관리 API (Quality) - Route::prefix('inspections')->group(function () { - Route::get('', [InspectionController::class, 'index'])->name('v1.inspections.index'); // 목록 - Route::get('/stats', [InspectionController::class, 'stats'])->name('v1.inspections.stats'); // 통계 - Route::post('', [InspectionController::class, 'store'])->name('v1.inspections.store'); // 생성 - Route::get('/{id}', [InspectionController::class, 'show'])->whereNumber('id')->name('v1.inspections.show'); // 상세 - Route::put('/{id}', [InspectionController::class, 'update'])->whereNumber('id')->name('v1.inspections.update'); // 수정 - Route::delete('/{id}', [InspectionController::class, 'destroy'])->whereNumber('id')->name('v1.inspections.destroy'); // 삭제 - Route::patch('/{id}/complete', [InspectionController::class, 'complete'])->whereNumber('id')->name('v1.inspections.complete'); // 완료 처리 - }); - - // 파일 저장소 API - Route::prefix('files')->group(function () { - Route::post('/upload', [FileStorageController::class, 'upload'])->name('v1.files.upload'); // 파일 업로드 (임시) - Route::post('/move', [FileStorageController::class, 'move'])->name('v1.files.move'); // 파일 이동 (temp → folder) - Route::get('/', [FileStorageController::class, 'index'])->name('v1.files.index'); // 파일 목록 - Route::get('/trash', [FileStorageController::class, 'trash'])->name('v1.files.trash'); // 휴지통 목록 - Route::get('/{id}', [FileStorageController::class, 'show'])->name('v1.files.show'); // 파일 상세 - Route::get('/{id}/download', [FileStorageController::class, 'download'])->name('v1.files.download'); // 파일 다운로드 - Route::delete('/{id}', [FileStorageController::class, 'destroy'])->name('v1.files.destroy'); // 파일 삭제 (soft) - Route::post('/{id}/restore', [FileStorageController::class, 'restore'])->name('v1.files.restore'); // 파일 복구 - Route::delete('/{id}/permanent', [FileStorageController::class, 'permanentDelete'])->name('v1.files.permanent'); // 파일 영구 삭제 - Route::post('/{id}/share', [FileStorageController::class, 'createShareLink'])->name('v1.files.share'); // 공유 링크 생성 - }); - - // 저장소 사용량 - Route::get('/storage/usage', [FileStorageController::class, 'storageUsage'])->name('v1.storage.usage'); - - // 폴더 관리 API - Route::prefix('folders')->group(function () { - Route::get('/', [FolderController::class, 'index'])->name('v1.folders.index'); // 폴더 목록 - Route::post('/', [FolderController::class, 'store'])->name('v1.folders.store'); // 폴더 생성 - Route::get('/{id}', [FolderController::class, 'show'])->name('v1.folders.show'); // 폴더 상세 - Route::put('/{id}', [FolderController::class, 'update'])->name('v1.folders.update'); // 폴더 수정 - Route::delete('/{id}', [FolderController::class, 'destroy'])->name('v1.folders.destroy'); // 폴더 삭제/비활성화 - Route::post('/reorder', [FolderController::class, 'reorder'])->name('v1.folders.reorder'); // 폴더 순서 변경 - }); - - // 품목기준관리 (ItemMaster) API - Route::prefix('item-master')->group(function () { - // 초기화 - Route::get('/init', [ItemMasterController::class, 'init'])->name('v1.item-master.init'); - - // 페이지 관리 - Route::get('/pages', [ItemPageController::class, 'index'])->name('v1.item-master.pages.index'); - Route::post('/pages', [ItemPageController::class, 'store'])->name('v1.item-master.pages.store'); - Route::put('/pages/{id}', [ItemPageController::class, 'update'])->name('v1.item-master.pages.update'); - Route::delete('/pages/{id}', [ItemPageController::class, 'destroy'])->name('v1.item-master.pages.destroy'); - - // 독립 섹션 관리 - Route::get('/sections', [ItemSectionController::class, 'index'])->name('v1.item-master.sections.index'); - Route::post('/sections', [ItemSectionController::class, 'storeIndependent'])->name('v1.item-master.sections.store-independent'); - Route::post('/sections/{id}/clone', [ItemSectionController::class, 'clone'])->name('v1.item-master.sections.clone'); - Route::get('/sections/{id}/usage', [ItemSectionController::class, 'getUsage'])->name('v1.item-master.sections.usage'); - - // 섹션 관리 (페이지 연결) - Route::post('/pages/{pageId}/sections', [ItemSectionController::class, 'store'])->name('v1.item-master.sections.store'); - Route::put('/sections/{id}', [ItemSectionController::class, 'update'])->name('v1.item-master.sections.update'); - Route::delete('/sections/{id}', [ItemSectionController::class, 'destroy'])->name('v1.item-master.sections.destroy'); - Route::put('/pages/{pageId}/sections/reorder', [ItemSectionController::class, 'reorder'])->name('v1.item-master.sections.reorder'); - - // 독립 필드 관리 - Route::get('/fields', [ItemFieldController::class, 'index'])->name('v1.item-master.fields.index'); - Route::post('/fields', [ItemFieldController::class, 'storeIndependent'])->name('v1.item-master.fields.store-independent'); - Route::post('/fields/{id}/clone', [ItemFieldController::class, 'clone'])->name('v1.item-master.fields.clone'); - Route::get('/fields/{id}/usage', [ItemFieldController::class, 'getUsage'])->name('v1.item-master.fields.usage'); - - // 필드 관리 (섹션 연결) - Route::post('/sections/{sectionId}/fields', [ItemFieldController::class, 'store'])->name('v1.item-master.fields.store'); - Route::put('/fields/{id}', [ItemFieldController::class, 'update'])->name('v1.item-master.fields.update'); - Route::delete('/fields/{id}', [ItemFieldController::class, 'destroy'])->name('v1.item-master.fields.destroy'); - Route::put('/sections/{sectionId}/fields/reorder', [ItemFieldController::class, 'reorder'])->name('v1.item-master.fields.reorder'); - - // 독립 BOM 항목 관리 - Route::get('/bom-items', [ItemBomItemController::class, 'index'])->name('v1.item-master.bom-items.index'); - Route::post('/bom-items', [ItemBomItemController::class, 'storeIndependent'])->name('v1.item-master.bom-items.store-independent'); - - // BOM 항목 관리 (섹션 연결) - Route::post('/sections/{sectionId}/bom-items', [ItemBomItemController::class, 'store'])->name('v1.item-master.bom-items.store'); - Route::put('/bom-items/{id}', [ItemBomItemController::class, 'update'])->name('v1.item-master.bom-items.update'); - Route::delete('/bom-items/{id}', [ItemBomItemController::class, 'destroy'])->name('v1.item-master.bom-items.destroy'); - - // 섹션 템플릿 - Route::get('/section-templates', [SectionTemplateController::class, 'index'])->name('v1.item-master.section-templates.index'); - Route::post('/section-templates', [SectionTemplateController::class, 'store'])->name('v1.item-master.section-templates.store'); - Route::put('/section-templates/{id}', [SectionTemplateController::class, 'update'])->name('v1.item-master.section-templates.update'); - Route::delete('/section-templates/{id}', [SectionTemplateController::class, 'destroy'])->name('v1.item-master.section-templates.destroy'); - - // 커스텀 탭 - Route::get('/custom-tabs', [CustomTabController::class, 'index'])->name('v1.item-master.custom-tabs.index'); - Route::post('/custom-tabs', [CustomTabController::class, 'store'])->name('v1.item-master.custom-tabs.store'); - Route::put('/custom-tabs/reorder', [CustomTabController::class, 'reorder'])->name('v1.item-master.custom-tabs.reorder'); - Route::put('/custom-tabs/{id}', [CustomTabController::class, 'update'])->name('v1.item-master.custom-tabs.update'); - Route::delete('/custom-tabs/{id}', [CustomTabController::class, 'destroy'])->name('v1.item-master.custom-tabs.destroy'); - - // 단위 옵션 - Route::get('/unit-options', [UnitOptionController::class, 'index'])->name('v1.item-master.unit-options.index'); - Route::post('/unit-options', [UnitOptionController::class, 'store'])->name('v1.item-master.unit-options.store'); - Route::delete('/unit-options/{id}', [UnitOptionController::class, 'destroy'])->name('v1.item-master.unit-options.destroy'); - - // 엔티티 관계 관리 (독립 엔티티 + 링크 테이블) - // 페이지-섹션 연결 - Route::post('/pages/{pageId}/link-section', [EntityRelationshipController::class, 'linkSectionToPage'])->name('v1.item-master.pages.link-section'); - Route::delete('/pages/{pageId}/unlink-section/{sectionId}', [EntityRelationshipController::class, 'unlinkSectionFromPage'])->name('v1.item-master.pages.unlink-section'); - - // 페이지-필드 직접 연결 - Route::post('/pages/{pageId}/link-field', [EntityRelationshipController::class, 'linkFieldToPage'])->name('v1.item-master.pages.link-field'); - Route::delete('/pages/{pageId}/unlink-field/{fieldId}', [EntityRelationshipController::class, 'unlinkFieldFromPage'])->name('v1.item-master.pages.unlink-field'); - - // 페이지 관계 조회 - Route::get('/pages/{pageId}/relationships', [EntityRelationshipController::class, 'getPageRelationships'])->name('v1.item-master.pages.relationships'); - Route::get('/pages/{pageId}/structure', [EntityRelationshipController::class, 'getPageStructure'])->name('v1.item-master.pages.structure'); - - // 섹션-필드 연결 - Route::post('/sections/{sectionId}/link-field', [EntityRelationshipController::class, 'linkFieldToSection'])->name('v1.item-master.sections.link-field'); - Route::delete('/sections/{sectionId}/unlink-field/{fieldId}', [EntityRelationshipController::class, 'unlinkFieldFromSection'])->name('v1.item-master.sections.unlink-field'); - - // 섹션-BOM 연결 - Route::post('/sections/{sectionId}/link-bom', [EntityRelationshipController::class, 'linkBomToSection'])->name('v1.item-master.sections.link-bom'); - Route::delete('/sections/{sectionId}/unlink-bom/{bomId}', [EntityRelationshipController::class, 'unlinkBomFromSection'])->name('v1.item-master.sections.unlink-bom'); - - // 섹션 관계 조회 - Route::get('/sections/{sectionId}/relationships', [EntityRelationshipController::class, 'getSectionRelationships'])->name('v1.item-master.sections.relationships'); - - // 관계 순서 변경 - Route::post('/relationships/reorder', [EntityRelationshipController::class, 'reorderRelationships'])->name('v1.item-master.relationships.reorder'); - }); - - // 시스템 게시판 API (is_system=true, tenant_id=null) - Route::prefix('system-boards')->group(function () { - // 시스템 게시판 목록/상세 - Route::get('/', [SystemBoardController::class, 'index'])->name('v1.system-boards.index'); - Route::get('/{code}', [SystemBoardController::class, 'show'])->name('v1.system-boards.show'); - Route::get('/{code}/fields', [SystemBoardController::class, 'fields'])->name('v1.system-boards.fields'); - - // 시스템 게시글 API - Route::get('/{code}/posts', [SystemPostController::class, 'index'])->name('v1.system-boards.posts.index'); - Route::post('/{code}/posts', [SystemPostController::class, 'store'])->name('v1.system-boards.posts.store'); - Route::get('/{code}/posts/{id}', [SystemPostController::class, 'show'])->name('v1.system-boards.posts.show'); - Route::put('/{code}/posts/{id}', [SystemPostController::class, 'update'])->name('v1.system-boards.posts.update'); - Route::delete('/{code}/posts/{id}', [SystemPostController::class, 'destroy'])->name('v1.system-boards.posts.destroy'); - - // 시스템 댓글 API - Route::get('/{code}/posts/{postId}/comments', [SystemPostController::class, 'comments'])->name('v1.system-boards.posts.comments.index'); - Route::post('/{code}/posts/{postId}/comments', [SystemPostController::class, 'storeComment'])->name('v1.system-boards.posts.comments.store'); - Route::put('/{code}/posts/{postId}/comments/{commentId}', [SystemPostController::class, 'updateComment'])->name('v1.system-boards.posts.comments.update'); - Route::delete('/{code}/posts/{postId}/comments/{commentId}', [SystemPostController::class, 'destroyComment'])->name('v1.system-boards.posts.comments.destroy'); - }); - - // 게시판 관리 API (테넌트용) - Route::prefix('boards')->group(function () { - // 게시판 목록/상세 - Route::get('/', [BoardController::class, 'index'])->name('v1.boards.index'); // 접근 가능한 게시판 목록 - Route::get('/tenant', [BoardController::class, 'tenantBoards'])->name('v1.boards.tenant'); // 테넌트 게시판만 - Route::post('/', [BoardController::class, 'store'])->name('v1.boards.store'); // 테넌트 게시판 생성 - Route::get('/{id}', [BoardController::class, 'show'])->whereNumber('id')->name('v1.boards.show'); // 게시판 상세 (ID 기반) - Route::put('/{id}', [BoardController::class, 'update'])->whereNumber('id')->name('v1.boards.update'); // 테넌트 게시판 수정 - Route::delete('/{id}', [BoardController::class, 'destroy'])->whereNumber('id')->name('v1.boards.destroy'); // 테넌트 게시판 삭제 - Route::get('/{code}/fields', [BoardController::class, 'fields'])->name('v1.boards.fields'); // 게시판 필드 목록 - - // 게시글 API - Route::get('/{code}/posts', [PostController::class, 'index'])->name('v1.boards.posts.index'); // 게시글 목록 - Route::post('/{code}/posts', [PostController::class, 'store'])->name('v1.boards.posts.store'); // 게시글 작성 - Route::get('/{code}/posts/{id}', [PostController::class, 'show'])->name('v1.boards.posts.show'); // 게시글 상세 - Route::put('/{code}/posts/{id}', [PostController::class, 'update'])->name('v1.boards.posts.update'); // 게시글 수정 - Route::delete('/{code}/posts/{id}', [PostController::class, 'destroy'])->name('v1.boards.posts.destroy'); // 게시글 삭제 - - // 댓글 API - Route::get('/{code}/posts/{postId}/comments', [PostController::class, 'comments'])->name('v1.boards.posts.comments.index'); // 댓글 목록 - Route::post('/{code}/posts/{postId}/comments', [PostController::class, 'storeComment'])->name('v1.boards.posts.comments.store'); // 댓글 작성 - Route::put('/{code}/posts/{postId}/comments/{commentId}', [PostController::class, 'updateComment'])->name('v1.boards.posts.comments.update'); // 댓글 수정 - Route::delete('/{code}/posts/{postId}/comments/{commentId}', [PostController::class, 'destroyComment'])->name('v1.boards.posts.comments.destroy'); // 댓글 삭제 - - // 게시판 상세 (코드 기반) - 가장 마지막에 배치 (catch-all) - Route::get('/{code}', [BoardController::class, 'showByCode'])->name('v1.boards.show_by_code'); - }); - - // 게시글 API (사용자 중심) - Route::prefix('posts')->group(function () { - Route::get('/my', [PostController::class, 'myPosts'])->name('v1.posts.my'); // 나의 게시글 목록 - }); - - }); + // 도메인별 라우트 파일 로드 + require __DIR__.'/api/v1/auth.php'; + require __DIR__.'/api/v1/admin.php'; + require __DIR__.'/api/v1/users.php'; + require __DIR__.'/api/v1/tenants.php'; + require __DIR__.'/api/v1/hr.php'; + require __DIR__.'/api/v1/finance.php'; + require __DIR__.'/api/v1/sales.php'; + require __DIR__.'/api/v1/inventory.php'; + require __DIR__.'/api/v1/production.php'; + require __DIR__.'/api/v1/design.php'; + require __DIR__.'/api/v1/files.php'; + require __DIR__.'/api/v1/boards.php'; + require __DIR__.'/api/v1/common.php'; // 공유 링크 다운로드 (인증 불필요 - auth.apikey 그룹 밖) Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download'); }); + +/* +|-------------------------------------------------------------------------- +| V2 API Routes (확장 버전 - 필요시 추가) +|-------------------------------------------------------------------------- +| +| V2 라우트를 추가할 때: +| 1. routes/api/v2/ 디렉토리에 도메인별 파일 생성 +| 2. 아래 주석을 해제하고 필요한 파일만 require +| 3. V2에 없는 라우트는 자동으로 V1으로 폴백 +| +*/ +// Route::prefix('v2')->group(function () { +// // V2 전용 라우트만 정의 (V1에 없거나 변경된 API) +// require __DIR__ . '/api/v2/auth.php'; // 예: 새로운 인증 방식 +// require __DIR__ . '/api/v2/users.php'; // 예: 확장된 사용자 API +// +// // V1과 동일한 라우트는 정의하지 않음 - 미들웨어가 자동 폴백 처리 +// }); diff --git a/routes/api/v1/admin.php b/routes/api/v1/admin.php new file mode 100644 index 0000000..3ca3fd5 --- /dev/null +++ b/routes/api/v1/admin.php @@ -0,0 +1,63 @@ +group(function () { + // 목록/생성 + Route::get('users', [AdminController::class, 'index'])->name('v1.admin.users.index'); // 테넌트 사용자 목록 조회 + Route::post('users', [AdminController::class, 'store'])->name('v1.admin.users.store'); // 테넌트 사용자 생성 + + // 단건 + Route::get('users/{id}', [AdminController::class, 'show'])->name('v1.admin.users.show'); // 테넌트 사용자 단건 조회 + Route::put('users/{id}', [AdminController::class, 'update'])->name('v1.admin.users.update'); // 테넌트 사용자 수정 + + // 소프트 삭제 복구 + Route::delete('users/{id}', [AdminController::class, 'destroy'])->name('v1.admin.users.destroy'); // 테넌트 사용자 삭제(연결 삭제) + Route::post('users/{id}/restore', [AdminController::class, 'restore'])->name('v1.admin.users.restore'); // 테넌트 사용자 삭제 복구 + + // 상태 토글 + Route::patch('users/{id}/status', [AdminController::class, 'toggle'])->name('v1.admin.users.status.toggle'); // 테넌트 사용자 활성/비활성 + + // 역할 부여/해제 + Route::post('users/{id}/roles', [AdminController::class, 'attach'])->name('v1.admin.users.roles.attach'); // 테넌트 사용자 역할 부여 + Route::delete('users/{id}/roles/{role}', [AdminController::class, 'detach'])->name('v1.admin.users.roles.detach'); // 테넌트 사용자 역할 해제 + + // 비밀번호 초기화 + Route::post('users/{id}/reset-password', [AdminController::class, 'reset'])->name('v1.admin.users.password.reset'); // 테넌트 사용자 비밀번호 초기화 + + // 글로벌 메뉴 관리 (시스템 관리자용) + Route::prefix('global-menus')->group(function () { + Route::get('/', [GlobalMenuController::class, 'index'])->name('v1.admin.global-menus.index'); // 글로벌 메뉴 목록 + Route::post('/', [GlobalMenuController::class, 'store'])->name('v1.admin.global-menus.store'); // 글로벌 메뉴 생성 + Route::get('/tree', [GlobalMenuController::class, 'tree'])->name('v1.admin.global-menus.tree'); // 글로벌 메뉴 트리 + Route::get('/stats', [GlobalMenuController::class, 'stats'])->name('v1.admin.global-menus.stats'); // 글로벌 메뉴 통계 + Route::post('/reorder', [GlobalMenuController::class, 'reorder'])->name('v1.admin.global-menus.reorder'); // 글로벌 메뉴 순서 변경 + Route::get('/{id}', [GlobalMenuController::class, 'show'])->name('v1.admin.global-menus.show'); // 글로벌 메뉴 단건 조회 + Route::put('/{id}', [GlobalMenuController::class, 'update'])->name('v1.admin.global-menus.update'); // 글로벌 메뉴 수정 + Route::delete('/{id}', [GlobalMenuController::class, 'destroy'])->name('v1.admin.global-menus.destroy'); // 글로벌 메뉴 삭제 + Route::post('/{id}/sync-to-tenants', [GlobalMenuController::class, 'syncToTenants'])->name('v1.admin.global-menus.sync-to-tenants'); // 테넌트에 동기화 + }); + + // Admin FCM API (MNG 관리자용 FCM 발송) - API Key 인증만 사용 + Route::prefix('fcm')->group(function () { + Route::post('/send', [FcmController::class, 'send'])->name('v1.admin.fcm.send'); // FCM 발송 + Route::get('/preview-count', [FcmController::class, 'previewCount'])->name('v1.admin.fcm.preview'); // 대상 토큰 수 미리보기 + Route::get('/tokens', [FcmController::class, 'tokens'])->name('v1.admin.fcm.tokens'); // 토큰 목록 + Route::get('/tokens/stats', [FcmController::class, 'tokenStats'])->name('v1.admin.fcm.tokens.stats'); // 토큰 통계 + Route::patch('/tokens/{id}/toggle', [FcmController::class, 'toggleToken'])->name('v1.admin.fcm.tokens.toggle'); // 토큰 상태 토글 + Route::delete('/tokens/{id}', [FcmController::class, 'deleteToken'])->name('v1.admin.fcm.tokens.delete'); // 토큰 삭제 + Route::get('/history', [FcmController::class, 'history'])->name('v1.admin.fcm.history'); // 발송 이력 + }); +}); diff --git a/routes/api/v1/auth.php b/routes/api/v1/auth.php new file mode 100644 index 0000000..5eb252e --- /dev/null +++ b/routes/api/v1/auth.php @@ -0,0 +1,32 @@ +group(function () { + Route::post('/exchange-token', [InternalController::class, 'exchangeToken'])->name('v1.internal.exchange-token'); +}); + +// API KEY 인증 (글로벌 미들웨어로 이미 적용됨) +Route::get('/debug-apikey', [ApiController::class, 'debugApikey']); + +// Auth API +Route::post('login', [ApiController::class, 'login'])->name('v1.users.login'); +Route::middleware('auth:sanctum')->post('logout', [ApiController::class, 'logout'])->name('v1.users.logout'); +Route::post('signup', [ApiController::class, 'signup'])->name('v1.users.signup'); +Route::post('token-login', [ApiController::class, 'tokenLogin'])->name('v1.auth.token-login'); // MNG → DEV 자동 로그인 +Route::post('refresh', [RefreshController::class, 'refresh'])->name('v1.token.refresh'); +Route::post('register', [RegisterController::class, 'register'])->name('v1.register'); diff --git a/routes/api/v1/boards.php b/routes/api/v1/boards.php new file mode 100644 index 0000000..e97a3c2 --- /dev/null +++ b/routes/api/v1/boards.php @@ -0,0 +1,170 @@ +group(function () { + // 시스템 게시판 목록/상세 + Route::get('/', [SystemBoardController::class, 'index'])->name('v1.system-boards.index'); + Route::get('/{code}', [SystemBoardController::class, 'show'])->name('v1.system-boards.show'); + Route::get('/{code}/fields', [SystemBoardController::class, 'fields'])->name('v1.system-boards.fields'); + + // 시스템 게시글 API + Route::get('/{code}/posts', [SystemPostController::class, 'index'])->name('v1.system-boards.posts.index'); + Route::post('/{code}/posts', [SystemPostController::class, 'store'])->name('v1.system-boards.posts.store'); + Route::get('/{code}/posts/{id}', [SystemPostController::class, 'show'])->name('v1.system-boards.posts.show'); + Route::put('/{code}/posts/{id}', [SystemPostController::class, 'update'])->name('v1.system-boards.posts.update'); + Route::delete('/{code}/posts/{id}', [SystemPostController::class, 'destroy'])->name('v1.system-boards.posts.destroy'); + + // 시스템 댓글 API + Route::get('/{code}/posts/{postId}/comments', [SystemPostController::class, 'comments'])->name('v1.system-boards.posts.comments.index'); + Route::post('/{code}/posts/{postId}/comments', [SystemPostController::class, 'storeComment'])->name('v1.system-boards.posts.comments.store'); + Route::put('/{code}/posts/{postId}/comments/{commentId}', [SystemPostController::class, 'updateComment'])->name('v1.system-boards.posts.comments.update'); + Route::delete('/{code}/posts/{postId}/comments/{commentId}', [SystemPostController::class, 'destroyComment'])->name('v1.system-boards.posts.comments.destroy'); +}); + +// 게시판 관리 API (테넌트용) +Route::prefix('boards')->group(function () { + // 게시판 목록/상세 + Route::get('/', [BoardController::class, 'index'])->name('v1.boards.index'); // 접근 가능한 게시판 목록 + Route::get('/tenant', [BoardController::class, 'tenantBoards'])->name('v1.boards.tenant'); // 테넌트 게시판만 + Route::post('/', [BoardController::class, 'store'])->name('v1.boards.store'); // 테넌트 게시판 생성 + Route::get('/{id}', [BoardController::class, 'show'])->whereNumber('id')->name('v1.boards.show'); // 게시판 상세 (ID 기반) + Route::put('/{id}', [BoardController::class, 'update'])->whereNumber('id')->name('v1.boards.update'); // 테넌트 게시판 수정 + Route::delete('/{id}', [BoardController::class, 'destroy'])->whereNumber('id')->name('v1.boards.destroy'); // 테넌트 게시판 삭제 + Route::get('/{code}/fields', [BoardController::class, 'fields'])->name('v1.boards.fields'); // 게시판 필드 목록 + + // 게시글 API + Route::get('/{code}/posts', [PostController::class, 'index'])->name('v1.boards.posts.index'); // 게시글 목록 + Route::post('/{code}/posts', [PostController::class, 'store'])->name('v1.boards.posts.store'); // 게시글 작성 + Route::get('/{code}/posts/{id}', [PostController::class, 'show'])->name('v1.boards.posts.show'); // 게시글 상세 + Route::put('/{code}/posts/{id}', [PostController::class, 'update'])->name('v1.boards.posts.update'); // 게시글 수정 + Route::delete('/{code}/posts/{id}', [PostController::class, 'destroy'])->name('v1.boards.posts.destroy'); // 게시글 삭제 + + // 댓글 API + Route::get('/{code}/posts/{postId}/comments', [PostController::class, 'comments'])->name('v1.boards.posts.comments.index'); // 댓글 목록 + Route::post('/{code}/posts/{postId}/comments', [PostController::class, 'storeComment'])->name('v1.boards.posts.comments.store'); // 댓글 작성 + Route::put('/{code}/posts/{postId}/comments/{commentId}', [PostController::class, 'updateComment'])->name('v1.boards.posts.comments.update'); // 댓글 수정 + Route::delete('/{code}/posts/{postId}/comments/{commentId}', [PostController::class, 'destroyComment'])->name('v1.boards.posts.comments.destroy'); // 댓글 삭제 + + // 게시판 상세 (코드 기반) - 가장 마지막에 배치 (catch-all) + Route::get('/{code}', [BoardController::class, 'showByCode'])->name('v1.boards.show_by_code'); +}); + +// 게시글 API (사용자 중심) +Route::prefix('posts')->group(function () { + Route::get('/my', [PostController::class, 'myPosts'])->name('v1.posts.my'); // 나의 게시글 목록 +}); + +// 품목기준관리 (ItemMaster) API +Route::prefix('item-master')->group(function () { + // 초기화 + Route::get('/init', [ItemMasterController::class, 'init'])->name('v1.item-master.init'); + + // 페이지 관리 + Route::get('/pages', [ItemPageController::class, 'index'])->name('v1.item-master.pages.index'); + Route::post('/pages', [ItemPageController::class, 'store'])->name('v1.item-master.pages.store'); + Route::put('/pages/{id}', [ItemPageController::class, 'update'])->name('v1.item-master.pages.update'); + Route::delete('/pages/{id}', [ItemPageController::class, 'destroy'])->name('v1.item-master.pages.destroy'); + + // 독립 섹션 관리 + Route::get('/sections', [ItemSectionController::class, 'index'])->name('v1.item-master.sections.index'); + Route::post('/sections', [ItemSectionController::class, 'storeIndependent'])->name('v1.item-master.sections.store-independent'); + Route::post('/sections/{id}/clone', [ItemSectionController::class, 'clone'])->name('v1.item-master.sections.clone'); + Route::get('/sections/{id}/usage', [ItemSectionController::class, 'getUsage'])->name('v1.item-master.sections.usage'); + + // 섹션 관리 (페이지 연결) + Route::post('/pages/{pageId}/sections', [ItemSectionController::class, 'store'])->name('v1.item-master.sections.store'); + Route::put('/sections/{id}', [ItemSectionController::class, 'update'])->name('v1.item-master.sections.update'); + Route::delete('/sections/{id}', [ItemSectionController::class, 'destroy'])->name('v1.item-master.sections.destroy'); + Route::put('/pages/{pageId}/sections/reorder', [ItemSectionController::class, 'reorder'])->name('v1.item-master.sections.reorder'); + + // 독립 필드 관리 + Route::get('/fields', [ItemFieldController::class, 'index'])->name('v1.item-master.fields.index'); + Route::post('/fields', [ItemFieldController::class, 'storeIndependent'])->name('v1.item-master.fields.store-independent'); + Route::post('/fields/{id}/clone', [ItemFieldController::class, 'clone'])->name('v1.item-master.fields.clone'); + Route::get('/fields/{id}/usage', [ItemFieldController::class, 'getUsage'])->name('v1.item-master.fields.usage'); + + // 필드 관리 (섹션 연결) + Route::post('/sections/{sectionId}/fields', [ItemFieldController::class, 'store'])->name('v1.item-master.fields.store'); + Route::put('/fields/{id}', [ItemFieldController::class, 'update'])->name('v1.item-master.fields.update'); + Route::delete('/fields/{id}', [ItemFieldController::class, 'destroy'])->name('v1.item-master.fields.destroy'); + Route::put('/sections/{sectionId}/fields/reorder', [ItemFieldController::class, 'reorder'])->name('v1.item-master.fields.reorder'); + + // 독립 BOM 항목 관리 + Route::get('/bom-items', [ItemBomItemController::class, 'index'])->name('v1.item-master.bom-items.index'); + Route::post('/bom-items', [ItemBomItemController::class, 'storeIndependent'])->name('v1.item-master.bom-items.store-independent'); + + // BOM 항목 관리 (섹션 연결) + Route::post('/sections/{sectionId}/bom-items', [ItemBomItemController::class, 'store'])->name('v1.item-master.bom-items.store'); + Route::put('/bom-items/{id}', [ItemBomItemController::class, 'update'])->name('v1.item-master.bom-items.update'); + Route::delete('/bom-items/{id}', [ItemBomItemController::class, 'destroy'])->name('v1.item-master.bom-items.destroy'); + + // 섹션 템플릿 + Route::get('/section-templates', [SectionTemplateController::class, 'index'])->name('v1.item-master.section-templates.index'); + Route::post('/section-templates', [SectionTemplateController::class, 'store'])->name('v1.item-master.section-templates.store'); + Route::put('/section-templates/{id}', [SectionTemplateController::class, 'update'])->name('v1.item-master.section-templates.update'); + Route::delete('/section-templates/{id}', [SectionTemplateController::class, 'destroy'])->name('v1.item-master.section-templates.destroy'); + + // 커스텀 탭 + Route::get('/custom-tabs', [CustomTabController::class, 'index'])->name('v1.item-master.custom-tabs.index'); + Route::post('/custom-tabs', [CustomTabController::class, 'store'])->name('v1.item-master.custom-tabs.store'); + Route::put('/custom-tabs/reorder', [CustomTabController::class, 'reorder'])->name('v1.item-master.custom-tabs.reorder'); + Route::put('/custom-tabs/{id}', [CustomTabController::class, 'update'])->name('v1.item-master.custom-tabs.update'); + Route::delete('/custom-tabs/{id}', [CustomTabController::class, 'destroy'])->name('v1.item-master.custom-tabs.destroy'); + + // 단위 옵션 + Route::get('/unit-options', [UnitOptionController::class, 'index'])->name('v1.item-master.unit-options.index'); + Route::post('/unit-options', [UnitOptionController::class, 'store'])->name('v1.item-master.unit-options.store'); + Route::delete('/unit-options/{id}', [UnitOptionController::class, 'destroy'])->name('v1.item-master.unit-options.destroy'); + + // 엔티티 관계 관리 (독립 엔티티 + 링크 테이블) + // 페이지-섹션 연결 + Route::post('/pages/{pageId}/link-section', [EntityRelationshipController::class, 'linkSectionToPage'])->name('v1.item-master.pages.link-section'); + Route::delete('/pages/{pageId}/unlink-section/{sectionId}', [EntityRelationshipController::class, 'unlinkSectionFromPage'])->name('v1.item-master.pages.unlink-section'); + + // 페이지-필드 직접 연결 + Route::post('/pages/{pageId}/link-field', [EntityRelationshipController::class, 'linkFieldToPage'])->name('v1.item-master.pages.link-field'); + Route::delete('/pages/{pageId}/unlink-field/{fieldId}', [EntityRelationshipController::class, 'unlinkFieldFromPage'])->name('v1.item-master.pages.unlink-field'); + + // 페이지 관계 조회 + Route::get('/pages/{pageId}/relationships', [EntityRelationshipController::class, 'getPageRelationships'])->name('v1.item-master.pages.relationships'); + Route::get('/pages/{pageId}/structure', [EntityRelationshipController::class, 'getPageStructure'])->name('v1.item-master.pages.structure'); + + // 섹션-필드 연결 + Route::post('/sections/{sectionId}/link-field', [EntityRelationshipController::class, 'linkFieldToSection'])->name('v1.item-master.sections.link-field'); + Route::delete('/sections/{sectionId}/unlink-field/{fieldId}', [EntityRelationshipController::class, 'unlinkFieldFromSection'])->name('v1.item-master.sections.unlink-field'); + + // 섹션-BOM 연결 + Route::post('/sections/{sectionId}/link-bom', [EntityRelationshipController::class, 'linkBomToSection'])->name('v1.item-master.sections.link-bom'); + Route::delete('/sections/{sectionId}/unlink-bom/{bomId}', [EntityRelationshipController::class, 'unlinkBomFromSection'])->name('v1.item-master.sections.unlink-bom'); + + // 섹션 관계 조회 + Route::get('/sections/{sectionId}/relationships', [EntityRelationshipController::class, 'getSectionRelationships'])->name('v1.item-master.sections.relationships'); + + // 관계 순서 변경 + Route::post('/relationships/reorder', [EntityRelationshipController::class, 'reorderRelationships'])->name('v1.item-master.relationships.reorder'); +}); diff --git a/routes/api/v1/common.php b/routes/api/v1/common.php new file mode 100644 index 0000000..ede81c7 --- /dev/null +++ b/routes/api/v1/common.php @@ -0,0 +1,228 @@ +prefix('menus')->group(function () { + Route::get('/', [MenuController::class, 'index'])->name('v1.menus.index'); + Route::post('/', [MenuController::class, 'store'])->name('v1.menus.store'); + Route::post('/reorder', [MenuController::class, 'reorder'])->name('v1.menus.reorder'); + + // 동기화 관련 라우트 (/{id} 전에 위치해야 함) + Route::get('/trashed', [MenuController::class, 'trashed'])->name('v1.menus.trashed'); + Route::get('/available-global', [MenuController::class, 'availableGlobal'])->name('v1.menus.available-global'); + Route::get('/sync-status', [MenuController::class, 'syncStatus'])->name('v1.menus.sync-status'); + Route::post('/sync', [MenuController::class, 'sync'])->name('v1.menus.sync'); + Route::post('/sync-new', [MenuController::class, 'syncNew'])->name('v1.menus.sync-new'); + Route::post('/sync-updates', [MenuController::class, 'syncUpdates'])->name('v1.menus.sync-updates'); + + // 단일 메뉴 관련 라우트 + Route::get('/{id}', [MenuController::class, 'show'])->name('v1.menus.show'); + Route::patch('/{id}', [MenuController::class, 'update'])->name('v1.menus.update'); + Route::delete('/{id}', [MenuController::class, 'destroy'])->name('v1.menus.destroy'); + Route::post('/{id}/toggle', [MenuController::class, 'toggle'])->name('v1.menus.toggle'); + Route::post('/{id}/restore', [MenuController::class, 'restore'])->name('v1.menus.restore'); +}); + +// Role API +Route::prefix('roles')->group(function () { + Route::get('/', [RoleController::class, 'index'])->name('v1.roles.index'); + Route::post('/', [RoleController::class, 'store'])->name('v1.roles.store'); + Route::get('/stats', [RoleController::class, 'stats'])->name('v1.roles.stats'); + Route::get('/active', [RoleController::class, 'active'])->name('v1.roles.active'); + Route::get('/{id}', [RoleController::class, 'show'])->name('v1.roles.show'); + Route::patch('/{id}', [RoleController::class, 'update'])->name('v1.roles.update'); + Route::delete('/{id}', [RoleController::class, 'destroy'])->name('v1.roles.destroy'); +}); + +// Role Permission API - 공통 +Route::get('/role-permissions/menus', [RolePermissionController::class, 'menus'])->name('v1.roles.perms.menus'); + +// Role Permission API - 역할별 +Route::prefix('roles/{id}/permissions')->group(function () { + Route::get('/', [RolePermissionController::class, 'index'])->name('v1.roles.perms.index'); + Route::post('/', [RolePermissionController::class, 'grant'])->name('v1.roles.perms.grant'); + Route::delete('/', [RolePermissionController::class, 'revoke'])->name('v1.roles.perms.revoke'); + Route::put('/sync', [RolePermissionController::class, 'sync'])->name('v1.roles.perms.sync'); + // 권한 매트릭스 API + Route::get('/matrix', [RolePermissionController::class, 'matrix'])->name('v1.roles.perms.matrix'); + Route::post('/toggle', [RolePermissionController::class, 'toggle'])->name('v1.roles.perms.toggle'); + Route::post('/allow-all', [RolePermissionController::class, 'allowAll'])->name('v1.roles.perms.allowAll'); + Route::post('/deny-all', [RolePermissionController::class, 'denyAll'])->name('v1.roles.perms.denyAll'); + Route::post('/reset', [RolePermissionController::class, 'reset'])->name('v1.roles.perms.reset'); +}); + +// Permission API +Route::prefix('permissions')->group(function () { + Route::get('departments/{dept_id}/menu-matrix', [PermissionController::class, 'deptMenuMatrix'])->name('v1.permissions.deptMenuMatrix'); + Route::get('roles/{role_id}/menu-matrix', [PermissionController::class, 'roleMenuMatrix'])->name('v1.permissions.roleMenuMatrix'); + Route::get('users/{user_id}/menu-matrix', [PermissionController::class, 'userMenuMatrix'])->name('v1.permissions.userMenuMatrix'); +}); + +// Settings & Configuration (설정 및 환경설정 통합 관리) +Route::prefix('settings')->group(function () { + // 근무 설정 + Route::get('/work', [WorkSettingController::class, 'showWorkSetting'])->name('v1.settings.work.show'); + Route::put('/work', [WorkSettingController::class, 'updateWorkSetting'])->name('v1.settings.work.update'); + + // 출퇴근 설정 + Route::get('/attendance', [WorkSettingController::class, 'showAttendanceSetting'])->name('v1.settings.attendance.show'); + Route::put('/attendance', [WorkSettingController::class, 'updateAttendanceSetting'])->name('v1.settings.attendance.update'); + + // 급여 설정 + Route::get('/payroll', [PayrollController::class, 'getSettings'])->name('v1.settings.payroll.show'); + Route::put('/payroll', [PayrollController::class, 'updateSettings'])->name('v1.settings.payroll.update'); + + // 테넌트 필드 설정 + Route::get('/fields', [TenantFieldSettingController::class, 'index'])->name('v1.settings.fields.index'); + Route::put('/fields/bulk', [TenantFieldSettingController::class, 'bulkUpsert'])->name('v1.settings.fields.bulk'); + Route::patch('/fields/{key}', [TenantFieldSettingController::class, 'updateOne'])->name('v1.settings.fields.update'); + + // 옵션 그룹/값 + Route::get('/options', [TenantOptionGroupController::class, 'index'])->name('v1.settings.options.index'); + Route::post('/options', [TenantOptionGroupController::class, 'store'])->name('v1.settings.options.store'); + Route::get('/options/{id}', [TenantOptionGroupController::class, 'show'])->name('v1.settings.options.show'); + Route::patch('/options/{id}', [TenantOptionGroupController::class, 'update'])->name('v1.settings.options.update'); + Route::delete('/options/{id}', [TenantOptionGroupController::class, 'destroy'])->name('v1.settings.options.destroy'); + Route::get('/options/{gid}/values', [TenantOptionValueController::class, 'index'])->name('v1.settings.options.values.index'); + Route::post('/options/{gid}/values', [TenantOptionValueController::class, 'store'])->name('v1.settings.options.values.store'); + Route::get('/options/{gid}/values/{id}', [TenantOptionValueController::class, 'show'])->name('v1.settings.options.values.show'); + Route::patch('/options/{gid}/values/{id}', [TenantOptionValueController::class, 'update'])->name('v1.settings.options.values.update'); + Route::delete('/options/{gid}/values/{id}', [TenantOptionValueController::class, 'destroy'])->name('v1.settings.options.values.destroy'); + Route::patch('/options/{gid}/values/reorder', [TenantOptionValueController::class, 'reorder'])->name('v1.settings.options.values.reorder'); + + // 공통 코드 관리 + Route::get('/common/code', [CommonController::class, 'getComeCode'])->name('v1.settings.common.code'); + Route::get('/common', [CommonController::class, 'list'])->name('v1.settings.common.list'); + Route::get('/common/{group}', [CommonController::class, 'index'])->name('v1.settings.common.index'); + Route::post('/common', [CommonController::class, 'store'])->name('v1.settings.common.store'); + Route::patch('/common/{id}', [CommonController::class, 'update'])->name('v1.settings.common.update'); + Route::delete('/common/{id}', [CommonController::class, 'destroy'])->name('v1.settings.common.destroy'); + + // 알림 설정 (그룹 기반) - auth:sanctum 필수 + Route::middleware('auth:sanctum')->group(function () { + Route::get('/notifications', [NotificationSettingController::class, 'indexGrouped'])->name('v1.settings.notifications.index'); + Route::put('/notifications', [NotificationSettingController::class, 'updateGrouped'])->name('v1.settings.notifications.update'); + }); +}); + +// Push Notification API (FCM) - auth:sanctum 필수 +Route::prefix('push')->middleware('auth:sanctum')->group(function () { + Route::post('/register-token', [PushNotificationController::class, 'registerToken'])->name('v1.push.register-token'); + Route::post('/unregister-token', [PushNotificationController::class, 'unregisterToken'])->name('v1.push.unregister-token'); + Route::get('/tokens', [PushNotificationController::class, 'getTokens'])->name('v1.push.tokens'); + Route::get('/settings', [PushNotificationController::class, 'getSettings'])->name('v1.push.settings'); + Route::put('/settings', [PushNotificationController::class, 'updateSettings'])->name('v1.push.settings.update'); + Route::get('/notification-types', [PushNotificationController::class, 'getNotificationTypes'])->name('v1.push.notification-types'); +}); + +// Category API (통합) +Route::prefix('categories')->group(function () { + // 확장 기능 (와일드카드 라우트보다 먼저 정의) + Route::get('/tree', [CategoryController::class, 'tree'])->name('v1.categories.tree'); + Route::post('/reorder', [CategoryController::class, 'reorder'])->name('v1.categories.reorder'); + + // 기본 Category CRUD + Route::get('', [CategoryController::class, 'index'])->name('v1.categories.index'); + Route::post('', [CategoryController::class, 'store'])->name('v1.categories.store'); + Route::get('/{id}', [CategoryController::class, 'show'])->name('v1.categories.show'); + Route::patch('/{id}', [CategoryController::class, 'update'])->name('v1.categories.update'); + Route::delete('/{id}', [CategoryController::class, 'destroy'])->name('v1.categories.destroy'); + Route::post('/{id}/toggle', [CategoryController::class, 'toggle'])->name('v1.categories.toggle'); + Route::patch('/{id}/move', [CategoryController::class, 'move'])->name('v1.categories.move'); + + // Category Fields + Route::get('/{id}/fields', [CategoryFieldController::class, 'index'])->name('v1.categories.fields.index'); + Route::post('/{id}/fields', [CategoryFieldController::class, 'store'])->name('v1.categories.fields.store'); + Route::get('/fields/{field}', [CategoryFieldController::class, 'show'])->name('v1.categories.fields.show'); + Route::patch('/fields/{field}', [CategoryFieldController::class, 'update'])->name('v1.categories.fields.update'); + Route::delete('/fields/{field}', [CategoryFieldController::class, 'destroy'])->name('v1.categories.fields.destroy'); + Route::post('/{id}/fields/reorder', [CategoryFieldController::class, 'reorder'])->name('v1.categories.fields.reorder'); + Route::put('/{id}/fields/bulk-upsert', [CategoryFieldController::class, 'bulkUpsert'])->name('v1.categories.fields.bulk'); + + // Category Templates + Route::get('/{id}/templates', [CategoryTemplateController::class, 'index'])->name('v1.categories.templates.index'); + Route::post('/{id}/templates', [CategoryTemplateController::class, 'store'])->name('v1.categories.templates.store'); + Route::get('/templates/{tpl}', [CategoryTemplateController::class, 'show'])->name('v1.categories.templates.show'); + Route::patch('/templates/{tpl}', [CategoryTemplateController::class, 'update'])->name('v1.categories.templates.update'); + Route::delete('/templates/{tpl}', [CategoryTemplateController::class, 'destroy'])->name('v1.categories.templates.destroy'); + Route::post('/{id}/templates/{tpl}/apply', [CategoryTemplateController::class, 'apply'])->name('v1.categories.templates.apply'); + Route::get('/{id}/templates/{tpl}/preview', [CategoryTemplateController::class, 'preview'])->name('v1.categories.templates.preview'); + Route::get('/{id}/templates/diff', [CategoryTemplateController::class, 'diff'])->name('v1.categories.templates.diff'); + + // Category Logs + Route::get('/{id}/logs', [CategoryLogController::class, 'index'])->name('v1.categories.logs.index'); + Route::get('/logs/{log}', [CategoryLogController::class, 'show'])->name('v1.categories.logs.show'); +}); + +// Classifications API +Route::prefix('classifications')->group(function () { + Route::get('', [ClassificationController::class, 'index'])->name('v1.classifications.index'); + Route::post('', [ClassificationController::class, 'store'])->name('v1.classifications.store'); + Route::get('/{id}', [ClassificationController::class, 'show'])->whereNumber('id')->name('v1.classifications.show'); + Route::patch('/{id}', [ClassificationController::class, 'update'])->whereNumber('id')->name('v1.classifications.update'); + Route::delete('/{id}', [ClassificationController::class, 'destroy'])->whereNumber('id')->name('v1.classifications.destroy'); +}); + +// Popup API (팝업관리) +Route::prefix('popups')->group(function () { + Route::get('', [PopupController::class, 'index'])->name('v1.popups.index'); + Route::post('', [PopupController::class, 'store'])->name('v1.popups.store'); + Route::get('/active', [PopupController::class, 'active'])->name('v1.popups.active'); + Route::get('/{id}', [PopupController::class, 'show'])->whereNumber('id')->name('v1.popups.show'); + Route::put('/{id}', [PopupController::class, 'update'])->whereNumber('id')->name('v1.popups.update'); + Route::delete('/{id}', [PopupController::class, 'destroy'])->whereNumber('id')->name('v1.popups.destroy'); +}); + +// Report API (보고서) +Route::prefix('reports')->group(function () { + Route::get('/daily', [ReportController::class, 'daily'])->name('v1.reports.daily'); + Route::get('/daily/export', [ReportController::class, 'dailyExport'])->name('v1.reports.daily.export'); + Route::get('/expense-estimate', [ReportController::class, 'expenseEstimate'])->name('v1.reports.expense-estimate'); + Route::get('/expense-estimate/export', [ReportController::class, 'expenseEstimateExport'])->name('v1.reports.expense-estimate.export'); + + // AI Report API + Route::get('/ai', [AiReportController::class, 'index'])->name('v1.reports.ai.index'); + Route::post('/ai/generate', [AiReportController::class, 'generate'])->name('v1.reports.ai.generate'); + Route::get('/ai/{id}', [AiReportController::class, 'show'])->whereNumber('id')->name('v1.reports.ai.show'); + Route::delete('/ai/{id}', [AiReportController::class, 'destroy'])->whereNumber('id')->name('v1.reports.ai.destroy'); +}); + +// Dashboard API (대시보드) +Route::prefix('dashboard')->group(function () { + Route::get('/summary', [DashboardController::class, 'summary'])->name('v1.dashboard.summary'); + Route::get('/charts', [DashboardController::class, 'charts'])->name('v1.dashboard.charts'); + Route::get('/approvals', [DashboardController::class, 'approvals'])->name('v1.dashboard.approvals'); +}); diff --git a/routes/api/v1/design.php b/routes/api/v1/design.php new file mode 100644 index 0000000..8fbb9fe --- /dev/null +++ b/routes/api/v1/design.php @@ -0,0 +1,84 @@ +group(function () { + // Design Model API (설계 모델) + Route::prefix('models')->group(function () { + Route::get('', [DesignModelController::class, 'index'])->name('v1.design.models.index'); + Route::post('', [DesignModelController::class, 'store'])->name('v1.design.models.store'); + Route::get('/archived', [DesignModelController::class, 'archived'])->name('v1.design.models.archived'); + Route::get('/{id}', [DesignModelController::class, 'show'])->whereNumber('id')->name('v1.design.models.show'); + Route::put('/{id}', [DesignModelController::class, 'update'])->whereNumber('id')->name('v1.design.models.update'); + Route::delete('/{id}', [DesignModelController::class, 'destroy'])->whereNumber('id')->name('v1.design.models.destroy'); + Route::post('/{id}/archive', [DesignModelController::class, 'archive'])->whereNumber('id')->name('v1.design.models.archive'); + Route::post('/{id}/restore', [DesignModelController::class, 'restore'])->whereNumber('id')->name('v1.design.models.restore'); + + // Model Version API (모델 버전) + Route::get('/{modelId}/versions', [DesignModelVersionController::class, 'index'])->whereNumber('modelId')->name('v1.design.models.versions.index'); + Route::post('/{modelId}/versions', [DesignModelVersionController::class, 'store'])->whereNumber('modelId')->name('v1.design.models.versions.store'); + Route::get('/{modelId}/versions/{id}', [DesignModelVersionController::class, 'show'])->whereNumber(['modelId', 'id'])->name('v1.design.models.versions.show'); + Route::put('/{modelId}/versions/{id}', [DesignModelVersionController::class, 'update'])->whereNumber(['modelId', 'id'])->name('v1.design.models.versions.update'); + Route::delete('/{modelId}/versions/{id}', [DesignModelVersionController::class, 'destroy'])->whereNumber(['modelId', 'id'])->name('v1.design.models.versions.destroy'); + Route::post('/{modelId}/versions/{id}/release', [DesignModelVersionController::class, 'release'])->whereNumber(['modelId', 'id'])->name('v1.design.models.versions.release'); + Route::post('/{modelId}/versions/{id}/clone', [DesignModelVersionController::class, 'clone'])->whereNumber(['modelId', 'id'])->name('v1.design.models.versions.clone'); + Route::get('/{modelId}/versions/{id}/diff', [DesignModelVersionController::class, 'diff'])->whereNumber(['modelId', 'id'])->name('v1.design.models.versions.diff'); + }); + + // BOM Template API (BOM 템플릿) + Route::prefix('bom-templates')->group(function () { + Route::get('', [DesignBomTemplateController::class, 'index'])->name('v1.design.bom-templates.index'); + Route::post('', [DesignBomTemplateController::class, 'store'])->name('v1.design.bom-templates.store'); + Route::get('/{id}', [DesignBomTemplateController::class, 'show'])->whereNumber('id')->name('v1.design.bom-templates.show'); + Route::put('/{id}', [DesignBomTemplateController::class, 'update'])->whereNumber('id')->name('v1.design.bom-templates.update'); + Route::delete('/{id}', [DesignBomTemplateController::class, 'destroy'])->whereNumber('id')->name('v1.design.bom-templates.destroy'); + Route::put('/{id}/items/bulk-upsert', [DesignBomTemplateController::class, 'bulkUpsertItems'])->whereNumber('id')->name('v1.design.bom-templates.items.bulk-upsert'); + Route::post('/{id}/items/reorder', [DesignBomTemplateController::class, 'reorderItems'])->whereNumber('id')->name('v1.design.bom-templates.items.reorder'); + Route::get('/{id}/summary', [DesignBomTemplateController::class, 'summary'])->whereNumber('id')->name('v1.design.bom-templates.summary'); + Route::get('/{id}/validate', [DesignBomTemplateController::class, 'validate'])->whereNumber('id')->name('v1.design.bom-templates.validate'); + }); + + // BOM Calculation API (BOM 계산) + Route::prefix('bom-calculation')->group(function () { + Route::post('/calculate', [BomCalculationController::class, 'calculate'])->name('v1.design.bom-calculation.calculate'); + Route::post('/preview', [BomCalculationController::class, 'preview'])->name('v1.design.bom-calculation.preview'); + Route::get('/form-schema/{versionId}', [BomCalculationController::class, 'getFormSchema'])->whereNumber('versionId')->name('v1.design.bom-calculation.form-schema'); + }); + + // Audit Log API (감사 로그) + Route::prefix('audit-logs')->group(function () { + Route::get('', [DesignAuditLogController::class, 'index'])->name('v1.design.audit-logs.index'); + Route::get('/{id}', [DesignAuditLogController::class, 'show'])->whereNumber('id')->name('v1.design.audit-logs.show'); + }); +}); + +// Model Set API (모델셋 관리) +Route::prefix('model-sets')->group(function () { + Route::get('', [ModelSetController::class, 'index'])->name('v1.model-sets.index'); + Route::post('', [ModelSetController::class, 'store'])->name('v1.model-sets.store'); + Route::get('/active', [ModelSetController::class, 'active'])->name('v1.model-sets.active'); + Route::get('/{id}', [ModelSetController::class, 'show'])->whereNumber('id')->name('v1.model-sets.show'); + Route::put('/{id}', [ModelSetController::class, 'update'])->whereNumber('id')->name('v1.model-sets.update'); + Route::delete('/{id}', [ModelSetController::class, 'destroy'])->whereNumber('id')->name('v1.model-sets.destroy'); + Route::patch('/{id}/toggle', [ModelSetController::class, 'toggle'])->whereNumber('id')->name('v1.model-sets.toggle'); + Route::post('/{id}/clone', [ModelSetController::class, 'clone'])->whereNumber('id')->name('v1.model-sets.clone'); + Route::put('/{id}/items', [ModelSetController::class, 'updateItems'])->whereNumber('id')->name('v1.model-sets.items'); +}); diff --git a/routes/api/v1/files.php b/routes/api/v1/files.php new file mode 100644 index 0000000..1f0ca39 --- /dev/null +++ b/routes/api/v1/files.php @@ -0,0 +1,44 @@ +group(function () { + Route::post('/upload', [FileStorageController::class, 'upload'])->name('v1.files.upload'); // 파일 업로드 (임시) + Route::post('/move', [FileStorageController::class, 'move'])->name('v1.files.move'); // 파일 이동 (temp → folder) + Route::get('/', [FileStorageController::class, 'index'])->name('v1.files.index'); // 파일 목록 + Route::get('/trash', [FileStorageController::class, 'trash'])->name('v1.files.trash'); // 휴지통 목록 + Route::get('/{id}', [FileStorageController::class, 'show'])->name('v1.files.show'); // 파일 상세 + Route::get('/{id}/download', [FileStorageController::class, 'download'])->name('v1.files.download'); // 파일 다운로드 + Route::delete('/{id}', [FileStorageController::class, 'destroy'])->name('v1.files.destroy'); // 파일 삭제 (soft) + Route::post('/{id}/restore', [FileStorageController::class, 'restore'])->name('v1.files.restore'); // 파일 복구 + Route::delete('/{id}/permanent', [FileStorageController::class, 'permanentDelete'])->name('v1.files.permanent'); // 파일 영구 삭제 + Route::post('/{id}/share', [FileStorageController::class, 'createShareLink'])->name('v1.files.share'); // 공유 링크 생성 +}); + +// 저장소 사용량 +Route::get('/storage/usage', [FileStorageController::class, 'storageUsage'])->name('v1.storage.usage'); + +// 폴더 관리 API +Route::prefix('folders')->group(function () { + Route::get('/', [FolderController::class, 'index'])->name('v1.folders.index'); // 폴더 목록 + Route::post('/', [FolderController::class, 'store'])->name('v1.folders.store'); // 폴더 생성 + Route::get('/{id}', [FolderController::class, 'show'])->name('v1.folders.show'); // 폴더 상세 + Route::put('/{id}', [FolderController::class, 'update'])->name('v1.folders.update'); // 폴더 수정 + Route::delete('/{id}', [FolderController::class, 'destroy'])->name('v1.folders.destroy'); // 폴더 삭제/비활성화 + Route::post('/reorder', [FolderController::class, 'reorder'])->name('v1.folders.reorder'); // 폴더 순서 변경 +}); + +// 공유 링크 다운로드 (인증 불필요 - 메인 api.php에서 v1 그룹 밖으로 분리) +// Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download'); diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php new file mode 100644 index 0000000..2d03aca --- /dev/null +++ b/routes/api/v1/finance.php @@ -0,0 +1,298 @@ +group(function () { + Route::get('', [CardController::class, 'index'])->name('v1.cards.index'); + Route::post('', [CardController::class, 'store'])->name('v1.cards.store'); + Route::get('/active', [CardController::class, 'active'])->name('v1.cards.active'); + Route::get('/{id}', [CardController::class, 'show'])->whereNumber('id')->name('v1.cards.show'); + Route::put('/{id}', [CardController::class, 'update'])->whereNumber('id')->name('v1.cards.update'); + Route::delete('/{id}', [CardController::class, 'destroy'])->whereNumber('id')->name('v1.cards.destroy'); + Route::patch('/{id}/toggle', [CardController::class, 'toggle'])->whereNumber('id')->name('v1.cards.toggle'); +}); + +// BankAccount API (계좌 관리) +Route::prefix('bank-accounts')->group(function () { + Route::get('', [BankAccountController::class, 'index'])->name('v1.bank-accounts.index'); + Route::post('', [BankAccountController::class, 'store'])->name('v1.bank-accounts.store'); + Route::get('/active', [BankAccountController::class, 'active'])->name('v1.bank-accounts.active'); + Route::get('/{id}', [BankAccountController::class, 'show'])->whereNumber('id')->name('v1.bank-accounts.show'); + Route::put('/{id}', [BankAccountController::class, 'update'])->whereNumber('id')->name('v1.bank-accounts.update'); + Route::delete('/{id}', [BankAccountController::class, 'destroy'])->whereNumber('id')->name('v1.bank-accounts.destroy'); + Route::patch('/{id}/toggle', [BankAccountController::class, 'toggle'])->whereNumber('id')->name('v1.bank-accounts.toggle'); + Route::patch('/{id}/set-primary', [BankAccountController::class, 'setPrimary'])->whereNumber('id')->name('v1.bank-accounts.set-primary'); +}); + +// Deposit API (입금 관리) +Route::prefix('deposits')->group(function () { + Route::get('', [DepositController::class, 'index'])->name('v1.deposits.index'); + Route::post('', [DepositController::class, 'store'])->name('v1.deposits.store'); + Route::get('/summary', [DepositController::class, 'summary'])->name('v1.deposits.summary'); + Route::post('/bulk-update-account-code', [DepositController::class, 'bulkUpdateAccountCode'])->name('v1.deposits.bulk-update-account-code'); + Route::get('/{id}', [DepositController::class, 'show'])->whereNumber('id')->name('v1.deposits.show'); + Route::put('/{id}', [DepositController::class, 'update'])->whereNumber('id')->name('v1.deposits.update'); + Route::delete('/{id}', [DepositController::class, 'destroy'])->whereNumber('id')->name('v1.deposits.destroy'); +}); + +// Withdrawal API (출금 관리) +Route::prefix('withdrawals')->group(function () { + Route::get('', [WithdrawalController::class, 'index'])->name('v1.withdrawals.index'); + Route::post('', [WithdrawalController::class, 'store'])->name('v1.withdrawals.store'); + Route::get('/summary', [WithdrawalController::class, 'summary'])->name('v1.withdrawals.summary'); + Route::post('/bulk-update-account-code', [WithdrawalController::class, 'bulkUpdateAccountCode'])->name('v1.withdrawals.bulk-update-account-code'); + Route::get('/{id}', [WithdrawalController::class, 'show'])->whereNumber('id')->name('v1.withdrawals.show'); + Route::put('/{id}', [WithdrawalController::class, 'update'])->whereNumber('id')->name('v1.withdrawals.update'); + Route::delete('/{id}', [WithdrawalController::class, 'destroy'])->whereNumber('id')->name('v1.withdrawals.destroy'); +}); + +// Payroll API (급여 관리) +Route::prefix('payrolls')->group(function () { + Route::get('', [PayrollController::class, 'index'])->name('v1.payrolls.index'); + Route::post('', [PayrollController::class, 'store'])->name('v1.payrolls.store'); + Route::get('/summary', [PayrollController::class, 'summary'])->name('v1.payrolls.summary'); + Route::post('/calculate', [PayrollController::class, 'calculate'])->name('v1.payrolls.calculate'); + Route::post('/bulk-confirm', [PayrollController::class, 'bulkConfirm'])->name('v1.payrolls.bulk-confirm'); + Route::get('/{id}', [PayrollController::class, 'show'])->whereNumber('id')->name('v1.payrolls.show'); + Route::put('/{id}', [PayrollController::class, 'update'])->whereNumber('id')->name('v1.payrolls.update'); + Route::delete('/{id}', [PayrollController::class, 'destroy'])->whereNumber('id')->name('v1.payrolls.destroy'); + Route::post('/{id}/confirm', [PayrollController::class, 'confirm'])->whereNumber('id')->name('v1.payrolls.confirm'); + Route::post('/{id}/pay', [PayrollController::class, 'pay'])->whereNumber('id')->name('v1.payrolls.pay'); + Route::get('/{id}/payslip', [PayrollController::class, 'payslip'])->whereNumber('id')->name('v1.payrolls.payslip'); +}); + +// Salary API (급여 관리 - React 연동) +Route::prefix('salaries')->group(function () { + Route::get('', [SalaryController::class, 'index'])->name('v1.salaries.index'); + Route::post('', [SalaryController::class, 'store'])->name('v1.salaries.store'); + Route::get('/statistics', [SalaryController::class, 'statistics'])->name('v1.salaries.statistics'); + Route::get('/export', [SalaryController::class, 'export'])->name('v1.salaries.export'); + Route::post('/bulk-update-status', [SalaryController::class, 'bulkUpdateStatus'])->name('v1.salaries.bulk-update-status'); + Route::get('/{id}', [SalaryController::class, 'show'])->whereNumber('id')->name('v1.salaries.show'); + Route::put('/{id}', [SalaryController::class, 'update'])->whereNumber('id')->name('v1.salaries.update'); + Route::delete('/{id}', [SalaryController::class, 'destroy'])->whereNumber('id')->name('v1.salaries.destroy'); + Route::patch('/{id}/status', [SalaryController::class, 'updateStatus'])->whereNumber('id')->name('v1.salaries.update-status'); +}); + +// Expected Expense API (미지급비용 관리) +Route::prefix('expected-expenses')->group(function () { + Route::get('', [ExpectedExpenseController::class, 'index'])->name('v1.expected-expenses.index'); + Route::post('', [ExpectedExpenseController::class, 'store'])->name('v1.expected-expenses.store'); + Route::get('/summary', [ExpectedExpenseController::class, 'summary'])->name('v1.expected-expenses.summary'); + Route::get('/dashboard-detail', [ExpectedExpenseController::class, 'dashboardDetail'])->name('v1.expected-expenses.dashboard-detail'); + Route::delete('', [ExpectedExpenseController::class, 'destroyMany'])->name('v1.expected-expenses.destroy-many'); + Route::put('/update-payment-date', [ExpectedExpenseController::class, 'updateExpectedPaymentDate'])->name('v1.expected-expenses.update-payment-date'); + Route::get('/{id}', [ExpectedExpenseController::class, 'show'])->whereNumber('id')->name('v1.expected-expenses.show'); + Route::put('/{id}', [ExpectedExpenseController::class, 'update'])->whereNumber('id')->name('v1.expected-expenses.update'); + Route::delete('/{id}', [ExpectedExpenseController::class, 'destroy'])->whereNumber('id')->name('v1.expected-expenses.destroy'); +}); + +// Loan API (가지급금 관리) +Route::prefix('loans')->group(function () { + Route::get('', [LoanController::class, 'index'])->name('v1.loans.index'); + Route::post('', [LoanController::class, 'store'])->name('v1.loans.store'); + Route::get('/summary', [LoanController::class, 'summary'])->name('v1.loans.summary'); + Route::get('/dashboard', [LoanController::class, 'dashboard'])->name('v1.loans.dashboard'); + Route::get('/tax-simulation', [LoanController::class, 'taxSimulation'])->name('v1.loans.tax-simulation'); + Route::post('/calculate-interest', [LoanController::class, 'calculateInterest'])->name('v1.loans.calculate-interest'); + Route::get('/interest-report/{year}', [LoanController::class, 'interestReport'])->whereNumber('year')->name('v1.loans.interest-report'); + Route::get('/{id}', [LoanController::class, 'show'])->whereNumber('id')->name('v1.loans.show'); + Route::put('/{id}', [LoanController::class, 'update'])->whereNumber('id')->name('v1.loans.update'); + Route::delete('/{id}', [LoanController::class, 'destroy'])->whereNumber('id')->name('v1.loans.destroy'); + Route::post('/{id}/settle', [LoanController::class, 'settle'])->whereNumber('id')->name('v1.loans.settle'); +}); + +// Vendor Ledger API (거래처원장) +Route::prefix('vendor-ledger')->group(function () { + Route::get('', [VendorLedgerController::class, 'index'])->name('v1.vendor-ledger.index'); + Route::get('/summary', [VendorLedgerController::class, 'summary'])->name('v1.vendor-ledger.summary'); + Route::get('/{clientId}', [VendorLedgerController::class, 'show'])->whereNumber('clientId')->name('v1.vendor-ledger.show'); +}); + +// Card Transaction API (카드 거래) +Route::prefix('card-transactions')->group(function () { + Route::get('', [CardTransactionController::class, 'index'])->name('v1.card-transactions.index'); + Route::get('/summary', [CardTransactionController::class, 'summary'])->name('v1.card-transactions.summary'); + Route::get('/dashboard', [CardTransactionController::class, 'dashboard'])->name('v1.card-transactions.dashboard'); + Route::post('', [CardTransactionController::class, 'store'])->name('v1.card-transactions.store'); + Route::put('/bulk-update-account', [CardTransactionController::class, 'bulkUpdateAccountCode'])->name('v1.card-transactions.bulk-update-account'); + Route::get('/{id}', [CardTransactionController::class, 'show'])->whereNumber('id')->name('v1.card-transactions.show'); + Route::put('/{id}', [CardTransactionController::class, 'update'])->whereNumber('id')->name('v1.card-transactions.update'); + Route::delete('/{id}', [CardTransactionController::class, 'destroy'])->whereNumber('id')->name('v1.card-transactions.destroy'); +}); + +// Bank Transaction API (은행 거래 조회) +Route::prefix('bank-transactions')->group(function () { + Route::get('', [BankTransactionController::class, 'index'])->name('v1.bank-transactions.index'); + Route::get('/summary', [BankTransactionController::class, 'summary'])->name('v1.bank-transactions.summary'); + Route::get('/accounts', [BankTransactionController::class, 'accounts'])->name('v1.bank-transactions.accounts'); +}); + +// Receivables API (채권 현황) +Route::prefix('receivables')->group(function () { + Route::get('', [ReceivablesController::class, 'index'])->name('v1.receivables.index'); + Route::get('/summary', [ReceivablesController::class, 'summary'])->name('v1.receivables.summary'); + Route::put('/overdue-status', [ReceivablesController::class, 'updateOverdueStatus'])->name('v1.receivables.update-overdue-status'); + Route::put('/memos', [ReceivablesController::class, 'updateMemos'])->name('v1.receivables.update-memos'); +}); + +// Daily Report API (일일 보고서) +Route::prefix('daily-report')->group(function () { + Route::get('/note-receivables', [DailyReportController::class, 'noteReceivables'])->name('v1.daily-report.note-receivables'); + Route::get('/daily-accounts', [DailyReportController::class, 'dailyAccounts'])->name('v1.daily-report.daily-accounts'); + Route::get('/summary', [DailyReportController::class, 'summary'])->name('v1.daily-report.summary'); +}); + +// Comprehensive Analysis API (종합 분석 보고서) +Route::get('/comprehensive-analysis', [ComprehensiveAnalysisController::class, 'index'])->name('v1.comprehensive-analysis.index'); + +// Status Board API (CEO 대시보드 현황판) +Route::get('/status-board/summary', [StatusBoardController::class, 'summary'])->name('v1.status-board.summary'); + +// Today Issue API (CEO 대시보드 오늘의 이슈 리스트) +Route::get('/today-issues/summary', [TodayIssueController::class, 'summary'])->name('v1.today-issues.summary'); +Route::get('/today-issues/unread', [TodayIssueController::class, 'unread'])->name('v1.today-issues.unread'); +Route::get('/today-issues/unread/count', [TodayIssueController::class, 'unreadCount'])->name('v1.today-issues.unread.count'); +Route::post('/today-issues/{id}/read', [TodayIssueController::class, 'markAsRead'])->whereNumber('id')->name('v1.today-issues.read'); +Route::post('/today-issues/read-all', [TodayIssueController::class, 'markAllAsRead'])->name('v1.today-issues.read-all'); + +// Calendar API (CEO 대시보드 캘린더) +Route::get('/calendar/schedules', [CalendarController::class, 'summary'])->name('v1.calendar.schedules'); + +// Vat API (CEO 대시보드 부가세 현황) +Route::get('/vat/summary', [VatController::class, 'summary'])->name('v1.vat.summary'); + +// Entertainment API (CEO 대시보드 접대비 현황) +Route::get('/entertainment/summary', [EntertainmentController::class, 'summary'])->name('v1.entertainment.summary'); + +// Welfare API (CEO 대시보드 복리후생비 현황) +Route::get('/welfare/summary', [WelfareController::class, 'summary'])->name('v1.welfare.summary'); +Route::get('/welfare/detail', [WelfareController::class, 'detail'])->name('v1.welfare.detail'); + +// Plan API (요금제 관리) +Route::prefix('plans')->group(function () { + Route::get('', [PlanController::class, 'index'])->name('v1.plans.index'); + Route::post('', [PlanController::class, 'store'])->name('v1.plans.store'); + Route::get('/active', [PlanController::class, 'active'])->name('v1.plans.active'); + Route::get('/{id}', [PlanController::class, 'show'])->whereNumber('id')->name('v1.plans.show'); + Route::put('/{id}', [PlanController::class, 'update'])->whereNumber('id')->name('v1.plans.update'); + Route::delete('/{id}', [PlanController::class, 'destroy'])->whereNumber('id')->name('v1.plans.destroy'); + Route::patch('/{id}/toggle', [PlanController::class, 'toggle'])->whereNumber('id')->name('v1.plans.toggle'); +}); + +// Subscription API (구독 관리) +Route::prefix('subscriptions')->group(function () { + Route::get('', [SubscriptionController::class, 'index'])->name('v1.subscriptions.index'); + Route::post('', [SubscriptionController::class, 'store'])->name('v1.subscriptions.store'); + Route::get('/current', [SubscriptionController::class, 'current'])->name('v1.subscriptions.current'); + Route::get('/usage', [SubscriptionController::class, 'usage'])->name('v1.subscriptions.usage'); + Route::post('/export', [SubscriptionController::class, 'export'])->name('v1.subscriptions.export'); + Route::get('/export/{id}', [SubscriptionController::class, 'exportStatus'])->whereNumber('id')->name('v1.subscriptions.export.status'); + Route::get('/{id}', [SubscriptionController::class, 'show'])->whereNumber('id')->name('v1.subscriptions.show'); + Route::post('/{id}/cancel', [SubscriptionController::class, 'cancel'])->whereNumber('id')->name('v1.subscriptions.cancel'); + Route::post('/{id}/renew', [SubscriptionController::class, 'renew'])->whereNumber('id')->name('v1.subscriptions.renew'); + Route::post('/{id}/suspend', [SubscriptionController::class, 'suspend'])->whereNumber('id')->name('v1.subscriptions.suspend'); + Route::post('/{id}/resume', [SubscriptionController::class, 'resume'])->whereNumber('id')->name('v1.subscriptions.resume'); +}); + +// Payment API (결제 관리) +Route::prefix('payments')->group(function () { + Route::get('', [PaymentController::class, 'index'])->name('v1.payments.index'); + Route::post('', [PaymentController::class, 'store'])->name('v1.payments.store'); + Route::get('/summary', [PaymentController::class, 'summary'])->name('v1.payments.summary'); + Route::get('/{id}', [PaymentController::class, 'show'])->whereNumber('id')->name('v1.payments.show'); + Route::post('/{id}/complete', [PaymentController::class, 'complete'])->whereNumber('id')->name('v1.payments.complete'); + Route::post('/{id}/cancel', [PaymentController::class, 'cancel'])->whereNumber('id')->name('v1.payments.cancel'); + Route::post('/{id}/refund', [PaymentController::class, 'refund'])->whereNumber('id')->name('v1.payments.refund'); + Route::get('/{id}/statement', [PaymentController::class, 'statement'])->whereNumber('id')->name('v1.payments.statement'); +}); + +// Barobill Setting API (바로빌 설정) +Route::prefix('barobill-settings')->group(function () { + Route::get('', [BarobillSettingController::class, 'show'])->name('v1.barobill-settings.show'); + Route::put('', [BarobillSettingController::class, 'save'])->name('v1.barobill-settings.save'); + Route::post('/test-connection', [BarobillSettingController::class, 'testConnection'])->name('v1.barobill-settings.test-connection'); +}); + +// Tax Invoice API (세금계산서) +Route::prefix('tax-invoices')->group(function () { + Route::get('', [TaxInvoiceController::class, 'index'])->name('v1.tax-invoices.index'); + Route::post('', [TaxInvoiceController::class, 'store'])->name('v1.tax-invoices.store'); + Route::get('/summary', [TaxInvoiceController::class, 'summary'])->name('v1.tax-invoices.summary'); + Route::get('/{id}', [TaxInvoiceController::class, 'show'])->whereNumber('id')->name('v1.tax-invoices.show'); + Route::put('/{id}', [TaxInvoiceController::class, 'update'])->whereNumber('id')->name('v1.tax-invoices.update'); + Route::delete('/{id}', [TaxInvoiceController::class, 'destroy'])->whereNumber('id')->name('v1.tax-invoices.destroy'); + Route::post('/{id}/issue', [TaxInvoiceController::class, 'issue'])->whereNumber('id')->name('v1.tax-invoices.issue'); + Route::post('/{id}/cancel', [TaxInvoiceController::class, 'cancel'])->whereNumber('id')->name('v1.tax-invoices.cancel'); + Route::get('/{id}/check-status', [TaxInvoiceController::class, 'checkStatus'])->whereNumber('id')->name('v1.tax-invoices.check-status'); + Route::post('/bulk-issue', [TaxInvoiceController::class, 'bulkIssue'])->name('v1.tax-invoices.bulk-issue'); +}); + +// Bad Debt API (악성채권 추심관리) +Route::prefix('bad-debts')->group(function () { + Route::get('', [BadDebtController::class, 'index'])->name('v1.bad-debts.index'); + Route::post('', [BadDebtController::class, 'store'])->name('v1.bad-debts.store'); + Route::get('/summary', [BadDebtController::class, 'summary'])->name('v1.bad-debts.summary'); + Route::get('/{id}', [BadDebtController::class, 'show'])->whereNumber('id')->name('v1.bad-debts.show'); + Route::put('/{id}', [BadDebtController::class, 'update'])->whereNumber('id')->name('v1.bad-debts.update'); + Route::delete('/{id}', [BadDebtController::class, 'destroy'])->whereNumber('id')->name('v1.bad-debts.destroy'); + Route::patch('/{id}/toggle', [BadDebtController::class, 'toggle'])->whereNumber('id')->name('v1.bad-debts.toggle'); + // 서류 + Route::post('/{id}/documents', [BadDebtController::class, 'addDocument'])->whereNumber('id')->name('v1.bad-debts.documents.store'); + Route::delete('/{id}/documents/{documentId}', [BadDebtController::class, 'removeDocument'])->whereNumber(['id', 'documentId'])->name('v1.bad-debts.documents.destroy'); + // 메모 + Route::post('/{id}/memos', [BadDebtController::class, 'addMemo'])->whereNumber('id')->name('v1.bad-debts.memos.store'); + Route::delete('/{id}/memos/{memoId}', [BadDebtController::class, 'removeMemo'])->whereNumber(['id', 'memoId'])->name('v1.bad-debts.memos.destroy'); +}); + +// Bill API (어음관리) +Route::prefix('bills')->group(function () { + Route::get('', [BillController::class, 'index'])->name('v1.bills.index'); + Route::post('', [BillController::class, 'store'])->name('v1.bills.store'); + Route::get('/summary', [BillController::class, 'summary'])->name('v1.bills.summary'); + Route::get('/dashboard-detail', [BillController::class, 'dashboardDetail'])->name('v1.bills.dashboard-detail'); + Route::get('/{id}', [BillController::class, 'show'])->whereNumber('id')->name('v1.bills.show'); + Route::put('/{id}', [BillController::class, 'update'])->whereNumber('id')->name('v1.bills.update'); + Route::delete('/{id}', [BillController::class, 'destroy'])->whereNumber('id')->name('v1.bills.destroy'); + Route::patch('/{id}/status', [BillController::class, 'updateStatus'])->whereNumber('id')->name('v1.bills.update-status'); +}); diff --git a/routes/api/v1/hr.php b/routes/api/v1/hr.php new file mode 100644 index 0000000..4bcf9cf --- /dev/null +++ b/routes/api/v1/hr.php @@ -0,0 +1,215 @@ +group(function () { + Route::get('', [DepartmentController::class, 'index'])->name('v1.departments.index'); // 목록 + Route::post('', [DepartmentController::class, 'store'])->name('v1.departments.store'); // 생성 + Route::get('/tree', [DepartmentController::class, 'tree'])->name('v1.departments.tree'); // 트리 + Route::get('/{id}', [DepartmentController::class, 'show'])->name('v1.departments.show'); // 단건 + Route::patch('/{id}', [DepartmentController::class, 'update'])->name('v1.departments.update'); // 수정 + Route::delete('/{id}', [DepartmentController::class, 'destroy'])->name('v1.departments.destroy'); // 삭제(soft) + + // 부서-사용자 + Route::get('/{id}/users', [DepartmentController::class, 'listUsers'])->name('v1.departments.users.index'); // 부서 사용자 목록 + Route::post('/{id}/users', [DepartmentController::class, 'attachUser'])->name('v1.departments.users.attach'); // 사용자 배정(주/부서) + Route::delete('/{id}/users/{user}', [DepartmentController::class, 'detachUser'])->name('v1.departments.users.detach'); // 사용자 제거 + Route::patch('/{id}/users/{user}/primary', [DepartmentController::class, 'setPrimary'])->name('v1.departments.users.primary'); // 주부서 설정/해제 + + // 부서-권한 + Route::get('/{id}/permissions', [DepartmentController::class, 'listPermissions'])->name('v1.departments.permissions.index'); // 권한 목록 + Route::post('/{id}/permissions', [DepartmentController::class, 'upsertPermissions'])->name('v1.departments.permissions.upsert'); // 권한 부여/차단(메뉴별 가능) + Route::delete('/{id}/permissions/{permission}', [DepartmentController::class, 'revokePermissions'])->name('v1.departments.permissions.revoke'); // 권한 제거(해당 메뉴 범위까지) +}); + +// Position API (직급/직책 통합 관리) +Route::prefix('positions')->group(function () { + Route::get('', [PositionController::class, 'index'])->name('v1.positions.index'); + Route::post('', [PositionController::class, 'store'])->name('v1.positions.store'); + Route::put('/reorder', [PositionController::class, 'reorder'])->name('v1.positions.reorder'); + Route::get('/{id}', [PositionController::class, 'show'])->name('v1.positions.show'); + Route::put('/{id}', [PositionController::class, 'update'])->name('v1.positions.update'); + Route::delete('/{id}', [PositionController::class, 'destroy'])->name('v1.positions.destroy'); +}); + +// Employee API (사원 관리) +Route::prefix('employees')->group(function () { + Route::get('', [EmployeeController::class, 'index'])->name('v1.employees.index'); + Route::post('', [EmployeeController::class, 'store'])->name('v1.employees.store'); + Route::get('/stats', [EmployeeController::class, 'stats'])->name('v1.employees.stats'); + Route::get('/{id}', [EmployeeController::class, 'show'])->name('v1.employees.show'); + Route::patch('/{id}', [EmployeeController::class, 'update'])->name('v1.employees.update'); + Route::delete('/{id}', [EmployeeController::class, 'destroy'])->name('v1.employees.destroy'); + Route::post('/bulk-delete', [EmployeeController::class, 'bulkDelete'])->name('v1.employees.bulkDelete'); + Route::post('/{id}/create-account', [EmployeeController::class, 'createAccount'])->name('v1.employees.createAccount'); + Route::post('/{id}/revoke-account', [EmployeeController::class, 'revokeAccount'])->name('v1.employees.revokeAccount'); +}); + +// Attendance API (근태 관리) +Route::prefix('attendances')->group(function () { + Route::get('', [AttendanceController::class, 'index'])->name('v1.attendances.index'); + Route::post('', [AttendanceController::class, 'store'])->name('v1.attendances.store'); + Route::get('/monthly-stats', [AttendanceController::class, 'monthlyStats'])->name('v1.attendances.monthlyStats'); + Route::get('/export', [AttendanceController::class, 'export'])->name('v1.attendances.export'); + Route::post('/check-in', [AttendanceController::class, 'checkIn'])->name('v1.attendances.checkIn'); + Route::post('/check-out', [AttendanceController::class, 'checkOut'])->name('v1.attendances.checkOut'); + Route::get('/{id}', [AttendanceController::class, 'show'])->name('v1.attendances.show'); + Route::patch('/{id}', [AttendanceController::class, 'update'])->name('v1.attendances.update'); + Route::delete('/{id}', [AttendanceController::class, 'destroy'])->name('v1.attendances.destroy'); + Route::post('/bulk-delete', [AttendanceController::class, 'bulkDelete'])->name('v1.attendances.bulkDelete'); +}); + +// Leave API (휴가 관리) +Route::prefix('leaves')->group(function () { + Route::get('', [LeaveController::class, 'index'])->name('v1.leaves.index'); + Route::post('', [LeaveController::class, 'store'])->name('v1.leaves.store'); + Route::get('/balances', [LeaveController::class, 'balances'])->name('v1.leaves.balances'); + Route::get('/balance', [LeaveController::class, 'balance'])->name('v1.leaves.balance'); + Route::get('/balance/{userId}', [LeaveController::class, 'userBalance'])->name('v1.leaves.userBalance'); + Route::put('/balance', [LeaveController::class, 'setBalance'])->name('v1.leaves.setBalance'); + Route::get('/grants', [LeaveController::class, 'grants'])->name('v1.leaves.grants'); + Route::post('/grants', [LeaveController::class, 'storeGrant'])->name('v1.leaves.grants.store'); + Route::delete('/grants/{id}', [LeaveController::class, 'destroyGrant'])->name('v1.leaves.grants.destroy'); + Route::get('/{id}', [LeaveController::class, 'show'])->name('v1.leaves.show'); + Route::patch('/{id}', [LeaveController::class, 'update'])->name('v1.leaves.update'); + Route::delete('/{id}', [LeaveController::class, 'destroy'])->name('v1.leaves.destroy'); + Route::post('/{id}/approve', [LeaveController::class, 'approve'])->name('v1.leaves.approve'); + Route::post('/{id}/reject', [LeaveController::class, 'reject'])->name('v1.leaves.reject'); + Route::post('/{id}/cancel', [LeaveController::class, 'cancel'])->name('v1.leaves.cancel'); +}); + +// Leave Policy API (휴가 정책) +Route::get('/leave-policy', [LeavePolicyController::class, 'show'])->name('v1.leave-policy.show'); +Route::put('/leave-policy', [LeavePolicyController::class, 'update'])->name('v1.leave-policy.update'); + +// Approval Form API (결재 양식) +Route::prefix('approval-forms')->group(function () { + Route::get('', [ApprovalFormController::class, 'index'])->name('v1.approval-forms.index'); + Route::post('', [ApprovalFormController::class, 'store'])->name('v1.approval-forms.store'); + Route::get('/active', [ApprovalFormController::class, 'active'])->name('v1.approval-forms.active'); + Route::get('/{id}', [ApprovalFormController::class, 'show'])->whereNumber('id')->name('v1.approval-forms.show'); + Route::patch('/{id}', [ApprovalFormController::class, 'update'])->whereNumber('id')->name('v1.approval-forms.update'); + Route::delete('/{id}', [ApprovalFormController::class, 'destroy'])->whereNumber('id')->name('v1.approval-forms.destroy'); +}); + +// Approval Line API (결재선) +Route::prefix('approval-lines')->group(function () { + Route::get('', [ApprovalLineController::class, 'index'])->name('v1.approval-lines.index'); + Route::post('', [ApprovalLineController::class, 'store'])->name('v1.approval-lines.store'); + Route::get('/{id}', [ApprovalLineController::class, 'show'])->whereNumber('id')->name('v1.approval-lines.show'); + Route::patch('/{id}', [ApprovalLineController::class, 'update'])->whereNumber('id')->name('v1.approval-lines.update'); + Route::delete('/{id}', [ApprovalLineController::class, 'destroy'])->whereNumber('id')->name('v1.approval-lines.destroy'); +}); + +// Approval API (전자결재) +Route::prefix('approvals')->group(function () { + // 기안함 + Route::get('/drafts', [ApprovalController::class, 'drafts'])->name('v1.approvals.drafts'); + Route::get('/drafts/summary', [ApprovalController::class, 'draftsSummary'])->name('v1.approvals.drafts.summary'); + // 결재함 + Route::get('/inbox', [ApprovalController::class, 'inbox'])->name('v1.approvals.inbox'); + Route::get('/inbox/summary', [ApprovalController::class, 'inboxSummary'])->name('v1.approvals.inbox.summary'); + // 참조함 + Route::get('/reference', [ApprovalController::class, 'reference'])->name('v1.approvals.reference'); + // CRUD + Route::post('', [ApprovalController::class, 'store'])->name('v1.approvals.store'); + Route::get('/{id}', [ApprovalController::class, 'show'])->whereNumber('id')->name('v1.approvals.show'); + Route::patch('/{id}', [ApprovalController::class, 'update'])->whereNumber('id')->name('v1.approvals.update'); + Route::delete('/{id}', [ApprovalController::class, 'destroy'])->whereNumber('id')->name('v1.approvals.destroy'); + // 액션 + Route::post('/{id}/submit', [ApprovalController::class, 'submit'])->whereNumber('id')->name('v1.approvals.submit'); + Route::post('/{id}/approve', [ApprovalController::class, 'approve'])->whereNumber('id')->name('v1.approvals.approve'); + Route::post('/{id}/reject', [ApprovalController::class, 'reject'])->whereNumber('id')->name('v1.approvals.reject'); + Route::post('/{id}/cancel', [ApprovalController::class, 'cancel'])->whereNumber('id')->name('v1.approvals.cancel'); + // 참조 열람 + Route::post('/{id}/read', [ApprovalController::class, 'markRead'])->whereNumber('id')->name('v1.approvals.read'); + Route::post('/{id}/unread', [ApprovalController::class, 'markUnread'])->whereNumber('id')->name('v1.approvals.unread'); +}); + +// Site API (현장 관리) +Route::prefix('sites')->group(function () { + Route::get('', [SiteController::class, 'index'])->name('v1.sites.index'); + Route::post('', [SiteController::class, 'store'])->name('v1.sites.store'); + Route::get('/stats', [SiteController::class, 'stats'])->name('v1.sites.stats'); + Route::get('/active', [SiteController::class, 'active'])->name('v1.sites.active'); + Route::delete('/bulk', [SiteController::class, 'bulkDestroy'])->name('v1.sites.bulk-destroy'); + Route::get('/{id}', [SiteController::class, 'show'])->whereNumber('id')->name('v1.sites.show'); + Route::put('/{id}', [SiteController::class, 'update'])->whereNumber('id')->name('v1.sites.update'); + Route::delete('/{id}', [SiteController::class, 'destroy'])->whereNumber('id')->name('v1.sites.destroy'); +}); + +// Site Briefing API (현장설명회 관리) +Route::prefix('site-briefings')->group(function () { + Route::get('', [SiteBriefingController::class, 'index'])->name('v1.site-briefings.index'); + Route::post('', [SiteBriefingController::class, 'store'])->name('v1.site-briefings.store'); + Route::get('/stats', [SiteBriefingController::class, 'stats'])->name('v1.site-briefings.stats'); + Route::delete('/bulk', [SiteBriefingController::class, 'bulkDestroy'])->name('v1.site-briefings.bulk-destroy'); + Route::get('/{id}', [SiteBriefingController::class, 'show'])->whereNumber('id')->name('v1.site-briefings.show'); + Route::put('/{id}', [SiteBriefingController::class, 'update'])->whereNumber('id')->name('v1.site-briefings.update'); + Route::delete('/{id}', [SiteBriefingController::class, 'destroy'])->whereNumber('id')->name('v1.site-briefings.destroy'); +}); + +// Construction API (시공관리) +Route::prefix('construction')->group(function () { + // Contract API (계약관리) + Route::prefix('contracts')->group(function () { + Route::get('', [ContractController::class, 'index'])->name('v1.construction.contracts.index'); + Route::post('', [ContractController::class, 'store'])->name('v1.construction.contracts.store'); + Route::get('/stats', [ContractController::class, 'stats'])->name('v1.construction.contracts.stats'); + Route::get('/stage-counts', [ContractController::class, 'stageCounts'])->name('v1.construction.contracts.stage-counts'); + Route::delete('/bulk', [ContractController::class, 'bulkDestroy'])->name('v1.construction.contracts.bulk-destroy'); + Route::post('/from-bidding/{biddingId}', [ContractController::class, 'storeFromBidding'])->whereNumber('biddingId')->name('v1.construction.contracts.store-from-bidding'); + Route::get('/{id}', [ContractController::class, 'show'])->whereNumber('id')->name('v1.construction.contracts.show'); + Route::put('/{id}', [ContractController::class, 'update'])->whereNumber('id')->name('v1.construction.contracts.update'); + Route::delete('/{id}', [ContractController::class, 'destroy'])->whereNumber('id')->name('v1.construction.contracts.destroy'); + }); + + // HandoverReport API (인수인계보고서관리) + Route::prefix('handover-reports')->group(function () { + Route::get('', [HandoverReportController::class, 'index'])->name('v1.construction.handover-reports.index'); + Route::post('', [HandoverReportController::class, 'store'])->name('v1.construction.handover-reports.store'); + Route::get('/stats', [HandoverReportController::class, 'stats'])->name('v1.construction.handover-reports.stats'); + Route::delete('/bulk', [HandoverReportController::class, 'bulkDestroy'])->name('v1.construction.handover-reports.bulk-destroy'); + Route::get('/{id}', [HandoverReportController::class, 'show'])->whereNumber('id')->name('v1.construction.handover-reports.show'); + Route::put('/{id}', [HandoverReportController::class, 'update'])->whereNumber('id')->name('v1.construction.handover-reports.update'); + Route::delete('/{id}', [HandoverReportController::class, 'destroy'])->whereNumber('id')->name('v1.construction.handover-reports.destroy'); + }); + + // StructureReview API (구조검토관리) + Route::prefix('structure-reviews')->group(function () { + Route::get('', [StructureReviewController::class, 'index'])->name('v1.construction.structure-reviews.index'); + Route::post('', [StructureReviewController::class, 'store'])->name('v1.construction.structure-reviews.store'); + Route::get('/stats', [StructureReviewController::class, 'stats'])->name('v1.construction.structure-reviews.stats'); + Route::delete('/bulk', [StructureReviewController::class, 'bulkDestroy'])->name('v1.construction.structure-reviews.bulk-destroy'); + Route::get('/{id}', [StructureReviewController::class, 'show'])->whereNumber('id')->name('v1.construction.structure-reviews.show'); + Route::put('/{id}', [StructureReviewController::class, 'update'])->whereNumber('id')->name('v1.construction.structure-reviews.update'); + Route::delete('/{id}', [StructureReviewController::class, 'destroy'])->whereNumber('id')->name('v1.construction.structure-reviews.destroy'); + }); +}); diff --git a/routes/api/v1/inventory.php b/routes/api/v1/inventory.php new file mode 100644 index 0000000..4dbde58 --- /dev/null +++ b/routes/api/v1/inventory.php @@ -0,0 +1,117 @@ +group(function () { + Route::get('', [ItemsController::class, 'index'])->name('v1.items.index'); + Route::post('', [ItemsController::class, 'store'])->name('v1.items.store'); + Route::get('/options', [ItemsController::class, 'options'])->name('v1.items.options'); + Route::get('/stats', [ItemsController::class, 'stats'])->name('v1.items.stats'); + Route::get('/stats-by-type', [ItemsController::class, 'statsByItemType'])->name('v1.items.stats-by-type'); + Route::delete('/bulk', [ItemsController::class, 'bulkDestroy'])->name('v1.items.bulk-destroy'); + Route::get('/{id}', [ItemsController::class, 'show'])->whereNumber('id')->name('v1.items.show'); + Route::put('/{id}', [ItemsController::class, 'update'])->whereNumber('id')->name('v1.items.update'); + Route::delete('/{id}', [ItemsController::class, 'destroy'])->whereNumber('id')->name('v1.items.destroy'); + Route::patch('/{id}/toggle', [ItemsController::class, 'toggle'])->whereNumber('id')->name('v1.items.toggle'); +}); + +// Items BOM API (품목 BOM) +Route::prefix('items/{id}/bom')->group(function () { + Route::get('', [ItemsBomController::class, 'index'])->whereNumber('id')->name('v1.items.bom.index'); // BOM 목록 + Route::post('', [ItemsBomController::class, 'store'])->whereNumber('id')->name('v1.items.bom.store'); // BOM 항목 추가 + Route::put('/bulk-upsert', [ItemsBomController::class, 'bulkUpsert'])->whereNumber('id')->name('v1.items.bom.bulk-upsert'); // BOM 일괄 저장 + Route::post('/reorder', [ItemsBomController::class, 'reorder'])->whereNumber('id')->name('v1.items.bom.reorder'); // BOM 순서 변경 + Route::get('/summary', [ItemsBomController::class, 'summary'])->whereNumber('id')->name('v1.items.bom.summary'); // BOM 요약 + Route::get('/validate', [ItemsBomController::class, 'validate'])->whereNumber('id')->name('v1.items.bom.validate'); // BOM 검증 + Route::get('/{bomId}', [ItemsBomController::class, 'show'])->whereNumber(['id', 'bomId'])->name('v1.items.bom.show'); // BOM 항목 상세 + Route::put('/{bomId}', [ItemsBomController::class, 'update'])->whereNumber(['id', 'bomId'])->name('v1.items.bom.update'); // BOM 항목 수정 + Route::delete('/{bomId}', [ItemsBomController::class, 'destroy'])->whereNumber(['id', 'bomId'])->name('v1.items.bom.destroy'); // BOM 항목 삭제 +}); + +// Items File API (품목 파일) +Route::prefix('items/{id}/files')->group(function () { + Route::get('', [ItemsFileController::class, 'index'])->whereNumber('id')->name('v1.items.files.index'); // 파일 목록 + Route::post('', [ItemsFileController::class, 'store'])->whereNumber('id')->name('v1.items.files.store'); // 파일 추가 + Route::get('/{fileId}', [ItemsFileController::class, 'show'])->whereNumber(['id', 'fileId'])->name('v1.items.files.show'); // 파일 상세 + Route::delete('/{fileId}', [ItemsFileController::class, 'destroy'])->whereNumber(['id', 'fileId'])->name('v1.items.files.destroy'); // 파일 삭제 +}); + +// Labor API (노무비 관리) +Route::prefix('labor')->group(function () { + Route::get('', [LaborController::class, 'index'])->name('v1.labor.index'); + Route::post('', [LaborController::class, 'store'])->name('v1.labor.store'); + Route::get('/active', [LaborController::class, 'active'])->name('v1.labor.active'); + Route::get('/summary', [LaborController::class, 'summary'])->name('v1.labor.summary'); + Route::post('/bulk-upsert', [LaborController::class, 'bulkUpsert'])->name('v1.labor.bulk-upsert'); + Route::get('/{id}', [LaborController::class, 'show'])->whereNumber('id')->name('v1.labor.show'); + Route::put('/{id}', [LaborController::class, 'update'])->whereNumber('id')->name('v1.labor.update'); + Route::delete('/{id}', [LaborController::class, 'destroy'])->whereNumber('id')->name('v1.labor.destroy'); +}); + +// Purchase API (매입 관리) +Route::prefix('purchases')->group(function () { + Route::get('', [PurchaseController::class, 'index'])->name('v1.purchases.index'); + Route::post('', [PurchaseController::class, 'store'])->name('v1.purchases.store'); + Route::get('/summary', [PurchaseController::class, 'summary'])->name('v1.purchases.summary'); + Route::get('/dashboard-detail', [PurchaseController::class, 'dashboardDetail'])->name('v1.purchases.dashboard-detail'); + Route::post('/bulk-update-type', [PurchaseController::class, 'bulkUpdatePurchaseType'])->name('v1.purchases.bulk-update-type'); + Route::post('/bulk-update-tax-received', [PurchaseController::class, 'bulkUpdateTaxReceived'])->name('v1.purchases.bulk-update-tax-received'); + Route::get('/{id}', [PurchaseController::class, 'show'])->whereNumber('id')->name('v1.purchases.show'); + Route::put('/{id}', [PurchaseController::class, 'update'])->whereNumber('id')->name('v1.purchases.update'); + Route::delete('/{id}', [PurchaseController::class, 'destroy'])->whereNumber('id')->name('v1.purchases.destroy'); + Route::post('/{id}/confirm', [PurchaseController::class, 'confirm'])->whereNumber('id')->name('v1.purchases.confirm'); +}); + +// Receiving API (입고 관리) +Route::prefix('receivings')->group(function () { + Route::get('', [ReceivingController::class, 'index'])->name('v1.receivings.index'); + Route::post('', [ReceivingController::class, 'store'])->name('v1.receivings.store'); + Route::get('/stats', [ReceivingController::class, 'stats'])->name('v1.receivings.stats'); + Route::get('/{id}', [ReceivingController::class, 'show'])->whereNumber('id')->name('v1.receivings.show'); + Route::put('/{id}', [ReceivingController::class, 'update'])->whereNumber('id')->name('v1.receivings.update'); + Route::delete('/{id}', [ReceivingController::class, 'destroy'])->whereNumber('id')->name('v1.receivings.destroy'); + Route::post('/{id}/process', [ReceivingController::class, 'process'])->whereNumber('id')->name('v1.receivings.process'); +}); + +// Stock API (재고 현황) +Route::prefix('stocks')->group(function () { + Route::get('', [StockController::class, 'index'])->name('v1.stocks.index'); + Route::get('/stats', [StockController::class, 'stats'])->name('v1.stocks.stats'); + Route::get('/stats-by-type', [StockController::class, 'statsByItemType'])->name('v1.stocks.stats-by-type'); + Route::get('/{id}', [StockController::class, 'show'])->whereNumber('id')->name('v1.stocks.show'); +}); + +// Shipment API (출하 관리) +Route::prefix('shipments')->group(function () { + Route::get('', [ShipmentController::class, 'index'])->name('v1.shipments.index'); + Route::get('/stats', [ShipmentController::class, 'stats'])->name('v1.shipments.stats'); + Route::get('/stats-by-status', [ShipmentController::class, 'statsByStatus'])->name('v1.shipments.stats-by-status'); + Route::get('/options/lots', [ShipmentController::class, 'lotOptions'])->name('v1.shipments.options.lots'); + Route::get('/options/logistics', [ShipmentController::class, 'logisticsOptions'])->name('v1.shipments.options.logistics'); + Route::get('/options/vehicle-tonnage', [ShipmentController::class, 'vehicleTonnageOptions'])->name('v1.shipments.options.vehicle-tonnage'); + Route::post('', [ShipmentController::class, 'store'])->name('v1.shipments.store'); + Route::get('/{id}', [ShipmentController::class, 'show'])->whereNumber('id')->name('v1.shipments.show'); + Route::put('/{id}', [ShipmentController::class, 'update'])->whereNumber('id')->name('v1.shipments.update'); + Route::patch('/{id}/status', [ShipmentController::class, 'updateStatus'])->whereNumber('id')->name('v1.shipments.status'); + Route::delete('/{id}', [ShipmentController::class, 'destroy'])->whereNumber('id')->name('v1.shipments.destroy'); +}); diff --git a/routes/api/v1/production.php b/routes/api/v1/production.php new file mode 100644 index 0000000..92aea15 --- /dev/null +++ b/routes/api/v1/production.php @@ -0,0 +1,84 @@ +group(function () { + Route::get('', [ProcessController::class, 'index'])->name('v1.processes.index'); + Route::get('/options', [ProcessController::class, 'options'])->name('v1.processes.options'); + Route::get('/stats', [ProcessController::class, 'stats'])->name('v1.processes.stats'); + Route::post('', [ProcessController::class, 'store'])->name('v1.processes.store'); + Route::delete('', [ProcessController::class, 'destroyMany'])->name('v1.processes.destroy-many'); + Route::get('/{id}', [ProcessController::class, 'show'])->whereNumber('id')->name('v1.processes.show'); + Route::put('/{id}', [ProcessController::class, 'update'])->whereNumber('id')->name('v1.processes.update'); + Route::delete('/{id}', [ProcessController::class, 'destroy'])->whereNumber('id')->name('v1.processes.destroy'); + Route::patch('/{id}/toggle', [ProcessController::class, 'toggleActive'])->whereNumber('id')->name('v1.processes.toggle'); +}); + +// Work Order API (작업지시 관리) +Route::prefix('work-orders')->group(function () { + // 기본 CRUD + Route::get('', [WorkOrderController::class, 'index'])->name('v1.work-orders.index'); // 목록 + Route::get('/stats', [WorkOrderController::class, 'stats'])->name('v1.work-orders.stats'); // 통계 + Route::post('', [WorkOrderController::class, 'store'])->name('v1.work-orders.store'); // 생성 + Route::get('/{id}', [WorkOrderController::class, 'show'])->whereNumber('id')->name('v1.work-orders.show'); // 상세 + Route::put('/{id}', [WorkOrderController::class, 'update'])->whereNumber('id')->name('v1.work-orders.update'); // 수정 + Route::delete('/{id}', [WorkOrderController::class, 'destroy'])->whereNumber('id')->name('v1.work-orders.destroy'); // 삭제 + + // 상태 및 담당자 관리 + Route::patch('/{id}/status', [WorkOrderController::class, 'updateStatus'])->whereNumber('id')->name('v1.work-orders.status'); // 상태 변경 + Route::patch('/{id}/assign', [WorkOrderController::class, 'assign'])->whereNumber('id')->name('v1.work-orders.assign'); // 담당자 배정 + + // 벤딩 공정 상세 토글 + Route::patch('/{id}/bending/toggle', [WorkOrderController::class, 'toggleBendingField'])->whereNumber('id')->name('v1.work-orders.bending-toggle'); + + // 이슈 관리 + Route::post('/{id}/issues', [WorkOrderController::class, 'addIssue'])->whereNumber('id')->name('v1.work-orders.issues.store'); // 이슈 등록 + Route::patch('/{id}/issues/{issueId}/resolve', [WorkOrderController::class, 'resolveIssue'])->whereNumber('id')->name('v1.work-orders.issues.resolve'); // 이슈 해결 + + // 품목 상태 변경 + Route::patch('/{id}/items/{itemId}/status', [WorkOrderController::class, 'updateItemStatus'])->whereNumber('id')->whereNumber('itemId')->name('v1.work-orders.items.status'); + + // 자재 관리 + Route::get('/{id}/materials', [WorkOrderController::class, 'materials'])->whereNumber('id')->name('v1.work-orders.materials'); // 자재 목록 조회 + Route::post('/{id}/material-inputs', [WorkOrderController::class, 'registerMaterialInput'])->whereNumber('id')->name('v1.work-orders.material-inputs'); // 자재 투입 등록 +}); + +// Work Result API (작업실적 관리) +Route::prefix('work-results')->group(function () { + // 기본 CRUD + Route::get('', [WorkResultController::class, 'index'])->name('v1.work-results.index'); // 목록 + Route::get('/stats', [WorkResultController::class, 'stats'])->name('v1.work-results.stats'); // 통계 + Route::post('', [WorkResultController::class, 'store'])->name('v1.work-results.store'); // 생성 + Route::get('/{id}', [WorkResultController::class, 'show'])->whereNumber('id')->name('v1.work-results.show'); // 상세 + Route::put('/{id}', [WorkResultController::class, 'update'])->whereNumber('id')->name('v1.work-results.update'); // 수정 + Route::delete('/{id}', [WorkResultController::class, 'destroy'])->whereNumber('id')->name('v1.work-results.destroy'); // 삭제 + + // 상태 토글 + Route::patch('/{id}/inspection', [WorkResultController::class, 'toggleInspection'])->whereNumber('id')->name('v1.work-results.inspection'); // 검사 상태 토글 + Route::patch('/{id}/packaging', [WorkResultController::class, 'togglePackaging'])->whereNumber('id')->name('v1.work-results.packaging'); // 포장 상태 토글 +}); + +// Inspection API (검사 관리) +Route::prefix('inspections')->group(function () { + Route::get('', [InspectionController::class, 'index'])->name('v1.inspections.index'); // 목록 + Route::get('/stats', [InspectionController::class, 'stats'])->name('v1.inspections.stats'); // 통계 + Route::post('', [InspectionController::class, 'store'])->name('v1.inspections.store'); // 생성 + Route::get('/{id}', [InspectionController::class, 'show'])->whereNumber('id')->name('v1.inspections.show'); // 상세 + Route::put('/{id}', [InspectionController::class, 'update'])->whereNumber('id')->name('v1.inspections.update'); // 수정 + Route::delete('/{id}', [InspectionController::class, 'destroy'])->whereNumber('id')->name('v1.inspections.destroy'); // 삭제 + Route::patch('/{id}/complete', [InspectionController::class, 'complete'])->whereNumber('id')->name('v1.inspections.complete'); // 완료 처리 +}); diff --git a/routes/api/v1/sales.php b/routes/api/v1/sales.php new file mode 100644 index 0000000..4c2e37c --- /dev/null +++ b/routes/api/v1/sales.php @@ -0,0 +1,161 @@ +group(function () { + Route::get('', [ClientController::class, 'index'])->name('v1.clients.index'); + Route::post('', [ClientController::class, 'store'])->name('v1.clients.store'); + Route::get('/active', [ClientController::class, 'active'])->name('v1.clients.active'); + Route::get('/stats', [ClientController::class, 'stats'])->name('v1.clients.stats'); + Route::get('/{id}', [ClientController::class, 'show'])->whereNumber('id')->name('v1.clients.show'); + Route::put('/{id}', [ClientController::class, 'update'])->whereNumber('id')->name('v1.clients.update'); + Route::delete('/{id}', [ClientController::class, 'destroy'])->whereNumber('id')->name('v1.clients.destroy'); + Route::patch('/{id}/toggle', [ClientController::class, 'toggle'])->whereNumber('id')->name('v1.clients.toggle'); +}); + +// Client Group API (거래처 그룹 관리) +Route::prefix('client-groups')->group(function () { + Route::get('', [ClientGroupController::class, 'index'])->name('v1.client-groups.index'); + Route::post('', [ClientGroupController::class, 'store'])->name('v1.client-groups.store'); + Route::get('/{id}', [ClientGroupController::class, 'show'])->whereNumber('id')->name('v1.client-groups.show'); + Route::put('/{id}', [ClientGroupController::class, 'update'])->whereNumber('id')->name('v1.client-groups.update'); + Route::delete('/{id}', [ClientGroupController::class, 'destroy'])->whereNumber('id')->name('v1.client-groups.destroy'); +}); + +// Quote API (견적 관리) +Route::prefix('quotes')->group(function () { + Route::get('', [QuoteController::class, 'index'])->name('v1.quotes.index'); + Route::get('/stats', [QuoteController::class, 'stats'])->name('v1.quotes.stats'); + Route::get('/stage-counts', [QuoteController::class, 'stageCounts'])->name('v1.quotes.stage-counts'); + Route::post('', [QuoteController::class, 'store'])->name('v1.quotes.store'); + Route::delete('/bulk', [QuoteController::class, 'bulkDestroy'])->name('v1.quotes.bulk-destroy'); + Route::get('/{id}', [QuoteController::class, 'show'])->whereNumber('id')->name('v1.quotes.show'); + Route::put('/{id}', [QuoteController::class, 'update'])->whereNumber('id')->name('v1.quotes.update'); + Route::delete('/{id}', [QuoteController::class, 'destroy'])->whereNumber('id')->name('v1.quotes.destroy'); + Route::post('/{id}/clone', [QuoteController::class, 'clone'])->whereNumber('id')->name('v1.quotes.clone'); + Route::put('/{id}/stage', [QuoteController::class, 'updateStage'])->whereNumber('id')->name('v1.quotes.stage'); + Route::put('/{id}/items', [QuoteController::class, 'updateItems'])->whereNumber('id')->name('v1.quotes.items'); + Route::get('/{id}/histories', [QuoteController::class, 'histories'])->whereNumber('id')->name('v1.quotes.histories'); + Route::post('/{id}/histories', [QuoteController::class, 'addHistory'])->whereNumber('id')->name('v1.quotes.histories.store'); + Route::put('/{id}/histories/{historyId}', [QuoteController::class, 'updateHistory'])->whereNumber('id')->whereNumber('historyId')->name('v1.quotes.histories.update'); + Route::delete('/{id}/histories/{historyId}', [QuoteController::class, 'deleteHistory'])->whereNumber('id')->whereNumber('historyId')->name('v1.quotes.histories.destroy'); + // 견적서 관련 API + Route::get('/{id}/document', [QuoteController::class, 'getDocument'])->whereNumber('id')->name('v1.quotes.document.show'); + Route::post('/{id}/document/issue', [QuoteController::class, 'issueDocument'])->whereNumber('id')->name('v1.quotes.document.issue'); + Route::post('/{id}/document/send', [QuoteController::class, 'sendDocument'])->whereNumber('id')->name('v1.quotes.document.send'); + Route::post('/bulk-issue-document', [QuoteController::class, 'bulkIssueDocument'])->name('v1.quotes.bulk-issue-document'); +}); + +// Bidding API (입찰 관리) +Route::prefix('biddings')->group(function () { + Route::get('', [BiddingController::class, 'index'])->name('v1.biddings.index'); + Route::post('', [BiddingController::class, 'store'])->name('v1.biddings.store'); + Route::get('/stats', [BiddingController::class, 'stats'])->name('v1.biddings.stats'); + Route::delete('/bulk', [BiddingController::class, 'bulkDestroy'])->name('v1.biddings.bulk-destroy'); + Route::get('/{id}', [BiddingController::class, 'show'])->whereNumber('id')->name('v1.biddings.show'); + Route::put('/{id}', [BiddingController::class, 'update'])->whereNumber('id')->name('v1.biddings.update'); + Route::delete('/{id}', [BiddingController::class, 'destroy'])->whereNumber('id')->name('v1.biddings.destroy'); +}); + +// Pricing API (단가 관리) +Route::prefix('pricing')->group(function () { + Route::get('', [PricingController::class, 'index'])->name('v1.pricing.index'); + Route::post('', [PricingController::class, 'store'])->name('v1.pricing.store'); + Route::get('/types', [PricingController::class, 'types'])->name('v1.pricing.types'); + Route::get('/summary', [PricingController::class, 'summary'])->name('v1.pricing.summary'); + Route::get('/history/{itemId}', [PricingController::class, 'history'])->whereNumber('itemId')->name('v1.pricing.history'); + Route::post('/bulk-upsert', [PricingController::class, 'bulkUpsert'])->name('v1.pricing.bulk-upsert'); + Route::get('/{id}', [PricingController::class, 'show'])->whereNumber('id')->name('v1.pricing.show'); + Route::put('/{id}', [PricingController::class, 'update'])->whereNumber('id')->name('v1.pricing.update'); + Route::delete('/{id}', [PricingController::class, 'destroy'])->whereNumber('id')->name('v1.pricing.destroy'); +}); + +// Estimate API (견적/설계) +Route::prefix('estimates')->group(function () { + Route::get('', [EstimateController::class, 'index'])->name('v1.estimates.index'); // 견적 목록 + Route::get('/stats', [EstimateController::class, 'stats'])->name('v1.estimates.stats'); // 견적 통계 + Route::post('', [EstimateController::class, 'store'])->name('v1.estimates.store'); // 견적 생성 + Route::get('/{id}', [EstimateController::class, 'show'])->whereNumber('id')->name('v1.estimates.show'); // 견적 상세 + Route::put('/{id}', [EstimateController::class, 'update'])->whereNumber('id')->name('v1.estimates.update'); // 견적 수정 + Route::delete('/{id}', [EstimateController::class, 'destroy'])->whereNumber('id')->name('v1.estimates.destroy'); // 견적 삭제 + Route::post('/{id}/clone', [EstimateController::class, 'clone'])->name('v1.estimates.clone'); // 견적 복제 + Route::put('/{id}/status', [EstimateController::class, 'changeStatus'])->name('v1.estimates.status'); // 견적 상태 변경 + + // 견적 폼 및 계산 기능 + Route::get('/form-schema/{model_set_id}', [EstimateController::class, 'getFormSchema'])->name('v1.estimates.form-schema'); // 견적 폼 스키마 + Route::post('/preview/{model_set_id}', [EstimateController::class, 'previewCalculation'])->name('v1.estimates.preview'); // 견적 계산 미리보기 +}); + +// Order API (수주관리) +Route::prefix('orders')->group(function () { + // 기본 CRUD + Route::get('', [OrderController::class, 'index'])->name('v1.orders.index'); // 목록 + Route::get('/stats', [OrderController::class, 'stats'])->name('v1.orders.stats'); // 통계 + Route::post('', [OrderController::class, 'store'])->name('v1.orders.store'); // 생성 + Route::get('/{id}', [OrderController::class, 'show'])->whereNumber('id')->name('v1.orders.show'); // 상세 + Route::put('/{id}', [OrderController::class, 'update'])->whereNumber('id')->name('v1.orders.update'); // 수정 + Route::delete('/{id}', [OrderController::class, 'destroy'])->whereNumber('id')->name('v1.orders.destroy'); // 삭제 + + // 상태 관리 + Route::patch('/{id}/status', [OrderController::class, 'updateStatus'])->whereNumber('id')->name('v1.orders.status'); // 상태 변경 + + // 견적에서 수주 생성 + Route::post('/from-quote/{quoteId}', [OrderController::class, 'createFromQuote'])->whereNumber('quoteId')->name('v1.orders.from-quote'); + + // 생산지시 생성 + Route::post('/{id}/production-order', [OrderController::class, 'createProductionOrder'])->whereNumber('id')->name('v1.orders.production-order'); + + // 수주확정 되돌리기 + Route::post('/{id}/revert-confirmation', [OrderController::class, 'revertOrderConfirmation'])->whereNumber('id')->name('v1.orders.revert-confirmation'); + + // 생산지시 되돌리기 + Route::post('/{id}/revert-production', [OrderController::class, 'revertProductionOrder'])->whereNumber('id')->name('v1.orders.revert-production'); +}); + +// Sale API (매출 관리) +Route::prefix('sales')->group(function () { + Route::get('', [SaleController::class, 'index'])->name('v1.sales.index'); + Route::post('', [SaleController::class, 'store'])->name('v1.sales.store'); + Route::get('/summary', [SaleController::class, 'summary'])->name('v1.sales.summary'); + Route::get('/{id}', [SaleController::class, 'show'])->whereNumber('id')->name('v1.sales.show'); + Route::put('/{id}', [SaleController::class, 'update'])->whereNumber('id')->name('v1.sales.update'); + Route::delete('/{id}', [SaleController::class, 'destroy'])->whereNumber('id')->name('v1.sales.destroy'); + Route::post('/{id}/confirm', [SaleController::class, 'confirm'])->whereNumber('id')->name('v1.sales.confirm'); + Route::put('/bulk-update-account', [SaleController::class, 'bulkUpdateAccountCode'])->name('v1.sales.bulk-update-account'); + // 거래명세서 API + Route::get('/{id}/statement', [SaleController::class, 'getStatement'])->whereNumber('id')->name('v1.sales.statement.show'); + Route::post('/{id}/statement/issue', [SaleController::class, 'issueStatement'])->whereNumber('id')->name('v1.sales.statement.issue'); + Route::post('/{id}/statement/send', [SaleController::class, 'sendStatement'])->whereNumber('id')->name('v1.sales.statement.send'); + Route::post('/bulk-issue-statement', [SaleController::class, 'bulkIssueStatement'])->name('v1.sales.bulk-issue-statement'); +}); + +// Company API (회사 추가 관리) +Route::prefix('companies')->group(function () { + Route::post('/check', [CompanyController::class, 'check'])->name('v1.companies.check'); // 사업자등록번호 검증 + Route::post('/request', [CompanyController::class, 'request'])->name('v1.companies.request'); // 회사 추가 신청 + Route::get('/requests', [CompanyController::class, 'requests'])->name('v1.companies.requests.index'); // 신청 목록 (관리자) + Route::get('/requests/{id}', [CompanyController::class, 'showRequest'])->whereNumber('id')->name('v1.companies.requests.show'); // 신청 상세 + Route::post('/requests/{id}/approve', [CompanyController::class, 'approve'])->whereNumber('id')->name('v1.companies.requests.approve'); // 승인 + Route::post('/requests/{id}/reject', [CompanyController::class, 'reject'])->whereNumber('id')->name('v1.companies.requests.reject'); // 반려 + Route::get('/my-requests', [CompanyController::class, 'myRequests'])->name('v1.companies.my-requests'); // 내 신청 목록 +}); diff --git a/routes/api/v1/tenants.php b/routes/api/v1/tenants.php new file mode 100644 index 0000000..39dcb40 --- /dev/null +++ b/routes/api/v1/tenants.php @@ -0,0 +1,46 @@ +group(function () { + Route::get('list', [TenantController::class, 'index'])->name('v1.tenant.index'); // 테넌트 목록 조회 + Route::get('/', [TenantController::class, 'show'])->name('v1.tenant.show'); // 테넌트 정보 조회 + Route::put('/', [TenantController::class, 'update'])->name('v1.tenant.update'); // 테넌트 정보 수정 + Route::post('/', [TenantController::class, 'store'])->name('v1.tenant.store'); // 테넌트 등록 + Route::delete('/', [TenantController::class, 'destroy'])->name('v1.tenant.destroy'); // 테넌트 삭제(탈퇴) + Route::put('/restore/{tenant_id}', [TenantController::class, 'restore'])->name('v1.tenant.restore'); // 테넌트 복구 + Route::post('/logo', [TenantController::class, 'uploadLogo'])->name('v1.tenant.upload-logo'); // 로고 업로드 +}); + +// Tenant Statistics Field API +Route::prefix('tenant-stat-fields')->group(function () { + Route::get('/', [TenantStatFieldController::class, 'index'])->name('v1.tenant-stat-fields.index'); // 목록 조회 + Route::post('/', [TenantStatFieldController::class, 'store'])->name('v1.tenant-stat-fields.store'); // 생성 + Route::get('/{id}', [TenantStatFieldController::class, 'show'])->name('v1.tenant-stat-fields.show'); // 단건 조회 + Route::patch('/{id}', [TenantStatFieldController::class, 'update'])->name('v1.tenant-stat-fields.update'); // 수정 + Route::delete('/{id}', [TenantStatFieldController::class, 'destroy'])->name('v1.tenant-stat-fields.destroy'); // 삭제 + Route::post('/reorder', [TenantStatFieldController::class, 'reorder'])->name('v1.tenant-stat-fields.reorder'); // 정렬 변경 + Route::put('/bulk-upsert', [TenantStatFieldController::class, 'bulkUpsert'])->name('v1.tenant-stat-fields.bulk-upsert'); // 일괄 저장 +}); + +// Tenant Settings API (테넌트별 설정) +Route::prefix('tenant-settings')->group(function () { + Route::get('/', [TenantSettingController::class, 'index'])->name('v1.tenant-settings.index'); // 전체 설정 조회 + Route::post('/', [TenantSettingController::class, 'store'])->name('v1.tenant-settings.store'); // 설정 저장 + Route::put('/bulk', [TenantSettingController::class, 'bulkUpdate'])->name('v1.tenant-settings.bulk'); // 일괄 저장 + Route::post('/initialize', [TenantSettingController::class, 'initialize'])->name('v1.tenant-settings.initialize'); // 기본 설정 초기화 + Route::get('/{group}/{key}', [TenantSettingController::class, 'show'])->name('v1.tenant-settings.show'); // 단일 설정 조회 + Route::delete('/{group}/{key}', [TenantSettingController::class, 'destroy'])->name('v1.tenant-settings.destroy'); // 설정 삭제 +}); diff --git a/routes/api/v1/users.php b/routes/api/v1/users.php new file mode 100644 index 0000000..ccabd31 --- /dev/null +++ b/routes/api/v1/users.php @@ -0,0 +1,75 @@ +group(function () { + Route::get('index', [UserController::class, 'index'])->name('v1.users.index'); // 회원 목록 조회 + Route::get('show/{user_no}', [UserController::class, 'show'])->name('v1.users.show'); // 회원 상세 조회 + + Route::get('me', [UserController::class, 'me'])->name('v1.users.users.me'); // 내 정보 조회 + Route::put('me', [UserController::class, 'meUpdate'])->name('v1.users.me.update'); // 내 정보 수정 + Route::put('me/password', [UserController::class, 'changePassword'])->name('v1.users.me.password'); // 비밀번호 변겅 + + Route::get('me/tenants', [UserController::class, 'tenants'])->name('v1.users.me.tenants.index'); // 내 테넌트 목록 + Route::patch('me/tenants/switch', [UserController::class, 'switchTenant'])->name('v1.users.me.tenants.switch'); // 활성 테넌트 전환 + + // 사용자 초대 API + Route::get('invitations', [UserInvitationController::class, 'index'])->name('v1.users.invitations.index'); // 초대 목록 + Route::post('invite', [UserInvitationController::class, 'invite'])->name('v1.users.invite'); // 초대 발송 + Route::post('invitations/{token}/accept', [UserInvitationController::class, 'accept'])->name('v1.users.invitations.accept'); // 초대 수락 + Route::delete('invitations/{id}', [UserInvitationController::class, 'cancel'])->whereNumber('id')->name('v1.users.invitations.cancel'); // 초대 취소 + Route::post('invitations/{id}/resend', [UserInvitationController::class, 'resend'])->whereNumber('id')->name('v1.users.invitations.resend'); // 초대 재발송 + + // 알림 설정 API (auth:sanctum 필수) + Route::middleware('auth:sanctum')->group(function () { + Route::get('me/notification-settings', [NotificationSettingController::class, 'index'])->name('v1.users.me.notification-settings.index'); // 알림 설정 조회 + Route::put('me/notification-settings', [NotificationSettingController::class, 'update'])->name('v1.users.me.notification-settings.update'); // 알림 설정 수정 + Route::put('me/notification-settings/bulk', [NotificationSettingController::class, 'bulkUpdate'])->name('v1.users.me.notification-settings.bulk'); // 알림 일괄 설정 + }); +}); + +// User Role API +Route::prefix('users/{id}/roles')->group(function () { + Route::get('/', [UserRoleController::class, 'index'])->name('v1.users.roles.index'); // list + Route::post('/', [UserRoleController::class, 'grant'])->name('v1.users.roles.grant'); // grant + Route::delete('/', [UserRoleController::class, 'revoke'])->name('v1.users.roles.revoke'); // revoke + Route::put('/sync', [UserRoleController::class, 'sync'])->name('v1.users.roles.sync'); // sync +}); + +// Account API (계정 관리 - 탈퇴, 사용중지, 약관동의) +Route::prefix('account')->middleware('auth:sanctum')->group(function () { + Route::post('withdraw', [AccountController::class, 'withdraw'])->name('v1.account.withdraw'); // 회원 탈퇴 + Route::post('suspend', [AccountController::class, 'suspend'])->name('v1.account.suspend'); // 사용 중지 (테넌트) + Route::get('agreements', [AccountController::class, 'getAgreements'])->name('v1.account.agreements.index'); // 약관 동의 조회 + Route::put('agreements', [AccountController::class, 'updateAgreements'])->name('v1.account.agreements.update'); // 약관 동의 수정 +}); + +// 회원 프로필(테넌트 기준) +Route::prefix('profiles')->group(function () { + Route::get('', [TenantUserProfileController::class, 'index'])->name('v1.profiles.index'); // 프로필 목록(테넌트 기준) + // /me 라우트는 /{userId} 와일드카드보다 먼저 정의해야 함 + // auth:sanctum 미들웨어로 Bearer 토큰 인증 필요 + Route::middleware('auth:sanctum')->group(function () { + Route::get('/me', [TenantUserProfileController::class, 'me'])->name('v1.profiles.me'); // 내 프로필 조회 + Route::patch('/me', [TenantUserProfileController::class, 'updateMe'])->name('v1.profiles.me.update'); // 내 프로필 수정 + }); + Route::get('/{userId}', [TenantUserProfileController::class, 'show'])->name('v1.profiles.show'); // 특정 사용자 프로필 조회 + Route::patch('/{userId}', [TenantUserProfileController::class, 'update'])->name('v1.profiles.update'); // 특정 사용자 프로필 수정(관리자) +}); From f76fd2f865b480a35688cfdfdee65568e5b39d5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 28 Jan 2026 19:22:05 +0900 Subject: [PATCH 14/57] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20DB=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EA=B5=AC=ED=98=84=20(Phase=201.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - documents 테이블 생성 (문서 기본 정보, 상태, 다형성 연결) - document_approvals 테이블 생성 (결재 처리) - document_data 테이블 생성 (EAV 패턴 데이터 저장) - document_attachments 테이블 생성 (파일 첨부) - SAM 규칙 준수 (tenant_id, 감사 컬럼, softDeletes, comment) Co-Authored-By: Claude Opus 4.5 --- ...26_01_28_200000_create_documents_table.php | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 database/migrations/2026_01_28_200000_create_documents_table.php diff --git a/database/migrations/2026_01_28_200000_create_documents_table.php b/database/migrations/2026_01_28_200000_create_documents_table.php new file mode 100644 index 0000000..5beae8b --- /dev/null +++ b/database/migrations/2026_01_28_200000_create_documents_table.php @@ -0,0 +1,115 @@ +id()->comment('ID'); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete()->comment('테넌트 ID'); + $table->foreignId('template_id')->constrained('document_templates')->comment('템플릿 ID'); + + // 문서 정보 + $table->string('document_no', 50)->comment('문서번호'); + $table->string('title', 255)->comment('문서 제목'); + $table->enum('status', ['DRAFT', 'PENDING', 'APPROVED', 'REJECTED', 'CANCELLED']) + ->default('DRAFT')->comment('상태 (DRAFT:임시저장, PENDING:결재중, APPROVED:승인, REJECTED:반려, CANCELLED:취소)'); + + // 연결 정보 (다형성) + $table->string('linkable_type', 100)->nullable()->comment('연결 모델 타입'); + $table->unsignedBigInteger('linkable_id')->nullable()->comment('연결 모델 ID'); + + // 결재 정보 + $table->timestamp('submitted_at')->nullable()->comment('결재 요청일'); + $table->timestamp('completed_at')->nullable()->comment('결재 완료일'); + + // 감사 컬럼 + $table->foreignId('created_by')->nullable()->constrained('users')->comment('생성자 ID'); + $table->foreignId('updated_by')->nullable()->constrained('users')->comment('수정자 ID'); + $table->foreignId('deleted_by')->nullable()->constrained('users')->comment('삭제자 ID'); + $table->timestamps(); + $table->softDeletes(); + + // 인덱스 + $table->index(['tenant_id', 'status']); + $table->index('document_no'); + $table->index(['linkable_type', 'linkable_id']); + }); + + // 문서 결재 + Schema::create('document_approvals', function (Blueprint $table) { + $table->id()->comment('ID'); + $table->foreignId('document_id')->constrained()->cascadeOnDelete()->comment('문서 ID'); + $table->foreignId('user_id')->constrained()->comment('결재자 ID'); + + $table->unsignedTinyInteger('step')->default(1)->comment('결재 순서'); + $table->string('role', 50)->comment('역할 (작성/검토/승인)'); + $table->enum('status', ['PENDING', 'APPROVED', 'REJECTED']) + ->default('PENDING')->comment('상태 (PENDING:대기, APPROVED:승인, REJECTED:반려)'); + + $table->text('comment')->nullable()->comment('결재 의견'); + $table->timestamp('acted_at')->nullable()->comment('결재 처리일'); + + // 감사 컬럼 + $table->foreignId('created_by')->nullable()->constrained('users')->comment('생성자 ID'); + $table->foreignId('updated_by')->nullable()->constrained('users')->comment('수정자 ID'); + $table->timestamps(); + + // 인덱스 + $table->index(['document_id', 'step']); + $table->index(['user_id', 'status']); + }); + + // 문서 데이터 (EAV 패턴) + Schema::create('document_data', function (Blueprint $table) { + $table->id()->comment('ID'); + $table->foreignId('document_id')->constrained()->cascadeOnDelete()->comment('문서 ID'); + + $table->unsignedBigInteger('section_id')->nullable()->comment('섹션 ID (document_template_sections 참조)'); + $table->unsignedBigInteger('column_id')->nullable()->comment('컬럼 ID (document_template_columns 참조)'); + $table->unsignedSmallInteger('row_index')->default(0)->comment('행 인덱스 (테이블 데이터용)'); + + $table->string('field_key', 100)->comment('필드 키'); + $table->text('field_value')->nullable()->comment('필드 값'); + + $table->timestamps(); + + // 인덱스 + $table->index(['document_id', 'section_id']); + $table->index(['document_id', 'field_key']); + }); + + // 문서 첨부파일 + Schema::create('document_attachments', function (Blueprint $table) { + $table->id()->comment('ID'); + $table->foreignId('document_id')->constrained()->cascadeOnDelete()->comment('문서 ID'); + $table->foreignId('file_id')->constrained('files')->comment('파일 ID'); + + $table->string('attachment_type', 50)->default('general')->comment('첨부 유형 (general, signature, image 등)'); + $table->string('description', 255)->nullable()->comment('설명'); + + // 감사 컬럼 + $table->foreignId('created_by')->nullable()->constrained('users')->comment('생성자 ID'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('document_attachments'); + Schema::dropIfExists('document_data'); + Schema::dropIfExists('document_approvals'); + Schema::dropIfExists('documents'); + } +}; From c7b2e97189c048073a9da9de3293860743dd7c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 28 Jan 2026 19:22:18 +0900 Subject: [PATCH 15/57] =?UTF-8?q?feat:=20=EA=B2=BD=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=EC=97=85=20=ED=92=88=EB=AA=A9/=EB=8B=A8=EA=B0=80=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20Seeder=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(Phase=201.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KyungdongItemSeeder.php 생성 - chandj.KDunitprice → samdb.items, prices 마이그레이션 - is_deleted=NULL 조건 반영 (레거시 데이터 특성) Co-Authored-By: Claude --- .../seeders/Kyungdong/KyungdongItemSeeder.php | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 database/seeders/Kyungdong/KyungdongItemSeeder.php diff --git a/database/seeders/Kyungdong/KyungdongItemSeeder.php b/database/seeders/Kyungdong/KyungdongItemSeeder.php new file mode 100644 index 0000000..bdcfc16 --- /dev/null +++ b/database/seeders/Kyungdong/KyungdongItemSeeder.php @@ -0,0 +1,221 @@ + 'FG', + '[상품]' => 'FG', + '[반제품]' => 'PT', + '[부재료]' => 'SM', + '[원재료]' => 'RM', + '[무형상품]' => 'CS', + ]; + + /** + * 경동기업 품목/단가 마이그레이션 실행 + */ + public function run(): void + { + $tenantId = DummyDataSeeder::TENANT_ID; + $userId = DummyDataSeeder::USER_ID; + + $this->command->info('🚀 경동기업 품목/단가 마이그레이션 시작...'); + $this->command->info(" 대상 테넌트: ID {$tenantId}"); + + // 1. 기존 데이터 삭제 + $this->cleanupExistingData($tenantId); + + // 2. KDunitprice → items + $itemCount = $this->migrateItems($tenantId, $userId); + + // 3. items 기반 → prices + $priceCount = $this->migratePrices($tenantId, $userId); + + $this->command->info(''); + $this->command->info("✅ 마이그레이션 완료: items {$itemCount}건, prices {$priceCount}건"); + } + + /** + * 기존 데이터 삭제 (tenant_id 기준) + */ + private function cleanupExistingData(int $tenantId): void + { + $this->command->info(''); + $this->command->info('🧹 기존 데이터 삭제 중...'); + + // prices 먼저 삭제 (FK 관계) + $priceCount = DB::table('prices')->where('tenant_id', $tenantId)->count(); + DB::table('prices')->where('tenant_id', $tenantId)->delete(); + $this->command->info(" → prices: {$priceCount}건 삭제"); + + // items 삭제 + $itemCount = DB::table('items')->where('tenant_id', $tenantId)->count(); + DB::table('items')->where('tenant_id', $tenantId)->delete(); + $this->command->info(" → items: {$itemCount}건 삭제"); + } + + /** + * KDunitprice → items 마이그레이션 + */ + private function migrateItems(int $tenantId, int $userId): int + { + $this->command->info(''); + $this->command->info('📦 KDunitprice → items 마이그레이션...'); + + // chandj.KDunitprice에서 데이터 조회 (is_deleted=NULL이 활성 상태) + $kdItems = DB::connection('chandj') + ->table('KDunitprice') + ->whereNull('is_deleted') + ->whereNotNull('prodcode') + ->where('prodcode', '!=', '') + ->get(); + + $this->command->info(" → 소스 데이터: {$kdItems->count()}건"); + + $items = []; + $now = now(); + $batchCount = 0; + + foreach ($kdItems as $kd) { + $items[] = [ + 'tenant_id' => $tenantId, + 'item_type' => $this->mapItemType($kd->item_div), + 'code' => $kd->prodcode, + 'name' => $kd->item_name, + 'unit' => $kd->unit, + 'category_id' => null, + 'process_type' => null, + 'item_category' => null, + 'bom' => null, + 'attributes' => json_encode([ + 'spec' => $kd->spec, + 'item_div' => $kd->item_div, + 'legacy_source' => 'KDunitprice', + 'legacy_num' => $kd->num, + ]), + 'attributes_archive' => null, + 'options' => null, + 'description' => null, + 'is_active' => true, + 'created_by' => $userId, + 'updated_by' => $userId, + 'created_at' => $now, + 'updated_at' => $now, + ]; + + // 500건씩 배치 INSERT + if (count($items) >= 500) { + DB::table('items')->insert($items); + $batchCount += count($items); + $this->command->info(" → {$batchCount}건 완료..."); + $items = []; + } + } + + // 남은 데이터 INSERT + if (! empty($items)) { + DB::table('items')->insert($items); + $batchCount += count($items); + } + + $this->command->info(" ✓ items: {$batchCount}건 생성 완료"); + + return $batchCount; + } + + /** + * items 기반 → prices 마이그레이션 + */ + private function migratePrices(int $tenantId, int $userId): int + { + $this->command->info(''); + $this->command->info('💰 items → prices 마이그레이션...'); + + // 생성된 items와 KDunitprice 조인하여 prices 생성 + $items = DB::table('items') + ->where('tenant_id', $tenantId) + ->get(['id', 'code', 'item_type']); + + $kdPrices = DB::connection('chandj') + ->table('KDunitprice') + ->whereNull('is_deleted') + ->whereNotNull('prodcode') + ->where('prodcode', '!=', '') + ->pluck('unitprice', 'prodcode'); + + $prices = []; + $now = now(); + $batchCount = 0; + + foreach ($items as $item) { + $unitPrice = $kdPrices[$item->code] ?? 0; + + $prices[] = [ + 'tenant_id' => $tenantId, + 'item_type_code' => $item->item_type, + 'item_id' => $item->id, + 'client_group_id' => null, + 'purchase_price' => 0, + 'processing_cost' => null, + 'loss_rate' => null, + 'margin_rate' => null, + 'sales_price' => $unitPrice, + 'rounding_rule' => 'round', + 'rounding_unit' => 1, + 'supplier' => null, + 'effective_from' => now()->toDateString(), + 'effective_to' => null, + 'note' => 'KDunitprice 마이그레이션', + 'status' => 'active', + 'is_final' => false, + 'created_by' => $userId, + 'updated_by' => $userId, + 'created_at' => $now, + 'updated_at' => $now, + ]; + + // 500건씩 배치 INSERT + if (count($prices) >= 500) { + DB::table('prices')->insert($prices); + $batchCount += count($prices); + $this->command->info(" → {$batchCount}건 완료..."); + $prices = []; + } + } + + // 남은 데이터 INSERT + if (! empty($prices)) { + DB::table('prices')->insert($prices); + $batchCount += count($prices); + } + + $this->command->info(" ✓ prices: {$batchCount}건 생성 완료"); + + return $batchCount; + } + + /** + * item_div → item_type 매핑 + */ + private function mapItemType(?string $itemDiv): string + { + return self::ITEM_TYPE_MAP[$itemDiv] ?? 'SM'; + } +} From 2bce30056da4f18f6d16854d93f08825af01d722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 28 Jan 2026 19:57:22 +0900 Subject: [PATCH 16/57] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20(Phase=201.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Document 모델 (상태 관리, 다형성 연결, 결재 처리) - DocumentApproval 모델 (결재 단계, 상태 처리) - DocumentData 모델 (EAV 패턴 데이터 저장) - DocumentAttachment 모델 (파일 첨부 연결) Co-Authored-By: Claude Opus 4.5 --- app/Models/Documents/Document.php | 246 ++++++++++++++++++++ app/Models/Documents/DocumentApproval.php | 180 ++++++++++++++ app/Models/Documents/DocumentAttachment.php | 144 ++++++++++++ app/Models/Documents/DocumentData.php | 105 +++++++++ 4 files changed, 675 insertions(+) create mode 100644 app/Models/Documents/Document.php create mode 100644 app/Models/Documents/DocumentApproval.php create mode 100644 app/Models/Documents/DocumentAttachment.php create mode 100644 app/Models/Documents/DocumentData.php diff --git a/app/Models/Documents/Document.php b/app/Models/Documents/Document.php new file mode 100644 index 0000000..8823054 --- /dev/null +++ b/app/Models/Documents/Document.php @@ -0,0 +1,246 @@ + 'datetime', + 'completed_at' => 'datetime', + ]; + + protected $attributes = [ + 'status' => self::STATUS_DRAFT, + ]; + + // ========================================================================= + // Relationships + // ========================================================================= + + /** + * 템플릿 + */ + public function template(): BelongsTo + { + return $this->belongsTo(\App\Models\DocumentTemplate::class, 'template_id'); + } + + /** + * 결재 목록 + */ + public function approvals(): HasMany + { + return $this->hasMany(DocumentApproval::class)->orderBy('step'); + } + + /** + * 문서 데이터 + */ + public function data(): HasMany + { + return $this->hasMany(DocumentData::class); + } + + /** + * 첨부파일 + */ + public function attachments(): HasMany + { + return $this->hasMany(DocumentAttachment::class); + } + + /** + * 연결된 엔티티 (다형성) + */ + public function linkable(): MorphTo + { + return $this->morphTo(); + } + + /** + * 생성자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 수정자 + */ + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + // ========================================================================= + // Scopes + // ========================================================================= + + /** + * 상태로 필터링 + */ + public function scopeStatus($query, string $status) + { + return $query->where('status', $status); + } + + /** + * 임시저장 문서 + */ + public function scopeDraft($query) + { + return $query->where('status', self::STATUS_DRAFT); + } + + /** + * 결재 대기 문서 + */ + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + + /** + * 승인된 문서 + */ + public function scopeApproved($query) + { + return $query->where('status', self::STATUS_APPROVED); + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + /** + * 편집 가능 여부 + */ + public function canEdit(): bool + { + return $this->status === self::STATUS_DRAFT + || $this->status === self::STATUS_REJECTED; + } + + /** + * 결재 요청 가능 여부 + */ + public function canSubmit(): bool + { + return $this->status === self::STATUS_DRAFT + || $this->status === self::STATUS_REJECTED; + } + + /** + * 결재 처리 가능 여부 + */ + public function canApprove(): bool + { + return $this->status === self::STATUS_PENDING; + } + + /** + * 취소 가능 여부 + */ + public function canCancel(): bool + { + return in_array($this->status, [ + self::STATUS_DRAFT, + self::STATUS_PENDING, + ]); + } + + /** + * 현재 결재 단계 가져오기 + */ + public function getCurrentApprovalStep(): ?DocumentApproval + { + return $this->approvals() + ->where('status', DocumentApproval::STATUS_PENDING) + ->orderBy('step') + ->first(); + } + + /** + * 특정 사용자의 결재 차례 확인 + */ + public function isUserTurn(int $userId): bool + { + $currentStep = $this->getCurrentApprovalStep(); + + return $currentStep && $currentStep->user_id === $userId; + } +} diff --git a/app/Models/Documents/DocumentApproval.php b/app/Models/Documents/DocumentApproval.php new file mode 100644 index 0000000..d3b42bc --- /dev/null +++ b/app/Models/Documents/DocumentApproval.php @@ -0,0 +1,180 @@ + 'integer', + 'acted_at' => 'datetime', + ]; + + protected $attributes = [ + 'step' => 1, + 'status' => self::STATUS_PENDING, + ]; + + // ========================================================================= + // Relationships + // ========================================================================= + + /** + * 문서 + */ + public function document(): BelongsTo + { + return $this->belongsTo(Document::class); + } + + /** + * 결재자 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * 생성자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // ========================================================================= + // Scopes + // ========================================================================= + + /** + * 대기 중인 결재 + */ + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + + /** + * 승인된 결재 + */ + public function scopeApproved($query) + { + return $query->where('status', self::STATUS_APPROVED); + } + + /** + * 반려된 결재 + */ + public function scopeRejected($query) + { + return $query->where('status', self::STATUS_REJECTED); + } + + /** + * 특정 사용자의 결재 + */ + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + /** + * 대기 상태 여부 + */ + public function isPending(): bool + { + return $this->status === self::STATUS_PENDING; + } + + /** + * 승인 상태 여부 + */ + public function isApproved(): bool + { + return $this->status === self::STATUS_APPROVED; + } + + /** + * 반려 상태 여부 + */ + public function isRejected(): bool + { + return $this->status === self::STATUS_REJECTED; + } + + /** + * 결재 처리 완료 여부 + */ + public function isProcessed(): bool + { + return ! $this->isPending(); + } +} diff --git a/app/Models/Documents/DocumentAttachment.php b/app/Models/Documents/DocumentAttachment.php new file mode 100644 index 0000000..b05a2bf --- /dev/null +++ b/app/Models/Documents/DocumentAttachment.php @@ -0,0 +1,144 @@ + self::TYPE_GENERAL, + ]; + + // ========================================================================= + // Relationships + // ========================================================================= + + /** + * 문서 + */ + public function document(): BelongsTo + { + return $this->belongsTo(Document::class); + } + + /** + * 파일 + */ + public function file(): BelongsTo + { + return $this->belongsTo(File::class); + } + + /** + * 생성자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // ========================================================================= + // Scopes + // ========================================================================= + + /** + * 특정 유형의 첨부파일 + */ + public function scopeOfType($query, string $type) + { + return $query->where('attachment_type', $type); + } + + /** + * 일반 첨부파일 + */ + public function scopeGeneral($query) + { + return $query->where('attachment_type', self::TYPE_GENERAL); + } + + /** + * 서명 첨부파일 + */ + public function scopeSignatures($query) + { + return $query->where('attachment_type', self::TYPE_SIGNATURE); + } + + /** + * 이미지 첨부파일 + */ + public function scopeImages($query) + { + return $query->where('attachment_type', self::TYPE_IMAGE); + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + /** + * 서명 첨부파일 여부 + */ + public function isSignature(): bool + { + return $this->attachment_type === self::TYPE_SIGNATURE; + } + + /** + * 이미지 첨부파일 여부 + */ + public function isImage(): bool + { + return $this->attachment_type === self::TYPE_IMAGE; + } +} diff --git a/app/Models/Documents/DocumentData.php b/app/Models/Documents/DocumentData.php new file mode 100644 index 0000000..a9418dc --- /dev/null +++ b/app/Models/Documents/DocumentData.php @@ -0,0 +1,105 @@ + 'integer', + 'column_id' => 'integer', + 'row_index' => 'integer', + ]; + + protected $attributes = [ + 'row_index' => 0, + ]; + + // ========================================================================= + // Relationships + // ========================================================================= + + /** + * 문서 + */ + public function document(): BelongsTo + { + return $this->belongsTo(Document::class); + } + + // ========================================================================= + // Scopes + // ========================================================================= + + /** + * 특정 섹션의 데이터 + */ + public function scopeForSection($query, int $sectionId) + { + return $query->where('section_id', $sectionId); + } + + /** + * 특정 필드 키의 데이터 + */ + public function scopeForField($query, string $fieldKey) + { + return $query->where('field_key', $fieldKey); + } + + /** + * 특정 행의 데이터 + */ + public function scopeForRow($query, int $rowIndex) + { + return $query->where('row_index', $rowIndex); + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + /** + * 값을 특정 타입으로 캐스팅 + */ + public function getTypedValue(string $type = 'string'): mixed + { + return match ($type) { + 'integer', 'int' => (int) $this->field_value, + 'float', 'double' => (float) $this->field_value, + 'boolean', 'bool' => filter_var($this->field_value, FILTER_VALIDATE_BOOLEAN), + 'array', 'json' => json_decode($this->field_value, true), + default => $this->field_value, + }; + } +} From ec47c26ea84b94e9076db440f5f950c34c086f6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 28 Jan 2026 20:38:29 +0900 Subject: [PATCH 17/57] =?UTF-8?q?feat:=20Phase=201.1-1.2=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(models,=20item=5Flist=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migrateModels(): chandj.models → items (FG) 18건 - migrateItemList(): chandj.item_list → items (PT) 9건 - migratePrices(): 다양한 소스 단가 처리 로직 개선 - 코드 포맷: FG-{model}-{type}-{finish}, PT-{name} Co-Authored-By: Claude Opus 4.5 --- .../seeders/Kyungdong/KyungdongItemSeeder.php | 182 +++++++++++++++++- 1 file changed, 173 insertions(+), 9 deletions(-) diff --git a/database/seeders/Kyungdong/KyungdongItemSeeder.php b/database/seeders/Kyungdong/KyungdongItemSeeder.php index bdcfc16..4136797 100644 --- a/database/seeders/Kyungdong/KyungdongItemSeeder.php +++ b/database/seeders/Kyungdong/KyungdongItemSeeder.php @@ -9,8 +9,9 @@ /** * 경동기업 품목/단가 마이그레이션 Seeder * - * 소스: chandj.KDunitprice (603건) - * 타겟: samdb.items, samdb.prices + * Phase 1.0: chandj.KDunitprice (601건) → items, prices + * Phase 1.1: chandj.models (18건) → items (FG), prices + * Phase 1.2: chandj.item_list (9건) → items (PT), prices * * @see docs/plans/kd-items-migration-plan.md */ @@ -28,6 +29,14 @@ class KyungdongItemSeeder extends Seeder '[무형상품]' => 'CS', ]; + /** + * finishing_type 약어 매핑 + */ + private const FINISHING_MAP = [ + 'SUS마감' => 'SUS', + 'EGI마감' => 'EGI', + ]; + /** * 경동기업 품목/단가 마이그레이션 실행 */ @@ -42,14 +51,23 @@ public function run(): void // 1. 기존 데이터 삭제 $this->cleanupExistingData($tenantId); - // 2. KDunitprice → items + // Phase 1.0: KDunitprice → items $itemCount = $this->migrateItems($tenantId, $userId); - // 3. items 기반 → prices + // Phase 1.1: models → items (FG) + $modelCount = $this->migrateModels($tenantId, $userId); + + // Phase 1.2: item_list → items (PT) + $itemListCount = $this->migrateItemList($tenantId, $userId); + + // prices 생성 (모든 items 기반) $priceCount = $this->migratePrices($tenantId, $userId); + $totalItems = $itemCount + $modelCount + $itemListCount; $this->command->info(''); - $this->command->info("✅ 마이그레이션 완료: items {$itemCount}건, prices {$priceCount}건"); + $this->command->info('✅ 마이그레이션 완료:'); + $this->command->info(" → items: {$totalItems}건 (KDunitprice {$itemCount} + models {$modelCount} + item_list {$itemListCount})"); + $this->command->info(" → prices: {$priceCount}건"); } /** @@ -148,11 +166,12 @@ private function migratePrices(int $tenantId, int $userId): int $this->command->info(''); $this->command->info('💰 items → prices 마이그레이션...'); - // 생성된 items와 KDunitprice 조인하여 prices 생성 + // 생성된 items 조회 $items = DB::table('items') ->where('tenant_id', $tenantId) - ->get(['id', 'code', 'item_type']); + ->get(['id', 'code', 'item_type', 'attributes']); + // KDunitprice 단가 (code → unitprice) $kdPrices = DB::connection('chandj') ->table('KDunitprice') ->whereNull('is_deleted') @@ -160,12 +179,31 @@ private function migratePrices(int $tenantId, int $userId): int ->where('prodcode', '!=', '') ->pluck('unitprice', 'prodcode'); + // item_list 단가 (item_name → col13) + $itemListPrices = DB::connection('chandj') + ->table('item_list') + ->pluck('col13', 'item_name'); + $prices = []; $now = now(); $batchCount = 0; foreach ($items as $item) { - $unitPrice = $kdPrices[$item->code] ?? 0; + $attributes = json_decode($item->attributes, true) ?? []; + $legacySource = $attributes['legacy_source'] ?? ''; + + // 소스별 단가 결정 + $unitPrice = match ($legacySource) { + 'KDunitprice' => $kdPrices[$item->code] ?? 0, + 'item_list' => $itemListPrices[$attributes['legacy_num'] ? $this->getItemListName($item->code) : ''] ?? $attributes['base_price'] ?? 0, + 'models' => 0, // models는 단가 없음 + default => 0, + }; + + // item_list의 경우 attributes에 저장된 base_price 사용 + if ($legacySource === 'item_list' && isset($attributes['base_price'])) { + $unitPrice = $attributes['base_price']; + } $prices[] = [ 'tenant_id' => $tenantId, @@ -182,7 +220,7 @@ private function migratePrices(int $tenantId, int $userId): int 'supplier' => null, 'effective_from' => now()->toDateString(), 'effective_to' => null, - 'note' => 'KDunitprice 마이그레이션', + 'note' => "{$legacySource} 마이그레이션", 'status' => 'active', 'is_final' => false, 'created_by' => $userId, @@ -211,6 +249,132 @@ private function migratePrices(int $tenantId, int $userId): int return $batchCount; } + /** + * PT-{name} 코드에서 name 추출 + */ + private function getItemListName(string $code): string + { + return str_starts_with($code, 'PT-') ? substr($code, 3) : ''; + } + + /** + * Phase 1.1: models → items (FG) 마이그레이션 + */ + private function migrateModels(int $tenantId, int $userId): int + { + $this->command->info(''); + $this->command->info('📦 [Phase 1.1] models → items (FG) 마이그레이션...'); + + $models = DB::connection('chandj') + ->table('models') + ->where(function ($q) { + $q->where('is_deleted', 0)->orWhereNull('is_deleted'); + }) + ->get(); + + $this->command->info(" → 소스 데이터: {$models->count()}건"); + + $items = []; + $now = now(); + + foreach ($models as $model) { + $finishingShort = self::FINISHING_MAP[$model->finishing_type] ?? 'STD'; + $code = "FG-{$model->model_name}-{$model->guiderail_type}-{$finishingShort}"; + $name = "{$model->model_name} {$model->major_category} {$model->finishing_type} {$model->guiderail_type}"; + + $items[] = [ + 'tenant_id' => $tenantId, + 'item_type' => 'FG', + 'code' => $code, + 'name' => trim($name), + 'unit' => 'EA', + 'category_id' => null, + 'process_type' => null, + 'item_category' => $model->major_category, + 'bom' => null, + 'attributes' => json_encode([ + 'model_name' => $model->model_name, + 'major_category' => $model->major_category, + 'finishing_type' => $model->finishing_type, + 'guiderail_type' => $model->guiderail_type, + 'legacy_source' => 'models', + 'legacy_model_id' => $model->model_id, + ]), + 'attributes_archive' => null, + 'options' => null, + 'description' => null, + 'is_active' => true, + 'created_by' => $userId, + 'updated_by' => $userId, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + if (! empty($items)) { + DB::table('items')->insert($items); + } + + $this->command->info(" ✓ items (FG): {$models->count()}건 생성 완료"); + + return $models->count(); + } + + /** + * Phase 1.2: item_list → items (PT) 마이그레이션 + */ + private function migrateItemList(int $tenantId, int $userId): int + { + $this->command->info(''); + $this->command->info('📦 [Phase 1.2] item_list → items (PT) 마이그레이션...'); + + $itemList = DB::connection('chandj') + ->table('item_list') + ->get(); + + $this->command->info(" → 소스 데이터: {$itemList->count()}건"); + + $items = []; + $now = now(); + + foreach ($itemList as $item) { + $code = "PT-{$item->item_name}"; + + $items[] = [ + 'tenant_id' => $tenantId, + 'item_type' => 'PT', + 'code' => $code, + 'name' => $item->item_name, + 'unit' => 'EA', + 'category_id' => null, + 'process_type' => null, + 'item_category' => null, + 'bom' => null, + 'attributes' => json_encode([ + 'base_price' => $item->col13, + 'legacy_source' => 'item_list', + 'legacy_num' => $item->num, + ]), + 'attributes_archive' => null, + 'options' => null, + 'description' => null, + 'is_active' => true, + 'created_by' => $userId, + 'updated_by' => $userId, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + if (! empty($items)) { + DB::table('items')->insert($items); + } + + $this->command->info(" ✓ items (PT): {$itemList->count()}건 생성 완료"); + + return $itemList->count(); + } + /** * item_div → item_type 매핑 */ From bacc42da73bdd9f135accbce51ff835e7a1ac328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 28 Jan 2026 20:41:31 +0900 Subject: [PATCH 18/57] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20Service=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(Phase=201.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DocumentService 생성 (CRUD + 결재 워크플로우) - 순차 결재 로직 구현 (submit/approve/reject/cancel) - Multi-tenancy 및 DB 트랜잭션 지원 Co-Authored-By: Claude --- app/Services/DocumentService.php | 524 +++++++++++++++++++++++++++++++ 1 file changed, 524 insertions(+) create mode 100644 app/Services/DocumentService.php diff --git a/app/Services/DocumentService.php b/app/Services/DocumentService.php new file mode 100644 index 0000000..e52df08 --- /dev/null +++ b/app/Services/DocumentService.php @@ -0,0 +1,524 @@ +tenantId(); + + $query = Document::query() + ->where('tenant_id', $tenantId) + ->with([ + 'template:id,name,category', + 'creator:id,name', + ]); + + // 상태 필터 + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + // 템플릿 필터 + if (! empty($params['template_id'])) { + $query->where('template_id', $params['template_id']); + } + + // 검색 (문서번호, 제목) + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('document_no', 'like', "%{$search}%") + ->orWhere('title', 'like', "%{$search}%"); + }); + } + + // 날짜 범위 필터 + if (! empty($params['from_date'])) { + $query->whereDate('created_at', '>=', $params['from_date']); + } + if (! empty($params['to_date'])) { + $query->whereDate('created_at', '<=', $params['to_date']); + } + + // 정렬 + $sortBy = $params['sort_by'] ?? 'created_at'; + $sortDir = $params['sort_dir'] ?? 'desc'; + $query->orderBy($sortBy, $sortDir); + + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 문서 상세 조회 + */ + public function show(int $id): Document + { + $tenantId = $this->tenantId(); + + return Document::query() + ->where('tenant_id', $tenantId) + ->with([ + 'template:id,name,category,title', + 'template.approvalLines', + 'template.basicFields', + 'template.sections.items', + 'template.columns', + 'approvals.user:id,name', + 'data', + 'attachments.file', + 'creator:id,name', + 'updater:id,name', + ]) + ->findOrFail($id); + } + + // ========================================================================= + // 문서 생성/수정/삭제 + // ========================================================================= + + /** + * 문서 생성 + */ + public function create(array $data): Document + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + // 문서번호 생성 + $documentNo = $this->generateDocumentNo($tenantId, $data['template_id']); + + // 문서 생성 + $document = Document::create([ + 'tenant_id' => $tenantId, + 'template_id' => $data['template_id'], + 'document_no' => $documentNo, + 'title' => $data['title'], + 'status' => Document::STATUS_DRAFT, + 'linkable_type' => $data['linkable_type'] ?? null, + 'linkable_id' => $data['linkable_id'] ?? null, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + // 결재선 생성 + if (! empty($data['approvers'])) { + $this->createApprovals($document, $data['approvers'], $userId); + } + + // 문서 데이터 저장 + if (! empty($data['data'])) { + $this->saveDocumentData($document, $data['data']); + } + + // 첨부파일 연결 + if (! empty($data['attachments'])) { + $this->attachFiles($document, $data['attachments'], $userId); + } + + return $document->fresh([ + 'template:id,name,category', + 'approvals.user:id,name', + 'data', + 'attachments.file', + 'creator:id,name', + ]); + }); + } + + /** + * 문서 수정 + */ + public function update(int $id, array $data): Document + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $data, $tenantId, $userId) { + $document = Document::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // 수정 가능 상태 확인 + if (! $document->canEdit()) { + throw new BadRequestHttpException(__('error.document.not_editable')); + } + + // 기본 정보 수정 + $document->fill([ + 'title' => $data['title'] ?? $document->title, + 'linkable_type' => $data['linkable_type'] ?? $document->linkable_type, + 'linkable_id' => $data['linkable_id'] ?? $document->linkable_id, + 'updated_by' => $userId, + ]); + + // 반려 상태에서 수정 시 DRAFT로 변경 + if ($document->status === Document::STATUS_REJECTED) { + $document->status = Document::STATUS_DRAFT; + $document->submitted_at = null; + $document->completed_at = null; + } + + $document->save(); + + // 결재선 수정 + if (isset($data['approvers'])) { + $document->approvals()->delete(); + if (! empty($data['approvers'])) { + $this->createApprovals($document, $data['approvers'], $userId); + } + } + + // 문서 데이터 수정 + if (isset($data['data'])) { + $document->data()->delete(); + if (! empty($data['data'])) { + $this->saveDocumentData($document, $data['data']); + } + } + + // 첨부파일 수정 + if (isset($data['attachments'])) { + $document->attachments()->delete(); + if (! empty($data['attachments'])) { + $this->attachFiles($document, $data['attachments'], $userId); + } + } + + return $document->fresh([ + 'template:id,name,category', + 'approvals.user:id,name', + 'data', + 'attachments.file', + 'creator:id,name', + ]); + }); + } + + /** + * 문서 삭제 + */ + public function destroy(int $id): bool + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $tenantId, $userId) { + $document = Document::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // DRAFT 상태만 삭제 가능 + if ($document->status !== Document::STATUS_DRAFT) { + throw new BadRequestHttpException(__('error.document.not_deletable')); + } + + $document->deleted_by = $userId; + $document->save(); + $document->delete(); + + return true; + }); + } + + // ========================================================================= + // 결재 처리 + // ========================================================================= + + /** + * 결재 요청 (DRAFT → PENDING) + */ + public function submit(int $id): Document + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $tenantId, $userId) { + $document = Document::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // 결재 요청 가능 상태 확인 + if (! $document->canSubmit()) { + throw new BadRequestHttpException(__('error.document.not_submittable')); + } + + // 결재선 존재 확인 + $approvalCount = $document->approvals()->count(); + if ($approvalCount === 0) { + throw new BadRequestHttpException(__('error.document.approvers_required')); + } + + $document->status = Document::STATUS_PENDING; + $document->submitted_at = now(); + $document->updated_by = $userId; + $document->save(); + + return $document->fresh([ + 'template:id,name,category', + 'approvals.user:id,name', + 'creator:id,name', + ]); + }); + } + + /** + * 결재 승인 + */ + public function approve(int $id, ?string $comment = null): Document + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $comment, $tenantId, $userId) { + $document = Document::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // 결재 가능 상태 확인 + if (! $document->canApprove()) { + throw new BadRequestHttpException(__('error.document.not_approvable')); + } + + // 현재 사용자의 대기 중인 결재 단계 찾기 + $myApproval = $document->approvals() + ->where('user_id', $userId) + ->where('status', DocumentApproval::STATUS_PENDING) + ->orderBy('step') + ->first(); + + if (! $myApproval) { + throw new BadRequestHttpException(__('error.document.not_your_turn')); + } + + // 순차 결재 확인 (이전 단계가 완료되었는지) + $pendingBefore = $document->approvals() + ->where('step', '<', $myApproval->step) + ->where('status', DocumentApproval::STATUS_PENDING) + ->exists(); + + if ($pendingBefore) { + throw new BadRequestHttpException(__('error.document.not_your_turn')); + } + + // 승인 처리 + $myApproval->status = DocumentApproval::STATUS_APPROVED; + $myApproval->comment = $comment; + $myApproval->acted_at = now(); + $myApproval->updated_by = $userId; + $myApproval->save(); + + // 모든 결재 완료 확인 + $allApproved = ! $document->approvals() + ->where('status', DocumentApproval::STATUS_PENDING) + ->exists(); + + if ($allApproved) { + $document->status = Document::STATUS_APPROVED; + $document->completed_at = now(); + } + + $document->updated_by = $userId; + $document->save(); + + return $document->fresh([ + 'template:id,name,category', + 'approvals.user:id,name', + 'creator:id,name', + ]); + }); + } + + /** + * 결재 반려 + */ + public function reject(int $id, string $comment): Document + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $comment, $tenantId, $userId) { + $document = Document::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // 결재 가능 상태 확인 + if (! $document->canApprove()) { + throw new BadRequestHttpException(__('error.document.not_approvable')); + } + + // 현재 사용자의 대기 중인 결재 단계 찾기 + $myApproval = $document->approvals() + ->where('user_id', $userId) + ->where('status', DocumentApproval::STATUS_PENDING) + ->orderBy('step') + ->first(); + + if (! $myApproval) { + throw new BadRequestHttpException(__('error.document.not_your_turn')); + } + + // 순차 결재 확인 + $pendingBefore = $document->approvals() + ->where('step', '<', $myApproval->step) + ->where('status', DocumentApproval::STATUS_PENDING) + ->exists(); + + if ($pendingBefore) { + throw new BadRequestHttpException(__('error.document.not_your_turn')); + } + + // 반려 처리 + $myApproval->status = DocumentApproval::STATUS_REJECTED; + $myApproval->comment = $comment; + $myApproval->acted_at = now(); + $myApproval->updated_by = $userId; + $myApproval->save(); + + // 문서 반려 상태로 변경 + $document->status = Document::STATUS_REJECTED; + $document->completed_at = now(); + $document->updated_by = $userId; + $document->save(); + + return $document->fresh([ + 'template:id,name,category', + 'approvals.user:id,name', + 'creator:id,name', + ]); + }); + } + + /** + * 결재 취소/회수 (작성자만) + */ + public function cancel(int $id): Document + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $tenantId, $userId) { + $document = Document::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // 취소 가능 상태 확인 + if (! $document->canCancel()) { + throw new BadRequestHttpException(__('error.document.not_cancellable')); + } + + // 작성자만 취소 가능 + if ($document->created_by !== $userId) { + throw new BadRequestHttpException(__('error.document.only_creator_can_cancel')); + } + + $document->status = Document::STATUS_CANCELLED; + $document->completed_at = now(); + $document->updated_by = $userId; + $document->save(); + + return $document->fresh([ + 'template:id,name,category', + 'creator:id,name', + ]); + }); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + /** + * 문서번호 생성 + */ + private function generateDocumentNo(int $tenantId, int $templateId): string + { + $prefix = 'DOC'; + $date = now()->format('Ymd'); + + // 오늘 생성된 문서 중 마지막 번호 조회 + $lastNumber = Document::query() + ->where('tenant_id', $tenantId) + ->where('document_no', 'like', "{$prefix}-{$date}-%") + ->orderByDesc('document_no') + ->value('document_no'); + + if ($lastNumber) { + $sequence = (int) substr($lastNumber, -4) + 1; + } else { + $sequence = 1; + } + + return sprintf('%s-%s-%04d', $prefix, $date, $sequence); + } + + /** + * 결재선 생성 + */ + private function createApprovals(Document $document, array $approvers, int $userId): void + { + foreach ($approvers as $index => $approver) { + DocumentApproval::create([ + 'document_id' => $document->id, + 'user_id' => $approver['user_id'], + 'step' => $index + 1, + 'role' => $approver['role'] ?? '승인', + 'status' => DocumentApproval::STATUS_PENDING, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + } + } + + /** + * 문서 데이터 저장 + */ + private function saveDocumentData(Document $document, array $dataItems): void + { + foreach ($dataItems as $item) { + DocumentData::create([ + 'document_id' => $document->id, + 'section_id' => $item['section_id'] ?? null, + 'column_id' => $item['column_id'] ?? null, + 'row_index' => $item['row_index'] ?? 0, + 'field_key' => $item['field_key'], + 'field_value' => $item['field_value'] ?? null, + ]); + } + } + + /** + * 첨부파일 연결 + */ + private function attachFiles(Document $document, array $attachments, int $userId): void + { + foreach ($attachments as $attachment) { + DocumentAttachment::create([ + 'document_id' => $document->id, + 'file_id' => $attachment['file_id'], + 'attachment_type' => $attachment['attachment_type'] ?? DocumentAttachment::TYPE_GENERAL, + 'description' => $attachment['description'] ?? null, + 'created_by' => $userId, + ]); + } + } +} From 2e219edf8a743454dfb10aabeead9bd2d9c8baf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 28 Jan 2026 21:06:59 +0900 Subject: [PATCH 19/57] =?UTF-8?q?feat:=20Phase=202=20BOM=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase 2.1: BDmodels.seconditem → PT items 6건 추가 - 누락 부품: L-BAR, 보강평철, 케이스, 하단마감재 등 - Phase 2.2: items.bom JSON 연결 18건 - FG items (models) ↔ PT items (seconditem) BOM 관계 설정 - 최종: items 634건, prices 634건, BOM 18건 Co-Authored-By: Claude Opus 4.5 --- .../seeders/Kyungdong/KyungdongItemSeeder.php | 202 +++++++++++++++++- 1 file changed, 200 insertions(+), 2 deletions(-) diff --git a/database/seeders/Kyungdong/KyungdongItemSeeder.php b/database/seeders/Kyungdong/KyungdongItemSeeder.php index 4136797..bce7a85 100644 --- a/database/seeders/Kyungdong/KyungdongItemSeeder.php +++ b/database/seeders/Kyungdong/KyungdongItemSeeder.php @@ -12,6 +12,8 @@ * Phase 1.0: chandj.KDunitprice (601건) → items, prices * Phase 1.1: chandj.models (18건) → items (FG), prices * Phase 1.2: chandj.item_list (9건) → items (PT), prices + * Phase 2.1: chandj.BDmodels.seconditem → items (PT) 누락 부품 추가 + * Phase 2.2: chandj.BDmodels → items.bom JSON (FG ↔ PT 연결) * * @see docs/plans/kd-items-migration-plan.md */ @@ -60,14 +62,21 @@ public function run(): void // Phase 1.2: item_list → items (PT) $itemListCount = $this->migrateItemList($tenantId, $userId); + // Phase 2.1: BDmodels.seconditem → items (PT) 누락 부품 + $bdPartsCount = $this->migrateBDmodelsParts($tenantId, $userId); + // prices 생성 (모든 items 기반) $priceCount = $this->migratePrices($tenantId, $userId); - $totalItems = $itemCount + $modelCount + $itemListCount; + // Phase 2.2: BDmodels → items.bom JSON + $bomCount = $this->migrateBom($tenantId); + + $totalItems = $itemCount + $modelCount + $itemListCount + $bdPartsCount; $this->command->info(''); $this->command->info('✅ 마이그레이션 완료:'); - $this->command->info(" → items: {$totalItems}건 (KDunitprice {$itemCount} + models {$modelCount} + item_list {$itemListCount})"); + $this->command->info(" → items: {$totalItems}건 (KDunitprice {$itemCount} + models {$modelCount} + item_list {$itemListCount} + BDmodels부품 {$bdPartsCount})"); $this->command->info(" → prices: {$priceCount}건"); + $this->command->info(" → BOM 연결: {$bomCount}건"); } /** @@ -382,4 +391,193 @@ private function mapItemType(?string $itemDiv): string { return self::ITEM_TYPE_MAP[$itemDiv] ?? 'SM'; } + + /** + * Phase 2.1: BDmodels.seconditem → items (PT) 누락 부품 추가 + * + * item_list에 없는 BDmodels.seconditem을 PT items로 생성 + */ + private function migrateBDmodelsParts(int $tenantId, int $userId): int + { + $this->command->info(''); + $this->command->info('📦 [Phase 2.1] BDmodels.seconditem → items (PT) 누락 부품...'); + + // BDmodels에서 고유한 seconditem 목록 조회 + $bdSecondItems = DB::connection('chandj') + ->table('BDmodels') + ->where(function ($q) { + $q->where('is_deleted', 0)->orWhereNull('is_deleted'); + }) + ->whereNotNull('seconditem') + ->where('seconditem', '!=', '') + ->distinct() + ->pluck('seconditem'); + + // 이미 존재하는 PT items 코드 조회 + $existingPtCodes = DB::table('items') + ->where('tenant_id', $tenantId) + ->where('item_type', 'PT') + ->pluck('code') + ->map(fn ($code) => str_starts_with($code, 'PT-') ? substr($code, 3) : $code) + ->toArray(); + + $items = []; + $now = now(); + + foreach ($bdSecondItems as $secondItem) { + // 이미 PT items에 있으면 스킵 + if (in_array($secondItem, $existingPtCodes)) { + continue; + } + + $code = "PT-{$secondItem}"; + + $items[] = [ + 'tenant_id' => $tenantId, + 'item_type' => 'PT', + 'code' => $code, + 'name' => $secondItem, + 'unit' => 'EA', + 'category_id' => null, + 'process_type' => null, + 'item_category' => null, + 'bom' => null, + 'attributes' => json_encode([ + 'legacy_source' => 'BDmodels_seconditem', + ]), + 'attributes_archive' => null, + 'options' => null, + 'description' => null, + 'is_active' => true, + 'created_by' => $userId, + 'updated_by' => $userId, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + if (! empty($items)) { + DB::table('items')->insert($items); + } + + $this->command->info(" → 소스 데이터: {$bdSecondItems->count()}건 (중복 제외 ".count($items).'건 신규)'); + $this->command->info(' ✓ items (PT): '.count($items).'건 생성 완료'); + + return count($items); + } + + /** + * Phase 2.2: BDmodels → items.bom JSON (FG ↔ PT 연결) + * + * models 기반 FG items에 BOM 연결 + * bom: [{child_item_id: X, quantity: Y}, ...] + */ + private function migrateBom(int $tenantId): int + { + $this->command->info(''); + $this->command->info('🔗 [Phase 2.2] BDmodels → items.bom JSON 연결...'); + + // PT items 조회 (code → id 매핑) + $ptItems = DB::table('items') + ->where('tenant_id', $tenantId) + ->where('item_type', 'PT') + ->pluck('id', 'code') + ->toArray(); + + // PT- prefix 없는 버전도 매핑 추가 + $ptItemsByName = []; + foreach ($ptItems as $code => $id) { + $name = str_starts_with($code, 'PT-') ? substr($code, 3) : $code; + $ptItemsByName[$name] = $id; + } + + // FG items 조회 (models 기반) + $fgItems = DB::table('items') + ->where('tenant_id', $tenantId) + ->where('item_type', 'FG') + ->whereNotNull('attributes') + ->get(['id', 'code', 'attributes']); + + // BDmodels 데이터 조회 + $bdModels = DB::connection('chandj') + ->table('BDmodels') + ->where(function ($q) { + $q->where('is_deleted', 0)->orWhereNull('is_deleted'); + }) + ->whereNotNull('model_name') + ->where('model_name', '!=', '') + ->get(['model_name', 'seconditem', 'savejson']); + + // model_name → seconditems 그룹핑 + $modelBomMap = []; + foreach ($bdModels as $bd) { + if (empty($bd->seconditem)) { + continue; + } + + $modelName = $bd->model_name; + if (! isset($modelBomMap[$modelName])) { + $modelBomMap[$modelName] = []; + } + + // savejson에서 수량 파싱 (col8이 수량) + $quantity = 1; + if (! empty($bd->savejson)) { + $json = json_decode($bd->savejson, true); + if (is_array($json) && ! empty($json)) { + // 첫 번째 항목의 col8(수량) 사용 + $quantity = (int) ($json[0]['col8'] ?? 1); + } + } + + // 중복 체크 후 추가 + $found = false; + foreach ($modelBomMap[$modelName] as &$existing) { + if ($existing['seconditem'] === $bd->seconditem) { + $found = true; + break; + } + } + if (! $found) { + $modelBomMap[$modelName][] = [ + 'seconditem' => $bd->seconditem, + 'quantity' => $quantity, + ]; + } + } + + $updatedCount = 0; + + foreach ($fgItems as $fgItem) { + $attributes = json_decode($fgItem->attributes, true) ?? []; + $modelName = $attributes['model_name'] ?? null; + + if (empty($modelName) || ! isset($modelBomMap[$modelName])) { + continue; + } + + $bomArray = []; + foreach ($modelBomMap[$modelName] as $bomItem) { + $childItemId = $ptItemsByName[$bomItem['seconditem']] ?? null; + if ($childItemId) { + $bomArray[] = [ + 'child_item_id' => $childItemId, + 'quantity' => $bomItem['quantity'], + ]; + } + } + + if (! empty($bomArray)) { + DB::table('items') + ->where('id', $fgItem->id) + ->update(['bom' => json_encode($bomArray)]); + $updatedCount++; + } + } + + $this->command->info(' → BDmodels 모델: '.count($modelBomMap).'개'); + $this->command->info(" ✓ items.bom 연결: {$updatedCount}건 완료"); + + return $updatedCount; + } } From 94612e3b50eef3307314a34a6b833c491061a984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 28 Jan 2026 21:09:53 +0900 Subject: [PATCH 20/57] =?UTF-8?q?refactor:=20Attendance=20store()=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=EB=A5=BC=20Upsert=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 같은 날 같은 사용자의 기록이 있으면 업데이트, 없으면 생성 - 기존 Create Only 패턴에서 Upsert 패턴으로 변경 - Swagger 문서 업데이트 (409 응답 제거, 설명 변경) Co-Authored-By: Claude --- app/Services/AttendanceService.php | 23 +++++++++++++++++------ app/Swagger/v1/AttendanceApi.php | 9 ++++----- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/app/Services/AttendanceService.php b/app/Services/AttendanceService.php index f4b444d..4c26c6a 100644 --- a/app/Services/AttendanceService.php +++ b/app/Services/AttendanceService.php @@ -88,7 +88,9 @@ public function show(int $id): Attendance } /** - * 근태 등록 + * 근태 등록 (Upsert) + * - 같은 날 같은 사용자의 기록이 있으면 업데이트 + * - 없으면 새로 생성 */ public function store(array $data): Attendance { @@ -103,16 +105,25 @@ public function store(array $data): Attendance ->whereDate('base_date', $data['base_date']) ->first(); - if ($existing) { - throw new \Exception(__('error.attendance.already_exists')); - } - // json_details 구성 - // json_details 객체가 직접 전달된 경우 그대로 사용, 아니면 개별 필드에서 구성 $jsonDetails = isset($data['json_details']) && is_array($data['json_details']) ? $data['json_details'] : $this->buildJsonDetails($data); + if ($existing) { + // 기존 기록 업데이트 (Upsert) + $existing->status = $data['status'] ?? $existing->status; + $existing->json_details = array_merge($existing->json_details ?? [], $jsonDetails); + if (array_key_exists('remarks', $data)) { + $existing->remarks = $data['remarks']; + } + $existing->updated_by = $userId; + $existing->save(); + + return $existing->fresh(['user:id,name,email']); + } + + // 새 기록 생성 $attendance = Attendance::create([ 'tenant_id' => $tenantId, 'user_id' => $data['user_id'], diff --git a/app/Swagger/v1/AttendanceApi.php b/app/Swagger/v1/AttendanceApi.php index 17ec740..77a53b5 100644 --- a/app/Swagger/v1/AttendanceApi.php +++ b/app/Swagger/v1/AttendanceApi.php @@ -283,8 +283,8 @@ public function show() {} * @OA\Post( * path="/api/v1/attendances", * tags={"Attendances"}, - * summary="근태 등록", - * description="새 근태 기록을 등록합니다.", + * summary="근태 등록 (Upsert)", + * description="근태 기록을 등록합니다. 같은 날 같은 사용자의 기록이 있으면 업데이트, 없으면 새로 생성합니다.", * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, * * @OA\RequestBody( @@ -294,8 +294,8 @@ public function show() {} * ), * * @OA\Response( - * response=201, - * description="등록 성공", + * response=200, + * description="등록/수정 성공", * * @OA\JsonContent( * allOf={ @@ -308,7 +308,6 @@ public function show() {} * * @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=409, description="같은 날 기록이 이미 존재함", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ From b7f8157548531ab9bb94f44f9c1e4ff9c17e1961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 28 Jan 2026 21:11:40 +0900 Subject: [PATCH 21/57] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20Controller=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(Phase=201.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DocumentController CRUD 엔드포인트 구현 - 결재 워크플로우는 기존 시스템 연동을 위해 보류 Co-Authored-By: Claude --- .../Api/V1/Documents/DocumentController.php | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/Documents/DocumentController.php diff --git a/app/Http/Controllers/Api/V1/Documents/DocumentController.php b/app/Http/Controllers/Api/V1/Documents/DocumentController.php new file mode 100644 index 0000000..639031f --- /dev/null +++ b/app/Http/Controllers/Api/V1/Documents/DocumentController.php @@ -0,0 +1,79 @@ +service->list($request->validated()); + }, __('message.fetched')); + } + + /** + * 문서 상세 조회 + * GET /v1/documents/{id} + */ + public function show(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.fetched')); + } + + /** + * 문서 생성 + * POST /v1/documents + */ + public function store(StoreRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->create($request->validated()); + }, __('message.created')); + } + + /** + * 문서 수정 + * PATCH /v1/documents/{id} + */ + public function update(int $id, UpdateRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->update($id, $request->validated()); + }, __('message.updated')); + } + + /** + * 문서 삭제 + * DELETE /v1/documents/{id} + */ + public function destroy(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->destroy($id); + }, __('message.deleted')); + } + + // ========================================================================= + // 결재 관련 메서드 (보류 - 기존 시스템 연동 필요) + // ========================================================================= + // public function submit(int $id): JsonResponse + // public function approve(int $id, ApproveRequest $request): JsonResponse + // public function reject(int $id, RejectRequest $request): JsonResponse + // public function cancel(int $id): JsonResponse +} From 9bceaab8a33c16a8eff23e21c35d144eb120c3db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 28 Jan 2026 21:14:30 +0900 Subject: [PATCH 22/57] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20FormRequest=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(Phase=201.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IndexRequest: 목록 조회 필터/페이징 검증 - StoreRequest: 문서 생성 검증 (템플릿, 데이터, 첨부파일) - UpdateRequest: 문서 수정 검증 Co-Authored-By: Claude --- app/Http/Requests/Document/IndexRequest.php | 37 ++++++++++++ app/Http/Requests/Document/StoreRequest.php | 59 ++++++++++++++++++++ app/Http/Requests/Document/UpdateRequest.php | 55 ++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 app/Http/Requests/Document/IndexRequest.php create mode 100644 app/Http/Requests/Document/StoreRequest.php create mode 100644 app/Http/Requests/Document/UpdateRequest.php diff --git a/app/Http/Requests/Document/IndexRequest.php b/app/Http/Requests/Document/IndexRequest.php new file mode 100644 index 0000000..f31f070 --- /dev/null +++ b/app/Http/Requests/Document/IndexRequest.php @@ -0,0 +1,37 @@ + "nullable|string|in:{$statuses}", + 'template_id' => 'nullable|integer', + 'search' => 'nullable|string|max:100', + 'from_date' => 'nullable|date', + 'to_date' => 'nullable|date|after_or_equal:from_date', + 'sort_by' => 'nullable|string|in:created_at,document_no,title,status,submitted_at,completed_at', + 'sort_dir' => 'nullable|string|in:asc,desc', + 'per_page' => 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + ]; + } +} diff --git a/app/Http/Requests/Document/StoreRequest.php b/app/Http/Requests/Document/StoreRequest.php new file mode 100644 index 0000000..a6681e1 --- /dev/null +++ b/app/Http/Requests/Document/StoreRequest.php @@ -0,0 +1,59 @@ + 'required|integer|exists:document_templates,id', + 'title' => 'required|string|max:255', + 'linkable_type' => 'nullable|string|max:100', + 'linkable_id' => 'nullable|integer', + + // 결재선 (보류 상태이지만 구조는 유지) + 'approvers' => 'nullable|array', + 'approvers.*.user_id' => 'required_with:approvers|integer|exists:users,id', + 'approvers.*.role' => 'nullable|string|max:50', + + // 문서 데이터 (EAV) + 'data' => 'nullable|array', + 'data.*.section_id' => 'nullable|integer', + 'data.*.column_id' => 'nullable|integer', + 'data.*.row_index' => 'nullable|integer|min:0', + 'data.*.field_key' => 'required_with:data|string|max:100', + 'data.*.field_value' => 'nullable|string', + + // 첨부파일 + 'attachments' => 'nullable|array', + 'attachments.*.file_id' => 'required_with:attachments|integer|exists:files,id', + 'attachments.*.attachment_type' => "nullable|string|in:{$attachmentTypes}", + 'attachments.*.description' => 'nullable|string|max:255', + ]; + } + + public function messages(): array + { + return [ + 'template_id.required' => __('validation.required', ['attribute' => '템플릿']), + 'template_id.exists' => __('validation.exists', ['attribute' => '템플릿']), + 'title.required' => __('validation.required', ['attribute' => '제목']), + 'title.max' => __('validation.max.string', ['attribute' => '제목', 'max' => 255]), + 'approvers.*.user_id.exists' => __('validation.exists', ['attribute' => '결재자']), + 'data.*.field_key.required_with' => __('validation.required', ['attribute' => '필드 키']), + 'attachments.*.file_id.exists' => __('validation.exists', ['attribute' => '파일']), + ]; + } +} diff --git a/app/Http/Requests/Document/UpdateRequest.php b/app/Http/Requests/Document/UpdateRequest.php new file mode 100644 index 0000000..8388c00 --- /dev/null +++ b/app/Http/Requests/Document/UpdateRequest.php @@ -0,0 +1,55 @@ + 'nullable|string|max:255', + 'linkable_type' => 'nullable|string|max:100', + 'linkable_id' => 'nullable|integer', + + // 결재선 (보류 상태이지만 구조는 유지) + 'approvers' => 'nullable|array', + 'approvers.*.user_id' => 'required_with:approvers|integer|exists:users,id', + 'approvers.*.role' => 'nullable|string|max:50', + + // 문서 데이터 (EAV) + 'data' => 'nullable|array', + 'data.*.section_id' => 'nullable|integer', + 'data.*.column_id' => 'nullable|integer', + 'data.*.row_index' => 'nullable|integer|min:0', + 'data.*.field_key' => 'required_with:data|string|max:100', + 'data.*.field_value' => 'nullable|string', + + // 첨부파일 + 'attachments' => 'nullable|array', + 'attachments.*.file_id' => 'required_with:attachments|integer|exists:files,id', + 'attachments.*.attachment_type' => "nullable|string|in:{$attachmentTypes}", + 'attachments.*.description' => 'nullable|string|max:255', + ]; + } + + public function messages(): array + { + return [ + 'title.max' => __('validation.max.string', ['attribute' => '제목', 'max' => 255]), + 'approvers.*.user_id.exists' => __('validation.exists', ['attribute' => '결재자']), + 'data.*.field_key.required_with' => __('validation.required', ['attribute' => '필드 키']), + 'attachments.*.file_id.exists' => __('validation.exists', ['attribute' => '파일']), + ]; + } +} From e06b0637fa52200e0b9e3f0ed7028a4efb4ade15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 28 Jan 2026 21:29:10 +0900 Subject: [PATCH 23/57] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20Route=20=EB=B0=8F=20S?= =?UTF-8?q?wagger=20=EA=B5=AC=ED=98=84=20(Phase=201.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Document API Route 등록 (CRUD 5개 엔드포인트) - Swagger 문서 작성 (Document, DocumentApproval, DocumentData, DocumentAttachment 스키마) Co-Authored-By: Claude --- app/Swagger/v1/DocumentApi.php | 337 +++++++++++++++++++++++++++++++++ routes/api.php | 1 + routes/api/v1/documents.php | 26 +++ 3 files changed, 364 insertions(+) create mode 100644 app/Swagger/v1/DocumentApi.php create mode 100644 routes/api/v1/documents.php diff --git a/app/Swagger/v1/DocumentApi.php b/app/Swagger/v1/DocumentApi.php new file mode 100644 index 0000000..fa6383c --- /dev/null +++ b/app/Swagger/v1/DocumentApi.php @@ -0,0 +1,337 @@ +group(function () { + // 문서 CRUD + Route::get('/', [DocumentController::class, 'index'])->name('v1.documents.index'); + Route::get('/{id}', [DocumentController::class, 'show'])->whereNumber('id')->name('v1.documents.show'); + Route::post('/', [DocumentController::class, 'store'])->name('v1.documents.store'); + Route::patch('/{id}', [DocumentController::class, 'update'])->whereNumber('id')->name('v1.documents.update'); + Route::delete('/{id}', [DocumentController::class, 'destroy'])->whereNumber('id')->name('v1.documents.destroy'); + + // 결재 워크플로우 (보류 - 기존 시스템 연동 필요) + // Route::post('/{id}/submit', [DocumentController::class, 'submit'])->name('v1.documents.submit'); + // Route::post('/{id}/approve', [DocumentController::class, 'approve'])->name('v1.documents.approve'); + // Route::post('/{id}/reject', [DocumentController::class, 'reject'])->name('v1.documents.reject'); + // Route::post('/{id}/cancel', [DocumentController::class, 'cancel'])->name('v1.documents.cancel'); +}); From 8cf588bf0577225a1b4629c9df5a30ea7168776f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 28 Jan 2026 21:29:13 +0900 Subject: [PATCH 24/57] =?UTF-8?q?feat:=20Phase=203=20=EB=8B=A8=EA=B0=80=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase 3.1: price_motor → items (SM) 누락 품목 13건 추가 - PM-020~PM-032: 제어기 (6P~100회선) - PM-033~PM-035: 방화/방범 콘트롤박스, 스위치 - Phase 3.2: price_raw_materials → items (RM) 누락 품목 4건 추가 - RM-007~RM-011: 신설비상문, 제연커튼, 화이바/와이어원단 - 중복 확인 로직: 기존 품목명과 mb_strtolower 비교 - 최종 결과: items 651건, prices 651건, BOM 18건 Co-Authored-By: Claude Opus 4.5 --- .../seeders/Kyungdong/KyungdongItemSeeder.php | 334 +++++++++++++++++- 1 file changed, 331 insertions(+), 3 deletions(-) diff --git a/database/seeders/Kyungdong/KyungdongItemSeeder.php b/database/seeders/Kyungdong/KyungdongItemSeeder.php index bce7a85..3c0f707 100644 --- a/database/seeders/Kyungdong/KyungdongItemSeeder.php +++ b/database/seeders/Kyungdong/KyungdongItemSeeder.php @@ -14,6 +14,8 @@ * Phase 1.2: chandj.item_list (9건) → items (PT), prices * Phase 2.1: chandj.BDmodels.seconditem → items (PT) 누락 부품 추가 * Phase 2.2: chandj.BDmodels → items.bom JSON (FG ↔ PT 연결) + * Phase 3.1: chandj.price_motor → items (SM) + prices 누락 품목 + * Phase 3.2: chandj.price_raw_materials → items (RM) + prices 누락 품목 * * @see docs/plans/kd-items-migration-plan.md */ @@ -71,11 +73,25 @@ public function run(): void // Phase 2.2: BDmodels → items.bom JSON $bomCount = $this->migrateBom($tenantId); - $totalItems = $itemCount + $modelCount + $itemListCount + $bdPartsCount; + // Phase 3.1: price_motor → items (SM) + prices + $motorResult = $this->migratePriceMotor($tenantId, $userId); + + // Phase 3.2: price_raw_materials → items (RM) + prices + $rawMatResult = $this->migratePriceRawMaterials($tenantId, $userId); + + $totalItems = $itemCount + $modelCount + $itemListCount + $bdPartsCount + $motorResult['items'] + $rawMatResult['items']; + $totalPrices = $priceCount + $motorResult['prices'] + $rawMatResult['prices']; + $this->command->info(''); $this->command->info('✅ 마이그레이션 완료:'); - $this->command->info(" → items: {$totalItems}건 (KDunitprice {$itemCount} + models {$modelCount} + item_list {$itemListCount} + BDmodels부품 {$bdPartsCount})"); - $this->command->info(" → prices: {$priceCount}건"); + $this->command->info(" → items: {$totalItems}건"); + $this->command->info(" - KDunitprice: {$itemCount}건"); + $this->command->info(" - models: {$modelCount}건"); + $this->command->info(" - item_list: {$itemListCount}건"); + $this->command->info(" - BDmodels부품: {$bdPartsCount}건"); + $this->command->info(" - price_motor: {$motorResult['items']}건"); + $this->command->info(" - price_raw_materials: {$rawMatResult['items']}건"); + $this->command->info(" → prices: {$totalPrices}건"); $this->command->info(" → BOM 연결: {$bomCount}건"); } @@ -580,4 +596,316 @@ private function migrateBom(int $tenantId): int return $updatedCount; } + + /** + * Phase 3.1: price_motor → items (SM) + prices + * + * price_motor JSON에서 누락된 품목만 추가 + * - 제어기, 방화/방범 콘트롤박스, 스위치, 리모콘 등 + * + * @return array{items: int, prices: int} + */ + private function migratePriceMotor(int $tenantId, int $userId): array + { + $this->command->info(''); + $this->command->info('📦 [Phase 3.1] price_motor → items (SM) 누락 품목...'); + + // 최신 price_motor 데이터 조회 + $priceMotor = DB::connection('chandj') + ->table('price_motor') + ->where(function ($q) { + $q->where('is_deleted', 0)->orWhereNull('is_deleted'); + }) + ->orderByDesc('registedate') + ->first(); + + if (! $priceMotor || empty($priceMotor->itemList)) { + $this->command->info(' → 소스 데이터 없음'); + + return ['items' => 0, 'prices' => 0]; + } + + $itemList = json_decode($priceMotor->itemList, true); + if (! is_array($itemList)) { + $this->command->info(' → JSON 파싱 실패'); + + return ['items' => 0, 'prices' => 0]; + } + + // 기존 items 이름 조회 (중복 체크용) + $existingNames = DB::table('items') + ->where('tenant_id', $tenantId) + ->pluck('name') + ->map(fn ($n) => mb_strtolower($n)) + ->toArray(); + + $items = []; + $now = now(); + $newItemCodes = []; + + foreach ($itemList as $idx => $item) { + $col1 = $item['col1'] ?? ''; // 전압/카테고리 (220, 380, 제어기, 방화, 방범) + $col2 = $item['col2'] ?? ''; // 용량/품목명 + $salesPrice = (float) str_replace(',', '', $item['col13'] ?? '0'); + + // 모터 품목은 KDunitprice에 이미 있으므로 스킵 + if (in_array($col1, ['220', '380'])) { + continue; + } + + // 품목명 생성 + $name = trim("{$col1} {$col2}"); + if (empty($name) || $name === ' ') { + continue; + } + + // 이미 존재하는 품목 스킵 (유사 이름 체크) + $nameLower = mb_strtolower($name); + $exists = false; + foreach ($existingNames as $existingName) { + if (str_contains($existingName, $nameLower) || str_contains($nameLower, $existingName)) { + $exists = true; + break; + } + } + if ($exists) { + continue; + } + + // 코드 생성 + $code = 'PM-'.str_pad($idx + 1, 3, '0', STR_PAD_LEFT); + + $items[] = [ + 'tenant_id' => $tenantId, + 'item_type' => 'SM', + 'code' => $code, + 'name' => $name, + 'unit' => 'EA', + 'category_id' => null, + 'process_type' => null, + 'item_category' => null, + 'bom' => null, + 'attributes' => json_encode([ + 'price_category' => $col1, + 'price_spec' => $col2, + 'legacy_source' => 'price_motor', + ]), + 'attributes_archive' => null, + 'options' => null, + 'description' => null, + 'is_active' => true, + 'created_by' => $userId, + 'updated_by' => $userId, + 'created_at' => $now, + 'updated_at' => $now, + ]; + + $newItemCodes[$code] = $salesPrice; + $existingNames[] = $nameLower; // 중복 방지 + } + + if (! empty($items)) { + DB::table('items')->insert($items); + } + + // prices 생성 + $priceCount = 0; + if (! empty($newItemCodes)) { + $newItems = DB::table('items') + ->where('tenant_id', $tenantId) + ->whereIn('code', array_keys($newItemCodes)) + ->get(['id', 'code', 'item_type']); + + $prices = []; + foreach ($newItems as $item) { + $prices[] = [ + 'tenant_id' => $tenantId, + 'item_type_code' => $item->item_type, + 'item_id' => $item->id, + 'client_group_id' => null, + 'purchase_price' => 0, + 'processing_cost' => null, + 'loss_rate' => null, + 'margin_rate' => null, + 'sales_price' => $newItemCodes[$item->code], + 'rounding_rule' => 'round', + 'rounding_unit' => 1, + 'supplier' => null, + 'effective_from' => $priceMotor->registedate ?? now()->toDateString(), + 'effective_to' => null, + 'note' => 'price_motor 마이그레이션', + 'status' => 'active', + 'is_final' => false, + 'created_by' => $userId, + 'updated_by' => $userId, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + if (! empty($prices)) { + DB::table('prices')->insert($prices); + $priceCount = count($prices); + } + } + + $this->command->info(' → 소스 데이터: '.count($itemList).'건 (누락 '.count($items).'건 추가)'); + $this->command->info(' ✓ items: '.count($items).'건, prices: '.$priceCount.'건 생성 완료'); + + return ['items' => count($items), 'prices' => $priceCount]; + } + + /** + * Phase 3.2: price_raw_materials → items (RM) + prices + * + * price_raw_materials JSON에서 누락된 원자재 품목 추가 + * + * @return array{items: int, prices: int} + */ + private function migratePriceRawMaterials(int $tenantId, int $userId): array + { + $this->command->info(''); + $this->command->info('📦 [Phase 3.2] price_raw_materials → items (RM) 누락 품목...'); + + // 최신 price_raw_materials 데이터 조회 + $priceRaw = DB::connection('chandj') + ->table('price_raw_materials') + ->where(function ($q) { + $q->where('is_deleted', 0)->orWhereNull('is_deleted'); + }) + ->orderByDesc('registedate') + ->first(); + + if (! $priceRaw || empty($priceRaw->itemList)) { + $this->command->info(' → 소스 데이터 없음'); + + return ['items' => 0, 'prices' => 0]; + } + + $itemList = json_decode($priceRaw->itemList, true); + if (! is_array($itemList)) { + $this->command->info(' → JSON 파싱 실패'); + + return ['items' => 0, 'prices' => 0]; + } + + // 기존 items 이름 조회 (중복 체크용) + $existingNames = DB::table('items') + ->where('tenant_id', $tenantId) + ->pluck('name') + ->map(fn ($n) => mb_strtolower($n)) + ->toArray(); + + $items = []; + $now = now(); + $newItemCodes = []; + + foreach ($itemList as $idx => $item) { + $col1 = $item['col1'] ?? ''; // 카테고리 (슬랫, 스크린) + $col2 = $item['col2'] ?? ''; // 품목명 (방화, 실리카, 화이바) + $salesPrice = (float) str_replace(',', '', $item['col13'] ?? '0'); + + // 품목명 생성 + $name = trim("{$col1} {$col2}"); + if (empty($name) || $name === ' ') { + continue; + } + + // 이미 존재하는 품목 스킵 + $nameLower = mb_strtolower($name); + $exists = false; + foreach ($existingNames as $existingName) { + // 정확히 일치하거나 유사한 이름 체크 + $col2Lower = mb_strtolower($col2); + if (str_contains($existingName, $col2Lower) || $existingName === $nameLower) { + $exists = true; + break; + } + } + if ($exists) { + continue; + } + + // 코드 생성 + $code = 'RM-'.str_pad($idx + 1, 3, '0', STR_PAD_LEFT); + + $items[] = [ + 'tenant_id' => $tenantId, + 'item_type' => 'RM', + 'code' => $code, + 'name' => $name, + 'unit' => 'EA', + 'category_id' => null, + 'process_type' => null, + 'item_category' => $col1, + 'bom' => null, + 'attributes' => json_encode([ + 'raw_category' => $col1, + 'raw_name' => $col2, + 'legacy_source' => 'price_raw_materials', + ]), + 'attributes_archive' => null, + 'options' => null, + 'description' => null, + 'is_active' => true, + 'created_by' => $userId, + 'updated_by' => $userId, + 'created_at' => $now, + 'updated_at' => $now, + ]; + + $newItemCodes[$code] = $salesPrice; + $existingNames[] = $nameLower; + } + + if (! empty($items)) { + DB::table('items')->insert($items); + } + + // prices 생성 + $priceCount = 0; + if (! empty($newItemCodes)) { + $newItems = DB::table('items') + ->where('tenant_id', $tenantId) + ->whereIn('code', array_keys($newItemCodes)) + ->get(['id', 'code', 'item_type']); + + $prices = []; + foreach ($newItems as $item) { + $prices[] = [ + 'tenant_id' => $tenantId, + 'item_type_code' => $item->item_type, + 'item_id' => $item->id, + 'client_group_id' => null, + 'purchase_price' => 0, + 'processing_cost' => null, + 'loss_rate' => null, + 'margin_rate' => null, + 'sales_price' => $newItemCodes[$item->code], + 'rounding_rule' => 'round', + 'rounding_unit' => 1, + 'supplier' => null, + 'effective_from' => $priceRaw->registedate ?? now()->toDateString(), + 'effective_to' => null, + 'note' => 'price_raw_materials 마이그레이션', + 'status' => 'active', + 'is_final' => false, + 'created_by' => $userId, + 'updated_by' => $userId, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + if (! empty($prices)) { + DB::table('prices')->insert($prices); + $priceCount = count($prices); + } + } + + $this->command->info(' → 소스 데이터: '.count($itemList).'건 (누락 '.count($items).'건 추가)'); + $this->command->info(' ✓ items: '.count($items).'건, prices: '.$priceCount.'건 생성 완료'); + + return ['items' => count($items), 'prices' => $priceCount]; + } } From d3825e4bfb230b33b91ea43d1181030b2382a2d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 28 Jan 2026 21:40:37 +0900 Subject: [PATCH 25/57] =?UTF-8?q?fix:=20=EB=A7=A4=EC=9E=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=20=EC=95=8C=EB=A6=BC/=ED=91=B8=EC=8B=9C?= =?UTF-8?q?=20=EB=B0=9C=EC=86=A1=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleExpectedExpenseChange()에서 source_type이 'purchases'인 경우 알림 제외 - 매입 알림은 결재 상신 시에만 발송되도록 변경 Co-Authored-By: Claude Opus 4.5 --- app/Services/TodayIssueObserverService.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/Services/TodayIssueObserverService.php b/app/Services/TodayIssueObserverService.php index 71e7cd3..b3c6aa5 100644 --- a/app/Services/TodayIssueObserverService.php +++ b/app/Services/TodayIssueObserverService.php @@ -170,6 +170,12 @@ public function handleStockDeleted(Stock $stock): void */ public function handleExpectedExpenseChange(ExpectedExpense $expense): void { + // 매입(purchases)에서 동기화된 예상 지출은 알림 제외 + // 매입 알림은 결재 상신 시에만 발송 + if ($expense->source_type === 'purchases') { + return; + } + // 승인 대기 상태인 경우만 이슈 생성 if ($expense->payment_status === 'pending') { $title = $expense->description ?? __('message.today_issue.expense_item'); From 42deb60861e5d642fe496a651606a3f5de9bdd64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 28 Jan 2026 22:07:37 +0900 Subject: [PATCH 26/57] =?UTF-8?q?fix:=20Quote=20API=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8A=B8=20=EB=88=84=EB=9D=BD=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 라우트 분리(a96499a) 시 누락된 견적 라우트 복구 - /calculate, /calculate/bom, /calculate/bom/bulk - /number/preview, /calculation/schema - /{id}/pdf, /{id}/send/email, /{id}/send/kakao - 잘못 추가된 bulk-issue-document 라우트 제거 Co-Authored-By: Claude Opus 4.5 --- routes/api/v1/sales.php | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/routes/api/v1/sales.php b/routes/api/v1/sales.php index 4c2e37c..9101180 100644 --- a/routes/api/v1/sales.php +++ b/routes/api/v1/sales.php @@ -49,21 +49,37 @@ Route::get('/stage-counts', [QuoteController::class, 'stageCounts'])->name('v1.quotes.stage-counts'); Route::post('', [QuoteController::class, 'store'])->name('v1.quotes.store'); Route::delete('/bulk', [QuoteController::class, 'bulkDestroy'])->name('v1.quotes.bulk-destroy'); + + // 견적번호 미리보기 + Route::get('/number/preview', [QuoteController::class, 'previewNumber'])->name('v1.quotes.number-preview'); + + // 자동산출 + Route::get('/calculation/schema', [QuoteController::class, 'calculationSchema'])->name('v1.quotes.calculation-schema'); + Route::post('/calculate', [QuoteController::class, 'calculate'])->name('v1.quotes.calculate'); + Route::post('/calculate/bom', [QuoteController::class, 'calculateBom'])->name('v1.quotes.calculate-bom'); + Route::post('/calculate/bom/bulk', [QuoteController::class, 'calculateBomBulk'])->name('v1.quotes.calculate-bom-bulk'); + + // 단건 조회/수정/삭제 (id 경로는 구체적인 경로 뒤에 배치) Route::get('/{id}', [QuoteController::class, 'show'])->whereNumber('id')->name('v1.quotes.show'); Route::put('/{id}', [QuoteController::class, 'update'])->whereNumber('id')->name('v1.quotes.update'); Route::delete('/{id}', [QuoteController::class, 'destroy'])->whereNumber('id')->name('v1.quotes.destroy'); Route::post('/{id}/clone', [QuoteController::class, 'clone'])->whereNumber('id')->name('v1.quotes.clone'); Route::put('/{id}/stage', [QuoteController::class, 'updateStage'])->whereNumber('id')->name('v1.quotes.stage'); Route::put('/{id}/items', [QuoteController::class, 'updateItems'])->whereNumber('id')->name('v1.quotes.items'); + + // 히스토리 Route::get('/{id}/histories', [QuoteController::class, 'histories'])->whereNumber('id')->name('v1.quotes.histories'); Route::post('/{id}/histories', [QuoteController::class, 'addHistory'])->whereNumber('id')->name('v1.quotes.histories.store'); Route::put('/{id}/histories/{historyId}', [QuoteController::class, 'updateHistory'])->whereNumber('id')->whereNumber('historyId')->name('v1.quotes.histories.update'); Route::delete('/{id}/histories/{historyId}', [QuoteController::class, 'deleteHistory'])->whereNumber('id')->whereNumber('historyId')->name('v1.quotes.histories.destroy'); - // 견적서 관련 API + + // 견적서 문서 관리 Route::get('/{id}/document', [QuoteController::class, 'getDocument'])->whereNumber('id')->name('v1.quotes.document.show'); Route::post('/{id}/document/issue', [QuoteController::class, 'issueDocument'])->whereNumber('id')->name('v1.quotes.document.issue'); Route::post('/{id}/document/send', [QuoteController::class, 'sendDocument'])->whereNumber('id')->name('v1.quotes.document.send'); - Route::post('/bulk-issue-document', [QuoteController::class, 'bulkIssueDocument'])->name('v1.quotes.bulk-issue-document'); + Route::post('/{id}/pdf', [QuoteController::class, 'generatePdf'])->whereNumber('id')->name('v1.quotes.pdf'); + Route::post('/{id}/send/email', [QuoteController::class, 'sendEmail'])->whereNumber('id')->name('v1.quotes.send-email'); + Route::post('/{id}/send/kakao', [QuoteController::class, 'sendKakao'])->whereNumber('id')->name('v1.quotes.send-kakao'); }); // Bidding API (입찰 관리) From e9894fef61ee5acbe1cfe58568be24abe379b0fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 28 Jan 2026 22:24:21 +0900 Subject: [PATCH 27/57] =?UTF-8?q?feat:=20=EC=88=98=EC=A3=BC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D/=EC=83=81=EC=84=B8=20=ED=95=84=EB=93=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrderService: client relation에 manager_name 추가 - Order 모델: shipping_cost_label accessor 추가 (common_codes 조회) - $appends에 shipping_cost_label 추가 Co-Authored-By: Claude Opus 4.5 --- app/Models/Orders/Order.php | 11 +++++++++++ app/Services/OrderService.php | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/Models/Orders/Order.php b/app/Models/Orders/Order.php index 6329f38..eedeb89 100644 --- a/app/Models/Orders/Order.php +++ b/app/Models/Orders/Order.php @@ -149,6 +149,7 @@ class Order extends Model */ protected $appends = [ 'delivery_method_label', + 'shipping_cost_label', ]; /** @@ -255,6 +256,16 @@ public function getDeliveryMethodLabelAttribute(): string return CommonCode::getLabel('delivery_method', $this->delivery_method_code); } + /** + * 운임비용 라벨 (common_codes 테이블에서 조회) + */ + public function getShippingCostLabelAttribute(): string + { + $shippingCostCode = $this->options['shipping_cost_code'] ?? null; + + return $shippingCostCode ? CommonCode::getLabel('shipping_cost', $shippingCostCode) : ''; + } + /** * 수주확정 시 매출 생성 여부 */ diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 6b63900..e42ef24 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -31,7 +31,7 @@ public function index(array $params) $query = Order::query() ->where('tenant_id', $tenantId) - ->with(['client:id,name', 'items', 'quote:id,quote_number']); + ->with(['client:id,name,manager_name', 'items', 'quote:id,quote_number']); // 작업지시 생성 가능한 수주만 필터링 if ($forWorkOrder) { From 3fce54b7d48dc90c5d4e79b002f67e9ef24d2d56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 01:10:42 +0900 Subject: [PATCH 28/57] =?UTF-8?q?feat:=20=EA=B2=BD=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=EC=97=85=20=EC=A0=84=EC=9A=A9=20=EA=B2=AC=EC=A0=81=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20(Phase=20?= =?UTF-8?q?4=20=EC=99=84=EB=A3=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KdPriceTable 모델: 경동기업 단가 테이블 (motor, shaft, pipe, angle, raw_material, bdmodels) - KyungdongFormulaHandler: 모터 용량, 브라켓 크기, 절곡품(10종), 부자재(3종) 계산 - FormulaEvaluatorService: tenant_id=287 라우팅 추가 - kd_price_tables 마이그레이션 및 시더 (47건 단가 데이터) 테스트 결과: W0=3000, H0=2500 입력 시 16개 항목, 합계 751,200원 정상 계산 Co-Authored-By: Claude Opus 4.5 --- CURRENT_WORKS.md | 46 ++ app/Models/Kyungdong/KdPriceTable.php | 341 ++++++++ .../Quote/FormulaEvaluatorService.php | 226 +++++ .../Handlers/KyungdongFormulaHandler.php | 773 ++++++++++++++++++ ...29_004736_create_kd_price_tables_table.php | 62 ++ .../seeders/Kyungdong/KdPriceTableSeeder.php | 565 +++++++++++++ 6 files changed, 2013 insertions(+) create mode 100644 app/Models/Kyungdong/KdPriceTable.php create mode 100644 app/Services/Quote/Handlers/KyungdongFormulaHandler.php create mode 100644 database/migrations/2026_01_29_004736_create_kd_price_tables_table.php create mode 100644 database/seeders/Kyungdong/KdPriceTableSeeder.php diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index c95d053..d5bc318 100644 --- a/CURRENT_WORKS.md +++ b/CURRENT_WORKS.md @@ -1,3 +1,49 @@ +## 2026-01-29 (수) - 경동기업 견적 로직 Phase 4 완료 + +### 작업 목표 +- 경동기업(tenant_id=287) 전용 견적 계산 로직 구현 +- 5130 레거시 시스템의 BOM/견적 로직을 SAM에 이식 +- 동적 BOM 계산: 모터, 제어기, 절곡품(10종), 부자재(3종) + +### 생성된 파일 +| 파일명 | 설명 | +|--------|------| +| `app/Models/Kyungdong/KdPriceTable.php` | 경동기업 전용 단가 테이블 모델 | +| `app/Services/Quote/Handlers/KyungdongFormulaHandler.php` | 경동기업 견적 계산 핸들러 | +| `database/migrations/2026_01_29_004736_create_kd_price_tables_table.php` | kd_price_tables 마이그레이션 | +| `database/seeders/Kyungdong/KdPriceTableSeeder.php` | 단가 데이터 시더 (47건) | + +### 수정된 파일 +| 파일명 | 설명 | +|--------|------| +| `app/Services/Quote/FormulaEvaluatorService.php` | tenant_id=287 라우팅 추가 | + +### 구현된 기능 +| 기능 | 설명 | +|------|------| +| 모터 용량 계산 | 제품타입 × 인치 × 중량 3차원 조건 | +| 브라켓 크기 결정 | 중량 기반 530*320, 600*350, 690*390 | +| 주자재 계산 | W × (H + 550) / 1,000,000 × 단가 | +| 절곡품 계산 (10종) | 케이스, 마구리, 가이드레일, 하장바, L바, 평철, 환봉 등 | +| 부자재 계산 (3종) | 감기샤프트, 각파이프, 앵글 | + +### 테스트 결과 +``` +입력: W0=3000, H0=2500, 철재형, 5인치, KSS01 SUS +출력: 16개 항목, 합계 751,200원 ✅ +``` + +### 검증 완료 +- [x] Pint 코드 스타일 통과 +- [x] 마이그레이션 실행 완료 (kd_price_tables) +- [x] 시더 실행 완료 (47건 단가 데이터) +- [x] tinker 테스트 통과 (16개 항목 정상 계산) + +### 계획 문서 +- `docs/plans/kd-quote-logic-plan.md` - Phase 0~4 완료 (100%) + +--- + ## 2026-01-21 (화) - TodayIssue 헤더 알림 API (Phase 3 완료) ### 작업 목표 diff --git a/app/Models/Kyungdong/KdPriceTable.php b/app/Models/Kyungdong/KdPriceTable.php new file mode 100644 index 0000000..fa5c682 --- /dev/null +++ b/app/Models/Kyungdong/KdPriceTable.php @@ -0,0 +1,341 @@ + 'decimal:2', + 'raw_data' => 'array', + 'is_active' => 'boolean', + ]; + + // ========================================================================= + // Scopes + // ========================================================================= + + /** + * 테이블 유형으로 필터링 + */ + public function scopeOfType(Builder $query, string $type): Builder + { + return $query->where('table_type', $type); + } + + /** + * 활성 데이터만 + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + /** + * 모터 단가 조회 + */ + public function scopeMotor(Builder $query): Builder + { + return $query->ofType(self::TYPE_MOTOR)->active(); + } + + /** + * 샤프트 단가 조회 + */ + public function scopeShaft(Builder $query): Builder + { + return $query->ofType(self::TYPE_SHAFT)->active(); + } + + /** + * 파이프 단가 조회 + */ + public function scopePipeType(Builder $query): Builder + { + return $query->ofType(self::TYPE_PIPE)->active(); + } + + /** + * 앵글 단가 조회 + */ + public function scopeAngle(Builder $query): Builder + { + return $query->ofType(self::TYPE_ANGLE)->active(); + } + + // ========================================================================= + // Static Query Methods + // ========================================================================= + + /** + * 모터 단가 조회 + * + * @param string $motorCapacity 모터 용량 (150K, 300K, 400K, 500K, 600K, 800K, 1000K) + */ + public static function getMotorPrice(string $motorCapacity): float + { + $record = self::motor() + ->where('category', $motorCapacity) + ->first(); + + return (float) ($record?->unit_price ?? 0); + } + + /** + * 제어기 단가 조회 + * + * @param string $controllerType 제어기 타입 (매립형, 노출형, 뒷박스) + */ + public static function getControllerPrice(string $controllerType): float + { + $record = self::motor() + ->where('category', $controllerType) + ->first(); + + return (float) ($record?->unit_price ?? 0); + } + + /** + * 샤프트 단가 조회 + * + * @param string $size 사이즈 (3, 4, 5인치) + * @param float $length 길이 (m 단위) + */ + public static function getShaftPrice(string $size, float $length): float + { + // 길이를 소수점 1자리 문자열로 변환 (DB 저장 형식: '3.0', '4.0') + $lengthStr = number_format($length, 1, '.', ''); + + $record = self::shaft() + ->where('spec1', $size) + ->where('spec2', $lengthStr) + ->first(); + + return (float) ($record?->unit_price ?? 0); + } + + /** + * 파이프 단가 조회 + * + * @param string $thickness 두께 (1.4 등) + * @param int $length 길이 (3000, 6000) + */ + public static function getPipePrice(string $thickness, int $length): float + { + $record = self::pipeType() + ->where('spec1', $thickness) + ->where('spec2', (string) $length) + ->first(); + + return (float) ($record?->unit_price ?? 0); + } + + /** + * 앵글 단가 조회 + * + * @param string $type 타입 (스크린용, 철재용) + * @param string $bracketSize 브라켓크기 (530*320, 600*350, 690*390) + * @param string $angleType 앵글타입 (앵글3T, 앵글4T) + */ + public static function getAnglePrice(string $type, string $bracketSize, string $angleType): float + { + $record = self::angle() + ->where('category', $type) + ->where('spec1', $bracketSize) + ->where('spec2', $angleType) + ->first(); + + return (float) ($record?->unit_price ?? 0); + } + + /** + * 원자재 단가 조회 + * + * @param string $materialName 원자재명 (실리카, 스크린 등) + */ + public static function getRawMaterialPrice(string $materialName): float + { + $record = self::ofType(self::TYPE_RAW_MATERIAL) + ->active() + ->where('item_name', $materialName) + ->first(); + + return (float) ($record?->unit_price ?? 0); + } + + // ========================================================================= + // BDmodels 단가 조회 (절곡품) + // ========================================================================= + + /** + * BDmodels 스코프 + */ + public function scopeBdmodels(Builder $query): Builder + { + return $query->ofType(self::TYPE_BDMODELS)->active(); + } + + /** + * BDmodels 단가 조회 (케이스, 가이드레일, 하단마감재 등) + * + * @param string $secondItem 부품분류 (케이스, 가이드레일, 하단마감재, L-BAR 등) + * @param string|null $modelName 모델코드 (KSS01, KWS01 등) + * @param string|null $finishingType 마감재질 (SUS, EGI) + * @param string|null $spec 규격 (120*70, 650*550 등) + */ + public static function getBDModelPrice( + string $secondItem, + ?string $modelName = null, + ?string $finishingType = null, + ?string $spec = null + ): float { + $query = self::bdmodels()->where('category', $secondItem); + + if ($modelName) { + $query->where('item_code', $modelName); + } + + if ($finishingType) { + $query->where('spec1', $finishingType); + } + + if ($spec) { + $query->where('spec2', $spec); + } + + $record = $query->first(); + + return (float) ($record?->unit_price ?? 0); + } + + /** + * 케이스 단가 조회 + * + * @param string $spec 케이스 규격 (500*380, 650*550 등) + */ + public static function getCasePrice(string $spec): float + { + return self::getBDModelPrice('케이스', null, null, $spec); + } + + /** + * 가이드레일 단가 조회 + * + * @param string $modelName 모델코드 (KSS01 등) + * @param string $finishingType 마감재질 (SUS, EGI) + * @param string $spec 규격 (120*70, 120*100) + */ + public static function getGuideRailPrice(string $modelName, string $finishingType, string $spec): float + { + return self::getBDModelPrice('가이드레일', $modelName, $finishingType, $spec); + } + + /** + * 하단마감재(하장바) 단가 조회 + * + * @param string $modelName 모델코드 + * @param string $finishingType 마감재질 + */ + public static function getBottomBarPrice(string $modelName, string $finishingType): float + { + return self::getBDModelPrice('하단마감재', $modelName, $finishingType); + } + + /** + * L-BAR 단가 조회 + * + * @param string $modelName 모델코드 + */ + public static function getLBarPrice(string $modelName): float + { + return self::getBDModelPrice('L-BAR', $modelName); + } + + /** + * 보강평철 단가 조회 + */ + public static function getFlatBarPrice(): float + { + return self::getBDModelPrice('보강평철'); + } + + /** + * 케이스 마구리 단가 조회 + * + * @param string $spec 규격 + */ + public static function getCaseCapPrice(string $spec): float + { + return self::getBDModelPrice('마구리', null, null, $spec); + } + + /** + * 케이스용 연기차단재 단가 조회 + */ + public static function getCaseSmokeBlockPrice(): float + { + return self::getBDModelPrice('케이스용 연기차단재'); + } + + /** + * 가이드레일용 연기차단재 단가 조회 + */ + public static function getRailSmokeBlockPrice(): float + { + return self::getBDModelPrice('가이드레일용 연기차단재'); + } +} diff --git a/app/Services/Quote/FormulaEvaluatorService.php b/app/Services/Quote/FormulaEvaluatorService.php index 2b30b1b..03eca59 100644 --- a/app/Services/Quote/FormulaEvaluatorService.php +++ b/app/Services/Quote/FormulaEvaluatorService.php @@ -6,6 +6,7 @@ use App\Models\Items\Item; use App\Models\Products\Price; use App\Models\Quote\QuoteFormula; +use App\Services\Quote\Handlers\KyungdongFormulaHandler; use App\Services\Service; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -28,6 +29,11 @@ class FormulaEvaluatorService extends Service 'SUM', 'ROUND', 'CEIL', 'FLOOR', 'ABS', 'MIN', 'MAX', 'IF', 'AND', 'OR', 'NOT', ]; + /** + * 경동기업 테넌트 ID + */ + private const KYUNGDONG_TENANT_ID = 287; + private array $variables = []; private array $errors = []; @@ -599,6 +605,11 @@ public function calculateBomWithDebug( ]; } + // 경동기업(tenant_id=287) 전용 계산 로직 분기 + if ($tenantId === self::KYUNGDONG_TENANT_ID) { + return $this->calculateKyungdongBom($finishedGoodsCode, $inputVariables, $tenantId); + } + // Step 1: 입력값 수집 (React 동기화 변수 포함) $this->addDebugStep(1, '입력값수집', [ 'W0' => $inputVariables['W0'] ?? null, @@ -1538,4 +1549,219 @@ private function mergeLeafMaterials(array $leafMaterials, int $tenantId): array return array_values($result); } + + // ========================================================================= + // 경동기업 전용 계산 (tenant_id = 287) + // ========================================================================= + + /** + * 경동기업 전용 BOM 계산 + * + * 5130 레거시 시스템의 견적 로직을 구현한 KyungdongFormulaHandler 사용 + * - 3차원 조건 모터 용량 계산 (제품타입 × 인치 × 중량) + * - 브라켓 크기 결정 + * - 10종 절곡품 계산 + * - 3종 부자재 계산 + * + * @param string $finishedGoodsCode 완제품 코드 + * @param array $inputVariables 입력 변수 (W0, H0, QTY 등) + * @param int $tenantId 테넌트 ID + * @return array 계산 결과 + */ + private function calculateKyungdongBom( + string $finishedGoodsCode, + array $inputVariables, + int $tenantId + ): array { + $this->addDebugStep(0, '경동전용계산', [ + 'tenant_id' => $tenantId, + 'handler' => 'KyungdongFormulaHandler', + 'finished_goods' => $finishedGoodsCode, + ]); + + // Step 1: 입력값 수집 + $this->addDebugStep(1, '입력값수집', [ + 'W0' => $inputVariables['W0'] ?? null, + 'H0' => $inputVariables['H0'] ?? null, + 'QTY' => $inputVariables['QTY'] ?? 1, + 'bracket_inch' => $inputVariables['bracket_inch'] ?? '5', + 'product_type' => $inputVariables['product_type'] ?? 'screen', + 'finishing_type' => $inputVariables['finishing_type'] ?? 'SUS', + 'finished_goods' => $finishedGoodsCode, + ]); + + // Step 2: 완제품 조회 + $finishedGoods = $this->getItemDetails($finishedGoodsCode, $tenantId); + + if (! $finishedGoods) { + $this->addDebugStep(2, '완제품선택', [ + 'code' => $finishedGoodsCode, + 'error' => '완제품을 찾을 수 없습니다.', + ]); + + return [ + 'success' => false, + 'error' => __('error.finished_goods_not_found', ['code' => $finishedGoodsCode]), + 'debug_steps' => $this->debugSteps, + ]; + } + + $this->addDebugStep(2, '완제품선택', [ + 'code' => $finishedGoods['code'], + 'name' => $finishedGoods['name'], + 'item_category' => $finishedGoods['item_category'] ?? 'N/A', + ]); + + // KyungdongFormulaHandler 인스턴스 생성 + $handler = new KyungdongFormulaHandler; + + // Step 3: 경동 전용 변수 계산 + $W0 = (float) ($inputVariables['W0'] ?? 0); + $H0 = (float) ($inputVariables['H0'] ?? 0); + $QTY = (int) ($inputVariables['QTY'] ?? 1); + $bracketInch = $inputVariables['bracket_inch'] ?? '5'; + $productType = $inputVariables['product_type'] ?? 'screen'; + + // 중량 계산 (5130 로직) + $area = ($W0 * ($H0 + 550)) / 1000000; + $weight = $area * ($productType === 'steel' ? 25 : 2) + ($W0 / 1000) * 14.17; + + // 모터 용량 결정 + $motorCapacity = $handler->calculateMotorCapacity($productType, $weight, $bracketInch); + + // 브라켓 크기 결정 + $bracketSize = $handler->calculateBracketSize($weight, $bracketInch); + + $calculatedVariables = array_merge($inputVariables, [ + 'W0' => $W0, + 'H0' => $H0, + 'QTY' => $QTY, + 'W1' => $W0 + 140, + 'H1' => $H0 + 350, + 'AREA' => round($area, 4), + 'WEIGHT' => round($weight, 2), + 'MOTOR_CAPACITY' => $motorCapacity, + 'BRACKET_SIZE' => $bracketSize, + 'bracket_inch' => $bracketInch, + 'product_type' => $productType, + ]); + + $this->addDebugStep(3, '변수계산', [ + 'W0' => $W0, + 'H0' => $H0, + 'area' => round($area, 4), + 'weight' => round($weight, 2), + 'motor_capacity' => $motorCapacity, + 'bracket_size' => $bracketSize, + 'calculation_type' => '경동기업 전용 공식', + ]); + + // Step 4-7: 동적 항목 계산 (KyungdongFormulaHandler 사용) + $dynamicItems = $handler->calculateDynamicItems($calculatedVariables); + + $this->addDebugStep(4, 'BOM전개', [ + 'total_items' => count($dynamicItems), + 'item_categories' => array_unique(array_column($dynamicItems, 'category')), + ]); + + // Step 5-7: 단가 계산 (각 항목별) + $calculatedItems = []; + foreach ($dynamicItems as $item) { + $this->addDebugStep(6, '수량계산', [ + 'item_name' => $item['item_name'], + 'quantity' => $item['quantity'], + ]); + + $this->addDebugStep(7, '금액계산', [ + 'item_name' => $item['item_name'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'total_price' => $item['total_price'], + ]); + + $calculatedItems[] = [ + 'item_code' => $item['item_code'] ?? '', + 'item_name' => $item['item_name'], + 'item_category' => $item['category'], + 'specification' => $item['specification'] ?? '', + 'unit' => $item['unit'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'total_price' => $item['total_price'], + 'category_group' => $item['category'], + 'calculation_note' => '경동기업 전용 계산', + ]; + } + + // Step 8: 카테고리별 그룹화 + $groupedItems = []; + foreach ($calculatedItems as $item) { + $category = $item['category_group']; + if (! isset($groupedItems[$category])) { + $groupedItems[$category] = [ + 'name' => $this->getKyungdongCategoryName($category), + 'items' => [], + 'subtotal' => 0, + ]; + } + $groupedItems[$category]['items'][] = $item; + $groupedItems[$category]['subtotal'] += $item['total_price']; + } + + $this->addDebugStep(8, '카테고리그룹화', [ + 'groups' => array_map(fn ($g) => [ + 'name' => $g['name'], + 'count' => count($g['items']), + 'subtotal' => $g['subtotal'], + ], $groupedItems), + ]); + + // Step 9: 소계 계산 + $subtotals = []; + foreach ($groupedItems as $category => $group) { + $subtotals[$category] = [ + 'name' => $group['name'], + 'count' => count($group['items']), + 'subtotal' => $group['subtotal'], + ]; + } + + $this->addDebugStep(9, '소계계산', $subtotals); + + // Step 10: 최종 합계 + $grandTotal = array_sum(array_column($calculatedItems, 'total_price')); + + $this->addDebugStep(10, '최종합계', [ + 'item_count' => count($calculatedItems), + 'grand_total' => $grandTotal, + 'formatted' => number_format($grandTotal).'원', + ]); + + return [ + 'success' => true, + 'finished_goods' => $finishedGoods, + 'variables' => $calculatedVariables, + 'items' => $calculatedItems, + 'grouped_items' => $groupedItems, + 'subtotals' => $subtotals, + 'grand_total' => $grandTotal, + 'debug_steps' => $this->debugSteps, + 'calculation_type' => 'kyungdong', + ]; + } + + /** + * 경동기업 카테고리명 반환 + */ + private function getKyungdongCategoryName(string $category): string + { + return match ($category) { + 'material' => '주자재', + 'motor' => '모터', + 'controller' => '제어기', + 'steel' => '절곡품', + 'parts' => '부자재', + default => $category, + }; + } } diff --git a/app/Services/Quote/Handlers/KyungdongFormulaHandler.php b/app/Services/Quote/Handlers/KyungdongFormulaHandler.php new file mode 100644 index 0000000..6e27858 --- /dev/null +++ b/app/Services/Quote/Handlers/KyungdongFormulaHandler.php @@ -0,0 +1,773 @@ +calculateScreenMotor($weight, $inch); + } + + return $this->calculateSteelMotor($weight, $inch); + } + + /** + * 스크린 모터 용량 계산 + */ + private function calculateScreenMotor(float $weight, int $inch): string + { + if ($inch === 4) { + if ($weight <= 150) { + return '150K'; + } + if ($weight <= 300) { + return '300K'; + } + + return '400K'; + } + + if ($inch === 5) { + if ($weight <= 123) { + return '150K'; + } + if ($weight <= 246) { + return '300K'; + } + if ($weight <= 327) { + return '400K'; + } + if ($weight <= 500) { + return '500K'; + } + + return '600K'; + } + + if ($inch === 6) { + if ($weight <= 104) { + return '150K'; + } + if ($weight <= 208) { + return '300K'; + } + if ($weight <= 300) { + return '400K'; + } + if ($weight <= 424) { + return '500K'; + } + + return '600K'; + } + + // 기본값 + return '300K'; + } + + /** + * 철재 모터 용량 계산 + */ + private function calculateSteelMotor(float $weight, int $inch): string + { + if ($inch === 4) { + if ($weight <= 300) { + return '300K'; + } + + return '400K'; + } + + if ($inch === 5) { + if ($weight <= 246) { + return '300K'; + } + if ($weight <= 327) { + return '400K'; + } + if ($weight <= 500) { + return '500K'; + } + + return '600K'; + } + + if ($inch === 6) { + if ($weight <= 208) { + return '300K'; + } + if ($weight <= 277) { + return '400K'; + } + if ($weight <= 424) { + return '500K'; + } + if ($weight <= 508) { + return '600K'; + } + if ($weight <= 800) { + return '800K'; + } + + return '1000K'; + } + + if ($inch === 8) { + if ($weight <= 324) { + return '500K'; + } + if ($weight <= 388) { + return '600K'; + } + if ($weight <= 611) { + return '800K'; + } + + return '1000K'; + } + + // 기본값 + return '300K'; + } + + // ========================================================================= + // 브라켓 크기 계산 + // ========================================================================= + + /** + * 브라켓 크기 결정 + * + * @param float $weight 중량 (kg) + * @param string|null $bracketInch 브라켓 인치 (선택) + * @return string 브라켓 크기 (530*320, 600*350, 690*390) + */ + public function calculateBracketSize(float $weight, ?string $bracketInch = null): string + { + $motorCapacity = $this->getMotorCapacityByWeight($weight, $bracketInch); + + return match ($motorCapacity) { + '300K', '400K' => '530*320', + '500K', '600K' => '600*350', + '800K', '1000K' => '690*390', + default => '530*320', + }; + } + + /** + * 중량으로 모터 용량 판단 (인치 없을 때) + */ + private function getMotorCapacityByWeight(float $weight, ?string $bracketInch = null): string + { + if ($bracketInch) { + // 인치가 있으면 철재 기준으로 계산 + return $this->calculateSteelMotor($weight, (int) $bracketInch); + } + + // 인치 없으면 중량만으로 판단 + if ($weight <= 300) { + return '300K'; + } + if ($weight <= 400) { + return '400K'; + } + if ($weight <= 500) { + return '500K'; + } + if ($weight <= 600) { + return '600K'; + } + if ($weight <= 800) { + return '800K'; + } + + return '1000K'; + } + + // ========================================================================= + // 주자재(스크린) 계산 + // ========================================================================= + + /** + * 스크린 주자재 가격 계산 + * + * @param float $width 폭 (mm) + * @param float $height 높이 (mm) + * @return array [unit_price, area, total_price] + */ + public function calculateScreenPrice(float $width, float $height): array + { + // 면적 계산: W × (H + 550) / 1,000,000 + $calculateHeight = $height + 550; + $area = ($width * $calculateHeight) / 1000000; + + // 원자재 단가 조회 (실리카/스크린) + $unitPrice = $this->getRawMaterialPrice('실리카'); + + return [ + 'unit_price' => $unitPrice, + 'area' => round($area, 2), + 'total_price' => round($unitPrice * $area), + ]; + } + + // ========================================================================= + // 단가 조회 메서드 (KdPriceTable 사용) + // ========================================================================= + + /** + * BDmodels 테이블에서 단가 조회 + * + * @param string $modelName 모델코드 (KSS01, KWS01 등) + * @param string $secondItem 부품분류 (케이스, 가이드레일, 하단마감재, L-BAR 등) + * @param string|null $finishingType 마감재질 (SUS, EGI) + * @param string|null $spec 규격 (120*70, 650*550 등) + * @return float 단가 + */ + public function getBDModelPrice( + string $modelName, + string $secondItem, + ?string $finishingType = null, + ?string $spec = null + ): float { + // BDmodels는 복잡한 구조이므로 items 테이블의 기존 데이터 활용 + // TODO: 필요시 kd_price_tables TYPE_BDMODELS 추가 + return 0.0; + } + + /** + * price_* 테이블에서 단가 조회 (모터, 샤프트, 파이프, 앵글) + * + * @param string $tableName 테이블명 (motor, shaft, pipe, angle) + * @param array $conditions 조회 조건 + * @return float 단가 + */ + public function getPriceFromTable(string $tableName, array $conditions): float + { + $query = KdPriceTable::where('table_type', $tableName)->active(); + + foreach ($conditions as $field => $value) { + $query->where($field, $value); + } + + $record = $query->first(); + + return (float) ($record?->unit_price ?? 0); + } + + /** + * 원자재 단가 조회 + * + * @param string $materialName 원자재명 (실리카, 스크린 등) + * @return float 단가 + */ + public function getRawMaterialPrice(string $materialName): float + { + return KdPriceTable::getRawMaterialPrice($materialName); + } + + /** + * 모터 단가 조회 + * + * @param string $motorCapacity 모터 용량 (150K, 300K 등) + * @return float 단가 + */ + public function getMotorPrice(string $motorCapacity): float + { + return KdPriceTable::getMotorPrice($motorCapacity); + } + + /** + * 제어기 단가 조회 + * + * @param string $controllerType 제어기 타입 (매립형, 노출형, 뒷박스) + * @return float 단가 + */ + public function getControllerPrice(string $controllerType): float + { + return KdPriceTable::getControllerPrice($controllerType); + } + + /** + * 샤프트 단가 조회 + * + * @param string $size 사이즈 (3, 4, 5인치) + * @param float $length 길이 (m 단위) + * @return float 단가 + */ + public function getShaftPrice(string $size, float $length): float + { + return KdPriceTable::getShaftPrice($size, $length); + } + + /** + * 파이프 단가 조회 + * + * @param string $thickness 두께 (1.4 등) + * @param int $length 길이 (3000, 6000) + * @return float 단가 + */ + public function getPipePrice(string $thickness, int $length): float + { + return KdPriceTable::getPipePrice($thickness, $length); + } + + /** + * 앵글 단가 조회 + * + * @param string $type 타입 (스크린용, 철재용) + * @param string $bracketSize 브라켓크기 (530*320, 600*350, 690*390) + * @param string $angleType 앵글타입 (앵글3T, 앵글4T) + * @return float 단가 + */ + public function getAnglePrice(string $type, string $bracketSize, string $angleType): float + { + return KdPriceTable::getAnglePrice($type, $bracketSize, $angleType); + } + + // ========================================================================= + // 절곡품 계산 (10종) + // ========================================================================= + + /** + * 절곡품 항목 계산 (10종) + * + * 케이스, 케이스용 연기차단재, 케이스 마구리, 가이드레일, + * 레일용 연기차단재, 하장바, L바, 보강평철, 무게평철12T, 환봉 + * + * @param array $params 입력 파라미터 + * @return array 절곡품 항목 배열 + */ + public function calculateSteelItems(array $params): array + { + $items = []; + + // 기본 파라미터 + $width = (float) ($params['W0'] ?? 0); + $height = (float) ($params['H0'] ?? 0); + $quantity = (int) ($params['QTY'] ?? 1); + $modelName = $params['model_name'] ?? 'KSS01'; + $finishingType = $params['finishing_type'] ?? 'SUS'; + + // 절곡품 관련 파라미터 + $caseSpec = $params['case_spec'] ?? '500*380'; + $caseLength = (float) ($params['case_length'] ?? $width); // mm 단위 + $guideType = $params['guide_type'] ?? '벽면형'; // 벽면형, 측면형, 혼합형 + $guideSpec = $params['guide_spec'] ?? '120*70'; // 120*70, 120*100 + $guideLength = (float) ($params['guide_length'] ?? ($height + 550)) / 1000; // m 단위 + $bottomBarLength = (float) ($params['bottombar_length'] ?? $width) / 1000; // m 단위 + $lbarLength = (float) ($params['lbar_length'] ?? $width) / 1000; // m 단위 + $flatBarLength = (float) ($params['flatbar_length'] ?? $width) / 1000; // m 단위 + $weightPlateQty = (int) ($params['weight_plate_qty'] ?? 0); // 무게평철 수량 + $roundBarQty = (int) ($params['round_bar_qty'] ?? 0); // 환봉 수량 + + // 1. 케이스 (단가/1000 × 길이mm × 수량) + $casePrice = KdPriceTable::getCasePrice($caseSpec); + if ($casePrice > 0 && $caseLength > 0) { + $totalPrice = ($casePrice / 1000) * $caseLength * $quantity; + $items[] = [ + 'category' => 'steel', + 'item_name' => '케이스', + 'specification' => "{$caseSpec} {$caseLength}mm", + 'unit' => 'm', + 'quantity' => $caseLength / 1000 * $quantity, + 'unit_price' => $casePrice, + 'total_price' => round($totalPrice), + ]; + } + + // 2. 케이스용 연기차단재 (단가 × 길이m × 수량) + $caseSmokePrice = KdPriceTable::getCaseSmokeBlockPrice(); + if ($caseSmokePrice > 0 && $caseLength > 0) { + $lengthM = $caseLength / 1000; + $items[] = [ + 'category' => 'steel', + 'item_name' => '케이스용 연기차단재', + 'specification' => "{$lengthM}m", + 'unit' => 'm', + 'quantity' => $lengthM * $quantity, + 'unit_price' => $caseSmokePrice, + 'total_price' => round($caseSmokePrice * $lengthM * $quantity), + ]; + } + + // 3. 케이스 마구리 (단가 × 수량) + $caseCapPrice = KdPriceTable::getCaseCapPrice($caseSpec); + if ($caseCapPrice > 0) { + $capQty = 2 * $quantity; // 좌우 2개 + $items[] = [ + 'category' => 'steel', + 'item_name' => '케이스 마구리', + 'specification' => $caseSpec, + 'unit' => 'EA', + 'quantity' => $capQty, + 'unit_price' => $caseCapPrice, + 'total_price' => round($caseCapPrice * $capQty), + ]; + } + + // 4. 가이드레일 (단가 × 길이m × 수량) - 타입별 처리 + $guideItems = $this->calculateGuideRails($modelName, $finishingType, $guideType, $guideSpec, $guideLength, $quantity); + $items = array_merge($items, $guideItems); + + // 5. 레일용 연기차단재 (단가 × 길이m × 2 × 수량) + $railSmokePrice = KdPriceTable::getRailSmokeBlockPrice(); + if ($railSmokePrice > 0 && $guideLength > 0) { + $railSmokeQty = 2 * $quantity; // 좌우 2개 + $items[] = [ + 'category' => 'steel', + 'item_name' => '레일용 연기차단재', + 'specification' => "{$guideLength}m × 2", + 'unit' => 'm', + 'quantity' => $guideLength * $railSmokeQty, + 'unit_price' => $railSmokePrice, + 'total_price' => round($railSmokePrice * $guideLength * $railSmokeQty), + ]; + } + + // 6. 하장바 (단가 × 길이m × 수량) + $bottomBarPrice = KdPriceTable::getBottomBarPrice($modelName, $finishingType); + if ($bottomBarPrice > 0 && $bottomBarLength > 0) { + $items[] = [ + 'category' => 'steel', + 'item_name' => '하장바', + 'specification' => "{$modelName} {$finishingType} {$bottomBarLength}m", + 'unit' => 'm', + 'quantity' => $bottomBarLength * $quantity, + 'unit_price' => $bottomBarPrice, + 'total_price' => round($bottomBarPrice * $bottomBarLength * $quantity), + ]; + } + + // 7. L바 (단가 × 길이m × 수량) + $lbarPrice = KdPriceTable::getLBarPrice($modelName); + if ($lbarPrice > 0 && $lbarLength > 0) { + $items[] = [ + 'category' => 'steel', + 'item_name' => 'L바', + 'specification' => "{$modelName} {$lbarLength}m", + 'unit' => 'm', + 'quantity' => $lbarLength * $quantity, + 'unit_price' => $lbarPrice, + 'total_price' => round($lbarPrice * $lbarLength * $quantity), + ]; + } + + // 8. 보강평철 (단가 × 길이m × 수량) + $flatBarPrice = KdPriceTable::getFlatBarPrice(); + if ($flatBarPrice > 0 && $flatBarLength > 0) { + $items[] = [ + 'category' => 'steel', + 'item_name' => '보강평철', + 'specification' => "{$flatBarLength}m", + 'unit' => 'm', + 'quantity' => $flatBarLength * $quantity, + 'unit_price' => $flatBarPrice, + 'total_price' => round($flatBarPrice * $flatBarLength * $quantity), + ]; + } + + // 9. 무게평철12T (고정 12,000원 × 수량) + if ($weightPlateQty > 0) { + $weightPlatePrice = 12000; + $items[] = [ + 'category' => 'steel', + 'item_name' => '무게평철12T', + 'specification' => '12T', + 'unit' => 'EA', + 'quantity' => $weightPlateQty * $quantity, + 'unit_price' => $weightPlatePrice, + 'total_price' => $weightPlatePrice * $weightPlateQty * $quantity, + ]; + } + + // 10. 환봉 (고정 2,000원 × 수량) + if ($roundBarQty > 0) { + $roundBarPrice = 2000; + $items[] = [ + 'category' => 'steel', + 'item_name' => '환봉', + 'specification' => '', + 'unit' => 'EA', + 'quantity' => $roundBarQty * $quantity, + 'unit_price' => $roundBarPrice, + 'total_price' => $roundBarPrice * $roundBarQty * $quantity, + ]; + } + + return $items; + } + + /** + * 가이드레일 계산 (타입별 처리) + * + * @param string $modelName 모델코드 + * @param string $finishingType 마감재질 + * @param string $guideType 가이드레일 타입 (벽면형, 측면형, 혼합형) + * @param string $guideSpec 가이드레일 규격 (120*70, 120*100) + * @param float $guideLength 가이드레일 길이 (m) + * @param int $quantity 수량 + * @return array 가이드레일 항목 배열 + */ + private function calculateGuideRails( + string $modelName, + string $finishingType, + string $guideType, + string $guideSpec, + float $guideLength, + int $quantity + ): array { + $items = []; + + if ($guideLength <= 0) { + return $items; + } + + switch ($guideType) { + case '벽면형': + // 120*70 × 2개 + $price = KdPriceTable::getGuideRailPrice($modelName, $finishingType, '120*70'); + if ($price > 0) { + $guideQty = 2 * $quantity; + $items[] = [ + 'category' => 'steel', + 'item_name' => '가이드레일', + 'specification' => "{$modelName} {$finishingType} 120*70 {$guideLength}m × 2", + 'unit' => 'm', + 'quantity' => $guideLength * $guideQty, + 'unit_price' => $price, + 'total_price' => round($price * $guideLength * $guideQty), + ]; + } + break; + + case '측면형': + // 120*100 × 2개 + $price = KdPriceTable::getGuideRailPrice($modelName, $finishingType, '120*100'); + if ($price > 0) { + $guideQty = 2 * $quantity; + $items[] = [ + 'category' => 'steel', + 'item_name' => '가이드레일', + 'specification' => "{$modelName} {$finishingType} 120*100 {$guideLength}m × 2", + 'unit' => 'm', + 'quantity' => $guideLength * $guideQty, + 'unit_price' => $price, + 'total_price' => round($price * $guideLength * $guideQty), + ]; + } + break; + + case '혼합형': + // 120*70 × 1개 + 120*100 × 1개 + $price70 = KdPriceTable::getGuideRailPrice($modelName, $finishingType, '120*70'); + $price100 = KdPriceTable::getGuideRailPrice($modelName, $finishingType, '120*100'); + + if ($price70 > 0) { + $items[] = [ + 'category' => 'steel', + 'item_name' => '가이드레일', + 'specification' => "{$modelName} {$finishingType} 120*70 {$guideLength}m", + 'unit' => 'm', + 'quantity' => $guideLength * $quantity, + 'unit_price' => $price70, + 'total_price' => round($price70 * $guideLength * $quantity), + ]; + } + if ($price100 > 0) { + $items[] = [ + 'category' => 'steel', + 'item_name' => '가이드레일', + 'specification' => "{$modelName} {$finishingType} 120*100 {$guideLength}m", + 'unit' => 'm', + 'quantity' => $guideLength * $quantity, + 'unit_price' => $price100, + 'total_price' => round($price100 * $guideLength * $quantity), + ]; + } + break; + } + + return $items; + } + + // ========================================================================= + // 부자재 계산 (3종) + // ========================================================================= + + /** + * 부자재 항목 계산 + * + * @param array $params 입력 파라미터 + * @return array 부자재 항목 배열 + */ + public function calculatePartItems(array $params): array + { + $items = []; + + $width = (float) ($params['W0'] ?? 0); + $bracketInch = $params['bracket_inch'] ?? '5'; + $bracketSize = $params['BRACKET_SIZE'] ?? $this->calculateBracketSize(100, $bracketInch); + $productType = $params['product_type'] ?? 'screen'; + $quantity = (int) ($params['QTY'] ?? 1); + + // 1. 감기샤프트 + $shaftSize = $bracketInch; + $shaftLength = ceil($width / 1000); // mm → m 변환 후 올림 + $shaftPrice = $this->getShaftPrice($shaftSize, $shaftLength); + if ($shaftPrice > 0) { + $items[] = [ + 'category' => 'parts', + 'item_name' => "감기샤프트 {$shaftSize}인치", + 'specification' => "{$shaftLength}m", + 'unit' => 'EA', + 'quantity' => $quantity, + 'unit_price' => $shaftPrice, + 'total_price' => $shaftPrice * $quantity, + ]; + } + + // 2. 각파이프 + $pipeThickness = '1.4'; + $pipeLength = $width > 3000 ? 6000 : 3000; + $pipePrice = $this->getPipePrice($pipeThickness, $pipeLength); + if ($pipePrice > 0) { + $items[] = [ + 'category' => 'parts', + 'item_name' => '각파이프', + 'specification' => "{$pipeThickness}T {$pipeLength}mm", + 'unit' => 'EA', + 'quantity' => $quantity, + 'unit_price' => $pipePrice, + 'total_price' => $pipePrice * $quantity, + ]; + } + + // 3. 앵글 + $angleType = $productType === 'steel' ? '철재용' : '스크린용'; + $angleSpec = $bracketSize === '690*390' ? '앵글4T' : '앵글3T'; + $anglePrice = $this->getAnglePrice($angleType, $bracketSize, $angleSpec); + if ($anglePrice > 0) { + $items[] = [ + 'category' => 'parts', + 'item_name' => "앵글 {$angleSpec}", + 'specification' => "{$angleType} {$bracketSize}", + 'unit' => 'EA', + 'quantity' => 2 * $quantity, // 좌우 2개 + 'unit_price' => $anglePrice, + 'total_price' => $anglePrice * 2 * $quantity, + ]; + } + + return $items; + } + + // ========================================================================= + // 전체 동적 항목 계산 + // ========================================================================= + + /** + * 동적 항목 전체 계산 + * + * @param array $inputs 입력 파라미터 + * @return array 계산된 항목 배열 + */ + public function calculateDynamicItems(array $inputs): array + { + $items = []; + + $width = (float) ($inputs['W0'] ?? 0); + $height = (float) ($inputs['H0'] ?? 0); + $quantity = (int) ($inputs['QTY'] ?? 1); + $bracketInch = $inputs['bracket_inch'] ?? '5'; + $productType = $inputs['product_type'] ?? 'screen'; + + // 중량 계산 (5130 로직) + $area = ($width * ($height + 550)) / 1000000; + $weight = $area * ($productType === 'steel' ? 25 : 2) + ($width / 1000) * 14.17; + + // 모터 용량/브라켓 크기 계산 + $motorCapacity = $this->calculateMotorCapacity($productType, $weight, $bracketInch); + $bracketSize = $this->calculateBracketSize($weight, $bracketInch); + + // 입력값에 계산된 값 추가 (부자재 계산용) + $inputs['WEIGHT'] = $weight; + $inputs['MOTOR_CAPACITY'] = $motorCapacity; + $inputs['BRACKET_SIZE'] = $bracketSize; + + // 1. 주자재 (스크린) + $screenResult = $this->calculateScreenPrice($width, $height); + $items[] = [ + 'category' => 'material', + 'item_code' => 'KD-SCREEN', + 'item_name' => '주자재(스크린)', + 'specification' => "면적 {$screenResult['area']}㎡", + 'unit' => '㎡', + 'quantity' => $screenResult['area'] * $quantity, + 'unit_price' => $screenResult['unit_price'], + 'total_price' => $screenResult['total_price'] * $quantity, + ]; + + // 2. 모터 + $motorPrice = $this->getMotorPrice($motorCapacity); + $items[] = [ + 'category' => 'motor', + 'item_code' => "KD-MOTOR-{$motorCapacity}", + 'item_name' => "모터 {$motorCapacity}", + 'specification' => $motorCapacity, + 'unit' => 'EA', + 'quantity' => $quantity, + 'unit_price' => $motorPrice, + 'total_price' => $motorPrice * $quantity, + ]; + + // 3. 제어기 + $controllerType = $inputs['controller_type'] ?? '매립형'; + $controllerPrice = $this->getControllerPrice($controllerType); + $items[] = [ + 'category' => 'controller', + 'item_code' => 'KD-CTRL-'.strtoupper($controllerType), + 'item_name' => "제어기 {$controllerType}", + 'specification' => $controllerType, + 'unit' => 'EA', + 'quantity' => $quantity, + 'unit_price' => $controllerPrice, + 'total_price' => $controllerPrice * $quantity, + ]; + + // 4. 절곡품 + $steelItems = $this->calculateSteelItems($inputs); + $items = array_merge($items, $steelItems); + + // 5. 부자재 + $partItems = $this->calculatePartItems($inputs); + $items = array_merge($items, $partItems); + + return $items; + } +} diff --git a/database/migrations/2026_01_29_004736_create_kd_price_tables_table.php b/database/migrations/2026_01_29_004736_create_kd_price_tables_table.php new file mode 100644 index 0000000..b1d5691 --- /dev/null +++ b/database/migrations/2026_01_29_004736_create_kd_price_tables_table.php @@ -0,0 +1,62 @@ +id(); + $table->unsignedBigInteger('tenant_id')->default(287)->comment('경동기업 테넌트 ID'); + $table->string('table_type', 50)->comment('테이블 유형: motor, shaft, pipe, angle, raw_material, bdmodels'); + $table->string('item_code', 100)->nullable()->comment('품목 코드 (연동용)'); + $table->string('item_name', 200)->nullable()->comment('품목명'); + + // 조회 조건 필드들 + $table->string('category', 100)->nullable()->comment('분류 (모터용량, 재질, 타입 등)'); + $table->string('spec1', 100)->nullable()->comment('규격1 (사이즈, 두께 등)'); + $table->string('spec2', 100)->nullable()->comment('규격2 (길이, 브라켓크기 등)'); + $table->string('spec3', 100)->nullable()->comment('규격3 (추가 조건)'); + + // 단가 정보 + $table->decimal('unit_price', 15, 2)->default(0)->comment('단가'); + $table->string('unit', 20)->default('EA')->comment('단위'); + + // 원본 JSON 데이터 (레거시 호환용) + $table->json('raw_data')->nullable()->comment('원본 JSON 데이터'); + + // 메타 정보 + $table->boolean('is_active')->default(true)->comment('활성 여부'); + $table->timestamps(); + + // 인덱스 + $table->index(['tenant_id', 'table_type']); + $table->index(['table_type', 'category']); + $table->index(['table_type', 'spec1', 'spec2']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('kd_price_tables'); + } +}; diff --git a/database/seeders/Kyungdong/KdPriceTableSeeder.php b/database/seeders/Kyungdong/KdPriceTableSeeder.php new file mode 100644 index 0000000..490553a --- /dev/null +++ b/database/seeders/Kyungdong/KdPriceTableSeeder.php @@ -0,0 +1,565 @@ +command->info(''); + $this->command->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + $this->command->info('🔧 경동기업 단가 테이블 마이그레이션 (kd_price_tables)'); + $this->command->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + // 기존 데이터 삭제 + DB::table('kd_price_tables')->where('tenant_id', self::TENANT_ID)->delete(); + $this->command->info(' → 기존 데이터 삭제 완료'); + + // chandj 연결 가능 여부 확인 + $chandjAvailable = $this->checkChandjConnection(); + + if ($chandjAvailable) { + $this->command->info(' → chandj 데이터베이스 연결됨'); + $this->migrateFromChandj(); + } else { + $this->command->warn(' → chandj 데이터베이스 연결 불가 - 샘플 데이터 사용'); + $this->insertSampleData(); + } + + $count = DB::table('kd_price_tables')->where('tenant_id', self::TENANT_ID)->count(); + $this->command->info(''); + $this->command->info("✅ 완료: kd_price_tables {$count}건"); + } + + /** + * chandj 데이터베이스 연결 확인 + */ + private function checkChandjConnection(): bool + { + try { + DB::connection('chandj')->getPdo(); + + return true; + } catch (\Exception $e) { + return false; + } + } + + /** + * chandj 데이터베이스에서 마이그레이션 + */ + private function migrateFromChandj(): void + { + $this->migrateMotorPrices(); + $this->migrateShaftPrices(); + $this->migratePipePrices(); + $this->migrateAnglePrices(); + $this->migrateRawMaterialPrices(); + } + + /** + * price_motor → kd_price_tables + */ + private function migrateMotorPrices(): void + { + $this->command->info(''); + $this->command->info('📦 [1/5] price_motor 마이그레이션...'); + + $priceMotor = DB::connection('chandj') + ->table('price_motor') + ->where(function ($q) { + $q->where('is_deleted', 0)->orWhereNull('is_deleted'); + }) + ->orderByDesc('registedate') + ->first(); + + if (! $priceMotor || empty($priceMotor->itemList)) { + $this->command->info(' → 데이터 없음'); + + return; + } + + $itemList = json_decode($priceMotor->itemList, true); + if (! is_array($itemList)) { + $this->command->info(' → JSON 파싱 실패'); + + return; + } + + $count = 0; + $now = now(); + + foreach ($itemList as $item) { + $col1 = $item['col1'] ?? ''; // 전압/카테고리 (220, 380, 제어기 등) + $col2 = $item['col2'] ?? ''; // 용량/품목명 + $salesPrice = (float) str_replace(',', '', $item['col13'] ?? '0'); + + if (empty($col2) || $salesPrice <= 0) { + continue; + } + + // 카테고리 결정 + $category = match ($col1) { + '220', '380' => $col2, // 모터 용량 (150K, 300K 등) + default => $col1, // 제어기, 방화, 방범 등 + }; + + KdPriceTable::create([ + 'tenant_id' => self::TENANT_ID, + 'table_type' => KdPriceTable::TYPE_MOTOR, + 'item_name' => trim("{$col1} {$col2}"), + 'category' => $category, + 'spec1' => $col1, + 'spec2' => $col2, + 'unit_price' => $salesPrice, + 'unit' => 'EA', + 'raw_data' => $item, + 'is_active' => true, + ]); + $count++; + } + + $this->command->info(" → {$count}건 완료"); + } + + /** + * price_shaft → kd_price_tables + */ + private function migrateShaftPrices(): void + { + $this->command->info(''); + $this->command->info('📦 [2/5] price_shaft 마이그레이션...'); + + $priceShaft = DB::connection('chandj') + ->table('price_shaft') + ->where(function ($q) { + $q->where('is_deleted', 0)->orWhereNull('is_deleted'); + }) + ->orderByDesc('registedate') + ->first(); + + if (! $priceShaft || empty($priceShaft->itemList)) { + $this->command->info(' → 데이터 없음'); + + return; + } + + $itemList = json_decode($priceShaft->itemList, true); + if (! is_array($itemList)) { + $this->command->info(' → JSON 파싱 실패'); + + return; + } + + $count = 0; + + foreach ($itemList as $item) { + $size = $item['col4'] ?? ''; // 사이즈 (3, 4, 5인치) + $length = $item['col10'] ?? ''; // 길이 (m 단위) + $salesPrice = (float) str_replace(',', '', $item['col19'] ?? '0'); + + if (empty($size) || $salesPrice <= 0) { + continue; + } + + KdPriceTable::create([ + 'tenant_id' => self::TENANT_ID, + 'table_type' => KdPriceTable::TYPE_SHAFT, + 'item_name' => "감기샤프트 {$size}인치 {$length}m", + 'category' => '감기샤프트', + 'spec1' => $size, + 'spec2' => $length, + 'unit_price' => $salesPrice, + 'unit' => 'EA', + 'raw_data' => $item, + 'is_active' => true, + ]); + $count++; + } + + $this->command->info(" → {$count}건 완료"); + } + + /** + * price_pipe → kd_price_tables + */ + private function migratePipePrices(): void + { + $this->command->info(''); + $this->command->info('📦 [3/5] price_pipe 마이그레이션...'); + + $pricePipe = DB::connection('chandj') + ->table('price_pipe') + ->where(function ($q) { + $q->where('is_deleted', 0)->orWhereNull('is_deleted'); + }) + ->orderByDesc('registedate') + ->first(); + + if (! $pricePipe || empty($pricePipe->itemList)) { + $this->command->info(' → 데이터 없음'); + + return; + } + + $itemList = json_decode($pricePipe->itemList, true); + if (! is_array($itemList)) { + $this->command->info(' → JSON 파싱 실패'); + + return; + } + + $count = 0; + + foreach ($itemList as $item) { + $length = $item['col2'] ?? ''; // 길이 (3000, 6000) + $thickness = $item['col4'] ?? ''; // 두께 (1.4) + $salesPrice = (float) str_replace(',', '', $item['col8'] ?? '0'); + + if (empty($thickness) || $salesPrice <= 0) { + continue; + } + + KdPriceTable::create([ + 'tenant_id' => self::TENANT_ID, + 'table_type' => KdPriceTable::TYPE_PIPE, + 'item_name' => "각파이프 {$thickness}T {$length}mm", + 'category' => '각파이프', + 'spec1' => $thickness, + 'spec2' => $length, + 'unit_price' => $salesPrice, + 'unit' => 'EA', + 'raw_data' => $item, + 'is_active' => true, + ]); + $count++; + } + + $this->command->info(" → {$count}건 완료"); + } + + /** + * price_angle → kd_price_tables + */ + private function migrateAnglePrices(): void + { + $this->command->info(''); + $this->command->info('📦 [4/5] price_angle 마이그레이션...'); + + $priceAngle = DB::connection('chandj') + ->table('price_angle') + ->where(function ($q) { + $q->where('is_deleted', 0)->orWhereNull('is_deleted'); + }) + ->orderByDesc('registedate') + ->first(); + + if (! $priceAngle || empty($priceAngle->itemList)) { + $this->command->info(' → 데이터 없음'); + + return; + } + + $itemList = json_decode($priceAngle->itemList, true); + if (! is_array($itemList)) { + $this->command->info(' → JSON 파싱 실패'); + + return; + } + + $count = 0; + + foreach ($itemList as $item) { + $type = $item['col2'] ?? ''; // 타입 (스크린용, 철재용) + $bracketSize = $item['col3'] ?? ''; // 브라켓크기 + $angleType = $item['col4'] ?? ''; // 앵글타입 (앵글3T, 앵글4T) + $thickness = $item['col10'] ?? ''; // 두께 + $salesPrice = (float) str_replace(',', '', $item['col19'] ?? '0'); + + if (empty($type) || $salesPrice <= 0) { + continue; + } + + KdPriceTable::create([ + 'tenant_id' => self::TENANT_ID, + 'table_type' => KdPriceTable::TYPE_ANGLE, + 'item_name' => "앵글 {$type} {$bracketSize} {$angleType}", + 'category' => $type, + 'spec1' => $bracketSize, + 'spec2' => $angleType, + 'spec3' => $thickness, + 'unit_price' => $salesPrice, + 'unit' => 'EA', + 'raw_data' => $item, + 'is_active' => true, + ]); + $count++; + } + + $this->command->info(" → {$count}건 완료"); + } + + /** + * price_raw_materials → kd_price_tables + */ + private function migrateRawMaterialPrices(): void + { + $this->command->info(''); + $this->command->info('📦 [5/5] price_raw_materials 마이그레이션...'); + + $priceRaw = DB::connection('chandj') + ->table('price_raw_materials') + ->where(function ($q) { + $q->where('is_deleted', 0)->orWhereNull('is_deleted'); + }) + ->orderByDesc('registedate') + ->first(); + + if (! $priceRaw || empty($priceRaw->itemList)) { + $this->command->info(' → 데이터 없음'); + + return; + } + + $itemList = json_decode($priceRaw->itemList, true); + if (! is_array($itemList)) { + $this->command->info(' → JSON 파싱 실패'); + + return; + } + + $count = 0; + + foreach ($itemList as $item) { + $name = $item['col2'] ?? ''; + $spec = $item['col3'] ?? ''; + $salesPrice = (float) str_replace(',', '', $item['col19'] ?? $item['col13'] ?? '0'); + + if (empty($name) || $salesPrice <= 0) { + continue; + } + + KdPriceTable::create([ + 'tenant_id' => self::TENANT_ID, + 'table_type' => KdPriceTable::TYPE_RAW_MATERIAL, + 'item_name' => $name, + 'category' => '원자재', + 'spec1' => $spec, + 'unit_price' => $salesPrice, + 'unit' => '㎡', + 'raw_data' => $item, + 'is_active' => true, + ]); + $count++; + } + + $this->command->info(" → {$count}건 완료"); + } + + /** + * chandj 연결 불가 시 샘플 데이터 삽입 + */ + private function insertSampleData(): void + { + $this->command->info(''); + $this->command->info('📦 샘플 데이터 삽입 중...'); + + // 모터 샘플 데이터 (5130 분석 결과 기반) + $motorData = [ + ['category' => '150K', 'spec1' => '220', 'spec2' => '150K', 'unit_price' => 85000], + ['category' => '300K', 'spec1' => '220', 'spec2' => '300K', 'unit_price' => 120000], + ['category' => '400K', 'spec1' => '220', 'spec2' => '400K', 'unit_price' => 150000], + ['category' => '500K', 'spec1' => '220', 'spec2' => '500K', 'unit_price' => 180000], + ['category' => '600K', 'spec1' => '220', 'spec2' => '600K', 'unit_price' => 220000], + ['category' => '800K', 'spec1' => '220', 'spec2' => '800K', 'unit_price' => 280000], + ['category' => '1000K', 'spec1' => '220', 'spec2' => '1000K', 'unit_price' => 350000], + ['category' => '매립형', 'spec1' => '제어기', 'spec2' => '매립형', 'unit_price' => 45000], + ['category' => '노출형', 'spec1' => '제어기', 'spec2' => '노출형', 'unit_price' => 55000], + ['category' => '뒷박스', 'spec1' => '제어기', 'spec2' => '뒷박스', 'unit_price' => 35000], + ]; + + foreach ($motorData as $data) { + KdPriceTable::create(array_merge($data, [ + 'tenant_id' => self::TENANT_ID, + 'table_type' => KdPriceTable::TYPE_MOTOR, + 'item_name' => "모터/제어기 {$data['category']}", + 'unit' => 'EA', + 'is_active' => true, + ])); + } + $this->command->info(' → 모터/제어기 '.count($motorData).'건'); + + // 샤프트 샘플 데이터 + $shaftData = [ + ['spec1' => '3', 'spec2' => '3.0', 'unit_price' => 45000], + ['spec1' => '4', 'spec2' => '3.0', 'unit_price' => 55000], + ['spec1' => '5', 'spec2' => '3.0', 'unit_price' => 65000], + ['spec1' => '3', 'spec2' => '4.0', 'unit_price' => 60000], + ['spec1' => '4', 'spec2' => '4.0', 'unit_price' => 75000], + ['spec1' => '5', 'spec2' => '4.0', 'unit_price' => 90000], + ]; + + foreach ($shaftData as $data) { + KdPriceTable::create(array_merge($data, [ + 'tenant_id' => self::TENANT_ID, + 'table_type' => KdPriceTable::TYPE_SHAFT, + 'item_name' => "감기샤프트 {$data['spec1']}인치 {$data['spec2']}m", + 'category' => '감기샤프트', + 'unit' => 'EA', + 'is_active' => true, + ])); + } + $this->command->info(' → 샤프트 '.count($shaftData).'건'); + + // 파이프 샘플 데이터 + $pipeData = [ + ['spec1' => '1.4', 'spec2' => '3000', 'unit_price' => 12000], + ['spec1' => '1.4', 'spec2' => '6000', 'unit_price' => 24000], + ]; + + foreach ($pipeData as $data) { + KdPriceTable::create(array_merge($data, [ + 'tenant_id' => self::TENANT_ID, + 'table_type' => KdPriceTable::TYPE_PIPE, + 'item_name' => "각파이프 {$data['spec1']}T {$data['spec2']}mm", + 'category' => '각파이프', + 'unit' => 'EA', + 'is_active' => true, + ])); + } + $this->command->info(' → 파이프 '.count($pipeData).'건'); + + // 앵글 샘플 데이터 + $angleData = [ + ['category' => '스크린용', 'spec1' => '530*320', 'spec2' => '앵글3T', 'unit_price' => 8000], + ['category' => '스크린용', 'spec1' => '600*350', 'spec2' => '앵글3T', 'unit_price' => 10000], + ['category' => '스크린용', 'spec1' => '690*390', 'spec2' => '앵글4T', 'unit_price' => 12000], + ['category' => '철재용', 'spec1' => '530*320', 'spec2' => '앵글3T', 'unit_price' => 9000], + ['category' => '철재용', 'spec1' => '600*350', 'spec2' => '앵글3T', 'unit_price' => 11000], + ['category' => '철재용', 'spec1' => '690*390', 'spec2' => '앵글4T', 'unit_price' => 14000], + ]; + + foreach ($angleData as $data) { + KdPriceTable::create(array_merge($data, [ + 'tenant_id' => self::TENANT_ID, + 'table_type' => KdPriceTable::TYPE_ANGLE, + 'item_name' => "앵글 {$data['category']} {$data['spec1']} {$data['spec2']}", + 'unit' => 'EA', + 'is_active' => true, + ])); + } + $this->command->info(' → 앵글 '.count($angleData).'건'); + + // 원자재 샘플 데이터 + $rawData = [ + ['item_name' => '실리카', 'spec1' => '스크린용', 'unit_price' => 25000], + ['item_name' => '불투명', 'spec1' => '스크린용', 'unit_price' => 22000], + ['item_name' => '화이바원단', 'spec1' => '스크린용', 'unit_price' => 28000], + ]; + + foreach ($rawData as $data) { + KdPriceTable::create(array_merge($data, [ + 'tenant_id' => self::TENANT_ID, + 'table_type' => KdPriceTable::TYPE_RAW_MATERIAL, + 'category' => '원자재', + 'unit' => '㎡', + 'is_active' => true, + ])); + } + $this->command->info(' → 원자재 '.count($rawData).'건'); + + // BDmodels 샘플 데이터 (절곡품) + $this->insertBDModelsSampleData(); + } + + /** + * BDmodels 샘플 데이터 삽입 (절곡품) + */ + private function insertBDModelsSampleData(): void + { + $bdmodelsData = [ + // 케이스 (규격별 단가 - 원/m) + ['category' => '케이스', 'spec2' => '500*380', 'unit_price' => 15000, 'unit' => 'm'], + ['category' => '케이스', 'spec2' => '550*430', 'unit_price' => 18000, 'unit' => 'm'], + ['category' => '케이스', 'spec2' => '650*550', 'unit_price' => 22000, 'unit' => 'm'], + + // 케이스 마구리 (규격별 단가 - 원/개) + ['category' => '마구리', 'spec2' => '500*380', 'unit_price' => 5000, 'unit' => 'EA'], + ['category' => '마구리', 'spec2' => '550*430', 'unit_price' => 6000, 'unit' => 'EA'], + ['category' => '마구리', 'spec2' => '650*550', 'unit_price' => 7500, 'unit' => 'EA'], + + // 케이스용 연기차단재 (공통 단가 - 원/m) + ['category' => '케이스용 연기차단재', 'unit_price' => 3500, 'unit' => 'm'], + + // 가이드레일 (모델+마감+규격별 단가 - 원/m) + ['category' => '가이드레일', 'item_code' => 'KSS01', 'spec1' => 'SUS', 'spec2' => '120*70', 'unit_price' => 12000, 'unit' => 'm'], + ['category' => '가이드레일', 'item_code' => 'KSS01', 'spec1' => 'SUS', 'spec2' => '120*100', 'unit_price' => 15000, 'unit' => 'm'], + ['category' => '가이드레일', 'item_code' => 'KSS01', 'spec1' => 'EGI', 'spec2' => '120*70', 'unit_price' => 10000, 'unit' => 'm'], + ['category' => '가이드레일', 'item_code' => 'KSS01', 'spec1' => 'EGI', 'spec2' => '120*100', 'unit_price' => 13000, 'unit' => 'm'], + ['category' => '가이드레일', 'item_code' => 'KWS01', 'spec1' => 'SUS', 'spec2' => '120*70', 'unit_price' => 13000, 'unit' => 'm'], + ['category' => '가이드레일', 'item_code' => 'KWS01', 'spec1' => 'SUS', 'spec2' => '120*100', 'unit_price' => 16000, 'unit' => 'm'], + + // 가이드레일용 연기차단재 (공통 단가 - 원/m) + ['category' => '가이드레일용 연기차단재', 'unit_price' => 2500, 'unit' => 'm'], + + // 하단마감재/하장바 (모델+마감별 단가 - 원/m) + ['category' => '하단마감재', 'item_code' => 'KSS01', 'spec1' => 'SUS', 'unit_price' => 8000, 'unit' => 'm'], + ['category' => '하단마감재', 'item_code' => 'KSS01', 'spec1' => 'EGI', 'unit_price' => 6500, 'unit' => 'm'], + ['category' => '하단마감재', 'item_code' => 'KWS01', 'spec1' => 'SUS', 'unit_price' => 8500, 'unit' => 'm'], + + // L-BAR (모델별 단가 - 원/m) + ['category' => 'L-BAR', 'item_code' => 'KSS01', 'unit_price' => 4500, 'unit' => 'm'], + ['category' => 'L-BAR', 'item_code' => 'KWS01', 'unit_price' => 5000, 'unit' => 'm'], + + // 보강평철 (공통 단가 - 원/m) + ['category' => '보강평철', 'unit_price' => 3000, 'unit' => 'm'], + ]; + + foreach ($bdmodelsData as $data) { + $itemCode = $data['item_code'] ?? null; + $spec1 = $data['spec1'] ?? null; + $spec2 = $data['spec2'] ?? null; + + $itemName = $data['category']; + if ($itemCode) { + $itemName .= " {$itemCode}"; + } + if ($spec1) { + $itemName .= " {$spec1}"; + } + if ($spec2) { + $itemName .= " {$spec2}"; + } + + KdPriceTable::create([ + 'tenant_id' => self::TENANT_ID, + 'table_type' => KdPriceTable::TYPE_BDMODELS, + 'item_code' => $itemCode, + 'item_name' => $itemName, + 'category' => $data['category'], + 'spec1' => $spec1, + 'spec2' => $spec2, + 'unit_price' => $data['unit_price'], + 'unit' => $data['unit'], + 'is_active' => true, + ]); + } + $this->command->info(' → BDmodels(절곡품) '.count($bdmodelsData).'건'); + } +} From 90bcfaf26852a01109702daf79ac8b547d91ddd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 01:12:40 +0900 Subject: [PATCH 29/57] =?UTF-8?q?chore:=20=EB=85=BC=EB=A6=AC=EC=A0=81=20?= =?UTF-8?q?=EA=B4=80=EA=B3=84=20=EB=AC=B8=EC=84=9C=20=EB=B0=8F=20=EA=B8=80?= =?UTF-8?q?=EB=A1=9C=EB=B2=8C=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LOGICAL_RELATIONSHIPS.md 업데이트 - create_global_categories_table 마이그레이션 추가 Co-Authored-By: Claude Opus 4.5 --- LOGICAL_RELATIONSHIPS.md | 31 ++++- ..._152523_create_global_categories_table.php | 118 ++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_01_27_152523_create_global_categories_table.php diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 575494d..12cf164 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-01-26 22:07:37 +> **자동 생성**: 2026-01-29 00:51:16 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -194,6 +194,35 @@ ### model_versions - **model()**: belongsTo → `models` - **bomTemplates()**: hasMany → `bom_templates` +### documents +**모델**: `App\Models\Documents\Document` + +- **creator()**: belongsTo → `users` +- **updater()**: belongsTo → `users` +- **approvals()**: hasMany → `document_approvals` +- **data()**: hasMany → `document_data` +- **attachments()**: hasMany → `document_attachments` +- **linkable()**: morphTo → `(Polymorphic)` + +### document_approvals +**모델**: `App\Models\Documents\DocumentApproval` + +- **document()**: belongsTo → `documents` +- **user()**: belongsTo → `users` +- **creator()**: belongsTo → `users` + +### document_attachments +**모델**: `App\Models\Documents\DocumentAttachment` + +- **document()**: belongsTo → `documents` +- **file()**: belongsTo → `files` +- **creator()**: belongsTo → `users` + +### document_datas +**모델**: `App\Models\Documents\DocumentData` + +- **document()**: belongsTo → `documents` + ### estimates **모델**: `App\Models\Estimate\Estimate` diff --git a/database/migrations/2026_01_27_152523_create_global_categories_table.php b/database/migrations/2026_01_27_152523_create_global_categories_table.php new file mode 100644 index 0000000..28baa8f --- /dev/null +++ b/database/migrations/2026_01_27_152523_create_global_categories_table.php @@ -0,0 +1,118 @@ +id()->comment('글로벌 카테고리 PK'); + $table->unsignedBigInteger('parent_id')->nullable()->comment('상위 카테고리ID(NULL=최상위)'); + $table->string('code_group', 30)->comment('코드 그룹'); + $table->string('profile_code', 30)->nullable()->comment('역할 프로필 코드'); + $table->string('code', 30)->comment('카테고리 코드(영문, 고유)'); + $table->string('name', 100)->comment('카테고리명'); + $table->boolean('is_active')->default(true)->comment('활성여부'); + $table->integer('sort_order')->default(1)->comment('정렬순서'); + $table->string('description', 255)->nullable()->comment('비고'); + $table->unsignedBigInteger('created_by')->nullable()->comment('등록자'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자'); + $table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자'); + $table->timestamps(); + $table->softDeletes(); + + // 인덱스 + $table->unique(['code_group', 'code'], 'uq_global_codegroup_code'); + $table->index('parent_id', 'idx_global_parent_id'); + $table->index('code_group', 'idx_global_code_group'); + + // 자기 참조 FK + $table->foreign('parent_id', 'fk_global_category_parent') + ->references('id') + ->on('global_categories') + ->onDelete('set null'); + }); + + // tenant 1의 카테고리를 글로벌 카테고리로 복사 + $this->seedFromTenant1(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('global_categories'); + } + + /** + * tenant 1의 카테고리를 글로벌로 복사 + */ + private function seedFromTenant1(): void + { + $categories = \DB::table('categories') + ->where('tenant_id', 1) + ->whereNull('deleted_at') + ->orderBy('parent_id') + ->orderBy('sort_order') + ->get(); + + if ($categories->isEmpty()) { + return; + } + + $idMap = []; // old_id => new_id + + // 1단계: parent_id가 NULL인 루트 카테고리 + foreach ($categories->where('parent_id', null) as $cat) { + $newId = \DB::table('global_categories')->insertGetId([ + 'parent_id' => null, + 'code_group' => $cat->code_group, + 'profile_code' => $cat->profile_code, + 'code' => $cat->code, + 'name' => $cat->name, + 'is_active' => $cat->is_active, + 'sort_order' => $cat->sort_order, + 'description' => $cat->description, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $idMap[$cat->id] = $newId; + } + + // 2단계: 자식 카테고리 (재귀적으로 처리) + $remaining = $categories->whereNotNull('parent_id'); + $maxIterations = 10; + $iteration = 0; + + while ($remaining->isNotEmpty() && $iteration < $maxIterations) { + $processed = []; + foreach ($remaining as $cat) { + if (isset($idMap[$cat->parent_id])) { + $newId = \DB::table('global_categories')->insertGetId([ + 'parent_id' => $idMap[$cat->parent_id], + 'code_group' => $cat->code_group, + 'profile_code' => $cat->profile_code, + 'code' => $cat->code, + 'name' => $cat->name, + 'is_active' => $cat->is_active, + 'sort_order' => $cat->sort_order, + 'description' => $cat->description, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $idMap[$cat->id] = $newId; + $processed[] = $cat->id; + } + } + $remaining = $remaining->whereNotIn('id', $processed); + $iteration++; + } + } +}; From a0ffeb954bee15967cd0d7b1db8fc85c180c8ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 07:44:04 +0900 Subject: [PATCH 30/57] =?UTF-8?q?fix:=20=EA=B2=AC=EC=A0=81=20=ED=99=95?= =?UTF-8?q?=EC=A0=95/=EC=B7=A8=EC=86=8C=20API=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /quotes/{id}/finalize 라우트 추가 - POST /quotes/{id}/cancel-finalize 라우트 추가 - 라우트 누락으로 인한 404 오류 수정 Co-Authored-By: Claude Opus 4.5 --- routes/api/v1/sales.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/routes/api/v1/sales.php b/routes/api/v1/sales.php index 9101180..493517f 100644 --- a/routes/api/v1/sales.php +++ b/routes/api/v1/sales.php @@ -59,6 +59,9 @@ Route::post('/calculate/bom', [QuoteController::class, 'calculateBom'])->name('v1.quotes.calculate-bom'); Route::post('/calculate/bom/bulk', [QuoteController::class, 'calculateBomBulk'])->name('v1.quotes.calculate-bom-bulk'); + // 품목 단가 조회 (수동 품목 추가 시 사용) + Route::post('/items/prices', [QuoteController::class, 'getItemPrices'])->name('v1.quotes.item-prices'); + // 단건 조회/수정/삭제 (id 경로는 구체적인 경로 뒤에 배치) Route::get('/{id}', [QuoteController::class, 'show'])->whereNumber('id')->name('v1.quotes.show'); Route::put('/{id}', [QuoteController::class, 'update'])->whereNumber('id')->name('v1.quotes.update'); @@ -67,6 +70,10 @@ Route::put('/{id}/stage', [QuoteController::class, 'updateStage'])->whereNumber('id')->name('v1.quotes.stage'); Route::put('/{id}/items', [QuoteController::class, 'updateItems'])->whereNumber('id')->name('v1.quotes.items'); + // 견적 확정/확정 취소 + Route::post('/{id}/finalize', [QuoteController::class, 'finalize'])->whereNumber('id')->name('v1.quotes.finalize'); + Route::post('/{id}/cancel-finalize', [QuoteController::class, 'cancelFinalize'])->whereNumber('id')->name('v1.quotes.cancel-finalize'); + // 히스토리 Route::get('/{id}/histories', [QuoteController::class, 'histories'])->whereNumber('id')->name('v1.quotes.histories'); Route::post('/{id}/histories', [QuoteController::class, 'addHistory'])->whereNumber('id')->name('v1.quotes.histories.store'); From 87e20e965a1af9d9a4df1f8a78b2091b1358f776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 08:17:34 +0900 Subject: [PATCH 31/57] =?UTF-8?q?feat:=20BOM=20=EA=B3=84=EC=82=B0=20debug?= =?UTF-8?q?=5Fsteps=EC=97=90=20=EC=88=98=EC=8B=9D=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - calculateKyungdongBom 메서드에 formulas 배열 추가 - Step 1: 입력값 (변수, 설명, 값, 단위) - Step 3: 변수계산 (수식, 대입, 결과) - Step 6-7: 품목별 수량/금액 계산 과정 - Step 9: 카테고리별 소계 계산 - Step 10: 최종합계 수식 - 프론트엔드에서 실제 계산 수식 확인 가능 Co-Authored-By: Claude Opus 4.5 --- .../Quote/FormulaEvaluatorService.php | 160 +++++++++++++----- 1 file changed, 121 insertions(+), 39 deletions(-) diff --git a/app/Services/Quote/FormulaEvaluatorService.php b/app/Services/Quote/FormulaEvaluatorService.php index 03eca59..7217ba0 100644 --- a/app/Services/Quote/FormulaEvaluatorService.php +++ b/app/Services/Quote/FormulaEvaluatorService.php @@ -1059,10 +1059,13 @@ private function addProcessGroupToItems(array $items, array $groupedItems): arra /** * 품목 단가 조회 + * + * @param string $itemCode 품목 코드 + * @param int|null $tenantIdOverride 테넌트 ID (외부 호출 시 사용) */ - private function getItemPrice(string $itemCode): float + public function getItemPrice(string $itemCode, ?int $tenantIdOverride = null): float { - $tenantId = $this->tenantId(); + $tenantId = $tenantIdOverride ?? $this->tenantId(); if (! $tenantId) { $this->errors[] = __('error.tenant_id_required'); @@ -1580,14 +1583,20 @@ private function calculateKyungdongBom( ]); // Step 1: 입력값 수집 + $W0 = (float) ($inputVariables['W0'] ?? 0); + $H0 = (float) ($inputVariables['H0'] ?? 0); + $QTY = (int) ($inputVariables['QTY'] ?? 1); + $bracketInch = $inputVariables['bracket_inch'] ?? '5'; + $productType = $inputVariables['product_type'] ?? 'screen'; + $this->addDebugStep(1, '입력값수집', [ - 'W0' => $inputVariables['W0'] ?? null, - 'H0' => $inputVariables['H0'] ?? null, - 'QTY' => $inputVariables['QTY'] ?? 1, - 'bracket_inch' => $inputVariables['bracket_inch'] ?? '5', - 'product_type' => $inputVariables['product_type'] ?? 'screen', - 'finishing_type' => $inputVariables['finishing_type'] ?? 'SUS', - 'finished_goods' => $finishedGoodsCode, + 'formulas' => [ + ['var' => 'W0', 'desc' => '개구부 폭', 'value' => $W0, 'unit' => 'mm'], + ['var' => 'H0', 'desc' => '개구부 높이', 'value' => $H0, 'unit' => 'mm'], + ['var' => 'QTY', 'desc' => '수량', 'value' => $QTY, 'unit' => 'EA'], + ['var' => 'bracket_inch', 'desc' => '브라켓 인치', 'value' => $bracketInch, 'unit' => '인치'], + ['var' => 'product_type', 'desc' => '제품 타입', 'value' => $productType, 'unit' => ''], + ], ]); // Step 2: 완제품 조회 @@ -1616,15 +1625,20 @@ private function calculateKyungdongBom( $handler = new KyungdongFormulaHandler; // Step 3: 경동 전용 변수 계산 - $W0 = (float) ($inputVariables['W0'] ?? 0); - $H0 = (float) ($inputVariables['H0'] ?? 0); - $QTY = (int) ($inputVariables['QTY'] ?? 1); - $bracketInch = $inputVariables['bracket_inch'] ?? '5'; - $productType = $inputVariables['product_type'] ?? 'screen'; - - // 중량 계산 (5130 로직) + $W1 = $W0 + 140; + $H1 = $H0 + 350; $area = ($W0 * ($H0 + 550)) / 1000000; - $weight = $area * ($productType === 'steel' ? 25 : 2) + ($W0 / 1000) * 14.17; + + // 중량 계산 (제품타입별) + if ($productType === 'steel') { + $weight = $area * 25; + $weightFormula = "AREA × 25"; + $weightCalc = "{$area} × 25"; + } else { + $weight = $area * 2 + ($W0 / 1000) * 14.17; + $weightFormula = "AREA × 2 + (W0 / 1000) × 14.17"; + $weightCalc = "{$area} × 2 + ({$W0} / 1000) × 14.17"; + } // 모터 용량 결정 $motorCapacity = $handler->calculateMotorCapacity($productType, $weight, $bracketInch); @@ -1636,8 +1650,8 @@ private function calculateKyungdongBom( 'W0' => $W0, 'H0' => $H0, 'QTY' => $QTY, - 'W1' => $W0 + 140, - 'H1' => $H0 + 350, + 'W1' => $W1, + 'H1' => $H1, 'AREA' => round($area, 4), 'WEIGHT' => round($weight, 2), 'MOTOR_CAPACITY' => $motorCapacity, @@ -1647,13 +1661,56 @@ private function calculateKyungdongBom( ]); $this->addDebugStep(3, '변수계산', [ - 'W0' => $W0, - 'H0' => $H0, - 'area' => round($area, 4), - 'weight' => round($weight, 2), - 'motor_capacity' => $motorCapacity, - 'bracket_size' => $bracketSize, - 'calculation_type' => '경동기업 전용 공식', + 'formulas' => [ + [ + 'var' => 'W1', + 'desc' => '제작 폭', + 'formula' => 'W0 + 140', + 'calculation' => "{$W0} + 140", + 'result' => $W1, + 'unit' => 'mm', + ], + [ + 'var' => 'H1', + 'desc' => '제작 높이', + 'formula' => 'H0 + 350', + 'calculation' => "{$H0} + 350", + 'result' => $H1, + 'unit' => 'mm', + ], + [ + 'var' => 'AREA', + 'desc' => '면적', + 'formula' => '(W0 × (H0 + 550)) / 1,000,000', + 'calculation' => "({$W0} × ({$H0} + 550)) / 1,000,000", + 'result' => round($area, 4), + 'unit' => '㎡', + ], + [ + 'var' => 'WEIGHT', + 'desc' => '중량', + 'formula' => $weightFormula, + 'calculation' => $weightCalc, + 'result' => round($weight, 2), + 'unit' => 'kg', + ], + [ + 'var' => 'MOTOR_CAPACITY', + 'desc' => '모터 용량', + 'formula' => '중량/브라켓 기준표 조회', + 'calculation' => "WEIGHT({$weight}) + INCH({$bracketInch}) → 조회", + 'result' => $motorCapacity, + 'unit' => '', + ], + [ + 'var' => 'BRACKET_SIZE', + 'desc' => '브라켓 크기', + 'formula' => '중량 기준표 조회', + 'calculation' => "WEIGHT({$weight}) → 조회", + 'result' => $bracketSize, + 'unit' => '인치', + ], + ], ]); // Step 4-7: 동적 항목 계산 (KyungdongFormulaHandler 사용) @@ -1662,22 +1719,26 @@ private function calculateKyungdongBom( $this->addDebugStep(4, 'BOM전개', [ 'total_items' => count($dynamicItems), 'item_categories' => array_unique(array_column($dynamicItems, 'category')), + 'items' => array_map(fn ($item) => [ + 'name' => $item['item_name'], + 'category' => $item['category'], + ], $dynamicItems), ]); // Step 5-7: 단가 계산 (각 항목별) $calculatedItems = []; - foreach ($dynamicItems as $item) { - $this->addDebugStep(6, '수량계산', [ - 'item_name' => $item['item_name'], - 'quantity' => $item['quantity'], - ]); + $itemFormulas = []; - $this->addDebugStep(7, '금액계산', [ - 'item_name' => $item['item_name'], - 'quantity' => $item['quantity'], + foreach ($dynamicItems as $item) { + $itemFormulas[] = [ + 'item' => $item['item_name'], + 'qty_formula' => $item['quantity_formula'] ?? '고정값', + 'qty_result' => $item['quantity'], 'unit_price' => $item['unit_price'], - 'total_price' => $item['total_price'], - ]); + 'price_formula' => '수량 × 단가', + 'price_calc' => "{$item['quantity']} × {$item['unit_price']}", + 'total' => $item['total_price'], + ]; $calculatedItems[] = [ 'item_code' => $item['item_code'] ?? '', @@ -1686,13 +1747,23 @@ private function calculateKyungdongBom( 'specification' => $item['specification'] ?? '', 'unit' => $item['unit'], 'quantity' => $item['quantity'], + 'quantity_formula' => $item['quantity_formula'] ?? '', 'unit_price' => $item['unit_price'], 'total_price' => $item['total_price'], 'category_group' => $item['category'], + 'process_group' => $item['category'], 'calculation_note' => '경동기업 전용 계산', ]; } + $this->addDebugStep(6, '수량계산', [ + 'formulas' => $itemFormulas, + ]); + + $this->addDebugStep(7, '금액계산', [ + 'formulas' => $itemFormulas, + ]); + // Step 8: 카테고리별 그룹화 $groupedItems = []; foreach ($calculatedItems as $item) { @@ -1718,22 +1789,33 @@ private function calculateKyungdongBom( // Step 9: 소계 계산 $subtotals = []; + $subtotalFormulas = []; foreach ($groupedItems as $category => $group) { $subtotals[$category] = [ 'name' => $group['name'], 'count' => count($group['items']), 'subtotal' => $group['subtotal'], ]; + $subtotalFormulas[] = [ + 'category' => $group['name'], + 'formula' => implode(' + ', array_map(fn ($i) => $i['item_name'], $group['items'])), + 'result' => $group['subtotal'], + ]; } - $this->addDebugStep(9, '소계계산', $subtotals); + $this->addDebugStep(9, '소계계산', [ + 'formulas' => $subtotalFormulas, + 'subtotals' => $subtotals, + ]); // Step 10: 최종 합계 $grandTotal = array_sum(array_column($calculatedItems, 'total_price')); + $subtotalValues = array_column($subtotals, 'subtotal'); $this->addDebugStep(10, '최종합계', [ - 'item_count' => count($calculatedItems), - 'grand_total' => $grandTotal, + 'formula' => implode(' + ', array_column($subtotals, 'name')), + 'calculation' => implode(' + ', array_map(fn ($v) => number_format($v), $subtotalValues)), + 'result' => $grandTotal, 'formatted' => number_format($grandTotal).'원', ]); From 946e008b0282bf73ae00cbe56721c6879df26ea8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 09:10:13 +0900 Subject: [PATCH 32/57] =?UTF-8?q?fix:=20API=20=EB=9D=BC=EC=9A=B0=ED=8A=B8?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC=20=EC=8B=9C=20=EB=88=84=EB=9D=BD=EB=90=9C?= =?UTF-8?q?=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 분리 전 원본 라우트와 비교하여 누락된 17개 라우트 복구: [design.php] - BomCalculationController: calculateBom, getCompanyFormulas, getEstimateParameters, saveCompanyFormula, testFormula - DesignBomTemplateController: cloneTemplate, diff, listByVersion, replaceItems, upsertTemplate - DesignModelVersionController: createDraft (store → createDraft 메서드명 수정) - ModelSetController: calculateBom, getBomTemplates, getCategoryFields, getEstimateParameters [inventory.php] - ItemsFileController: upload, delete (store/destroy → upload/delete 메서드명 수정) - ItemsBomController: listAll, listCategories, tree, replace - ItemsController: showByCode, batchDestroy - LaborController: stats, bulkDestroy [sales.php] - ClientController: bulkDestroy - ClientGroupController: toggle - BiddingController: updateStatus - PricingController: stats, cost, byItems, bulkDestroy, finalize, revisions - QuoteController: convertToBidding, convertToOrder, sendHistory Co-Authored-By: Claude Opus 4.5 --- routes/api/v1/design.php | 26 +++++++++++++++++++++++++- routes/api/v1/inventory.php | 12 ++++++++++-- routes/api/v1/sales.php | 14 ++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/routes/api/v1/design.php b/routes/api/v1/design.php index 8fbb9fe..441c3b3 100644 --- a/routes/api/v1/design.php +++ b/routes/api/v1/design.php @@ -34,7 +34,7 @@ // Model Version API (모델 버전) Route::get('/{modelId}/versions', [DesignModelVersionController::class, 'index'])->whereNumber('modelId')->name('v1.design.models.versions.index'); - Route::post('/{modelId}/versions', [DesignModelVersionController::class, 'store'])->whereNumber('modelId')->name('v1.design.models.versions.store'); + Route::post('/{modelId}/versions', [DesignModelVersionController::class, 'createDraft'])->whereNumber('modelId')->name('v1.design.models.versions.store'); Route::get('/{modelId}/versions/{id}', [DesignModelVersionController::class, 'show'])->whereNumber(['modelId', 'id'])->name('v1.design.models.versions.show'); Route::put('/{modelId}/versions/{id}', [DesignModelVersionController::class, 'update'])->whereNumber(['modelId', 'id'])->name('v1.design.models.versions.update'); Route::delete('/{modelId}/versions/{id}', [DesignModelVersionController::class, 'destroy'])->whereNumber(['modelId', 'id'])->name('v1.design.models.versions.destroy'); @@ -51,9 +51,19 @@ Route::put('/{id}', [DesignBomTemplateController::class, 'update'])->whereNumber('id')->name('v1.design.bom-templates.update'); Route::delete('/{id}', [DesignBomTemplateController::class, 'destroy'])->whereNumber('id')->name('v1.design.bom-templates.destroy'); Route::put('/{id}/items/bulk-upsert', [DesignBomTemplateController::class, 'bulkUpsertItems'])->whereNumber('id')->name('v1.design.bom-templates.items.bulk-upsert'); + Route::put('/{id}/items', [DesignBomTemplateController::class, 'replaceItems'])->whereNumber('id')->name('v1.design.bom-templates.items.replace'); Route::post('/{id}/items/reorder', [DesignBomTemplateController::class, 'reorderItems'])->whereNumber('id')->name('v1.design.bom-templates.items.reorder'); Route::get('/{id}/summary', [DesignBomTemplateController::class, 'summary'])->whereNumber('id')->name('v1.design.bom-templates.summary'); Route::get('/{id}/validate', [DesignBomTemplateController::class, 'validate'])->whereNumber('id')->name('v1.design.bom-templates.validate'); + Route::get('/{id}/diff', [DesignBomTemplateController::class, 'diff'])->whereNumber('id')->name('v1.design.bom-templates.diff'); + Route::post('/{id}/clone', [DesignBomTemplateController::class, 'cloneTemplate'])->whereNumber('id')->name('v1.design.bom-templates.clone'); + Route::post('/{id}/calculate-bom', [BomCalculationController::class, 'calculateBom'])->whereNumber('id')->name('v1.design.bom-templates.calculate-bom'); + }); + + // Version BOM Templates API (버전별 BOM 템플릿) + Route::prefix('versions')->group(function () { + Route::get('/{versionId}/bom-templates', [DesignBomTemplateController::class, 'listByVersion'])->whereNumber('versionId')->name('v1.design.versions.bom-templates.index'); + Route::post('/{versionId}/bom-templates', [DesignBomTemplateController::class, 'upsertTemplate'])->whereNumber('versionId')->name('v1.design.versions.bom-templates.store'); }); // BOM Calculation API (BOM 계산) @@ -61,6 +71,16 @@ Route::post('/calculate', [BomCalculationController::class, 'calculate'])->name('v1.design.bom-calculation.calculate'); Route::post('/preview', [BomCalculationController::class, 'preview'])->name('v1.design.bom-calculation.preview'); Route::get('/form-schema/{versionId}', [BomCalculationController::class, 'getFormSchema'])->whereNumber('versionId')->name('v1.design.bom-calculation.form-schema'); + Route::post('/formulas/test', [BomCalculationController::class, 'testFormula'])->name('v1.design.formulas.test'); + }); + + // Model Estimate Parameters API (모델 견적 파라미터) + Route::get('/models/{modelId}/estimate-parameters', [BomCalculationController::class, 'getEstimateParameters'])->whereNumber('modelId')->name('v1.design.models.estimate-parameters'); + + // Company Formulas API (회사별 수식 관리) + Route::prefix('companies/{companyName}/formulas')->group(function () { + Route::get('', [BomCalculationController::class, 'getCompanyFormulas'])->name('v1.design.companies.formulas'); + Route::post('/{formulaType}', [BomCalculationController::class, 'saveCompanyFormula'])->name('v1.design.companies.formulas.save'); }); // Audit Log API (감사 로그) @@ -81,4 +101,8 @@ Route::patch('/{id}/toggle', [ModelSetController::class, 'toggle'])->whereNumber('id')->name('v1.model-sets.toggle'); Route::post('/{id}/clone', [ModelSetController::class, 'clone'])->whereNumber('id')->name('v1.model-sets.clone'); Route::put('/{id}/items', [ModelSetController::class, 'updateItems'])->whereNumber('id')->name('v1.model-sets.items'); + Route::get('/{id}/fields', [ModelSetController::class, 'getCategoryFields'])->whereNumber('id')->name('v1.model-sets.fields'); + Route::get('/{id}/bom-templates', [ModelSetController::class, 'getBomTemplates'])->whereNumber('id')->name('v1.model-sets.bom-templates'); + Route::get('/{id}/estimate-parameters', [ModelSetController::class, 'getEstimateParameters'])->whereNumber('id')->name('v1.model-sets.estimate-parameters'); + Route::post('/{id}/calculate-bom', [ModelSetController::class, 'calculateBom'])->whereNumber('id')->name('v1.model-sets.calculate-bom'); }); diff --git a/routes/api/v1/inventory.php b/routes/api/v1/inventory.php index 4dbde58..2246754 100644 --- a/routes/api/v1/inventory.php +++ b/routes/api/v1/inventory.php @@ -28,7 +28,9 @@ Route::get('/options', [ItemsController::class, 'options'])->name('v1.items.options'); Route::get('/stats', [ItemsController::class, 'stats'])->name('v1.items.stats'); Route::get('/stats-by-type', [ItemsController::class, 'statsByItemType'])->name('v1.items.stats-by-type'); + Route::get('/code/{code}', [ItemsController::class, 'showByCode'])->name('v1.items.show-by-code'); Route::delete('/bulk', [ItemsController::class, 'bulkDestroy'])->name('v1.items.bulk-destroy'); + Route::delete('/batch', [ItemsController::class, 'batchDestroy'])->name('v1.items.batch-destroy'); Route::get('/{id}', [ItemsController::class, 'show'])->whereNumber('id')->name('v1.items.show'); Route::put('/{id}', [ItemsController::class, 'update'])->whereNumber('id')->name('v1.items.update'); Route::delete('/{id}', [ItemsController::class, 'destroy'])->whereNumber('id')->name('v1.items.destroy'); @@ -43,6 +45,10 @@ Route::post('/reorder', [ItemsBomController::class, 'reorder'])->whereNumber('id')->name('v1.items.bom.reorder'); // BOM 순서 변경 Route::get('/summary', [ItemsBomController::class, 'summary'])->whereNumber('id')->name('v1.items.bom.summary'); // BOM 요약 Route::get('/validate', [ItemsBomController::class, 'validate'])->whereNumber('id')->name('v1.items.bom.validate'); // BOM 검증 + Route::get('/list-all', [ItemsBomController::class, 'listAll'])->whereNumber('id')->name('v1.items.bom.list-all'); // BOM 전체 목록 + Route::get('/by-category', [ItemsBomController::class, 'listCategories'])->whereNumber('id')->name('v1.items.bom.by-category'); // 카테고리별 BOM + Route::get('/tree', [ItemsBomController::class, 'tree'])->whereNumber('id')->name('v1.items.bom.tree'); // BOM 트리 구조 + Route::post('/replace', [ItemsBomController::class, 'replace'])->whereNumber('id')->name('v1.items.bom.replace'); // BOM 항목 대체 Route::get('/{bomId}', [ItemsBomController::class, 'show'])->whereNumber(['id', 'bomId'])->name('v1.items.bom.show'); // BOM 항목 상세 Route::put('/{bomId}', [ItemsBomController::class, 'update'])->whereNumber(['id', 'bomId'])->name('v1.items.bom.update'); // BOM 항목 수정 Route::delete('/{bomId}', [ItemsBomController::class, 'destroy'])->whereNumber(['id', 'bomId'])->name('v1.items.bom.destroy'); // BOM 항목 삭제 @@ -51,9 +57,9 @@ // Items File API (품목 파일) Route::prefix('items/{id}/files')->group(function () { Route::get('', [ItemsFileController::class, 'index'])->whereNumber('id')->name('v1.items.files.index'); // 파일 목록 - Route::post('', [ItemsFileController::class, 'store'])->whereNumber('id')->name('v1.items.files.store'); // 파일 추가 + Route::post('', [ItemsFileController::class, 'upload'])->whereNumber('id')->name('v1.items.files.upload'); // 파일 업로드 Route::get('/{fileId}', [ItemsFileController::class, 'show'])->whereNumber(['id', 'fileId'])->name('v1.items.files.show'); // 파일 상세 - Route::delete('/{fileId}', [ItemsFileController::class, 'destroy'])->whereNumber(['id', 'fileId'])->name('v1.items.files.destroy'); // 파일 삭제 + Route::delete('/{fileId}', [ItemsFileController::class, 'delete'])->whereNumber(['id', 'fileId'])->name('v1.items.files.delete'); // 파일 삭제 }); // Labor API (노무비 관리) @@ -62,7 +68,9 @@ Route::post('', [LaborController::class, 'store'])->name('v1.labor.store'); Route::get('/active', [LaborController::class, 'active'])->name('v1.labor.active'); Route::get('/summary', [LaborController::class, 'summary'])->name('v1.labor.summary'); + Route::get('/stats', [LaborController::class, 'stats'])->name('v1.labor.stats'); Route::post('/bulk-upsert', [LaborController::class, 'bulkUpsert'])->name('v1.labor.bulk-upsert'); + Route::delete('/bulk', [LaborController::class, 'bulkDestroy'])->name('v1.labor.bulk-destroy'); Route::get('/{id}', [LaborController::class, 'show'])->whereNumber('id')->name('v1.labor.show'); Route::put('/{id}', [LaborController::class, 'update'])->whereNumber('id')->name('v1.labor.update'); Route::delete('/{id}', [LaborController::class, 'destroy'])->whereNumber('id')->name('v1.labor.destroy'); diff --git a/routes/api/v1/sales.php b/routes/api/v1/sales.php index 493517f..385ab5d 100644 --- a/routes/api/v1/sales.php +++ b/routes/api/v1/sales.php @@ -27,6 +27,7 @@ Route::post('', [ClientController::class, 'store'])->name('v1.clients.store'); Route::get('/active', [ClientController::class, 'active'])->name('v1.clients.active'); Route::get('/stats', [ClientController::class, 'stats'])->name('v1.clients.stats'); + Route::delete('/bulk', [ClientController::class, 'bulkDestroy'])->name('v1.clients.bulk-destroy'); Route::get('/{id}', [ClientController::class, 'show'])->whereNumber('id')->name('v1.clients.show'); Route::put('/{id}', [ClientController::class, 'update'])->whereNumber('id')->name('v1.clients.update'); Route::delete('/{id}', [ClientController::class, 'destroy'])->whereNumber('id')->name('v1.clients.destroy'); @@ -40,6 +41,7 @@ Route::get('/{id}', [ClientGroupController::class, 'show'])->whereNumber('id')->name('v1.client-groups.show'); Route::put('/{id}', [ClientGroupController::class, 'update'])->whereNumber('id')->name('v1.client-groups.update'); Route::delete('/{id}', [ClientGroupController::class, 'destroy'])->whereNumber('id')->name('v1.client-groups.destroy'); + Route::patch('/{id}/toggle', [ClientGroupController::class, 'toggle'])->whereNumber('id')->name('v1.client-groups.toggle'); }); // Quote API (견적 관리) @@ -74,11 +76,16 @@ Route::post('/{id}/finalize', [QuoteController::class, 'finalize'])->whereNumber('id')->name('v1.quotes.finalize'); Route::post('/{id}/cancel-finalize', [QuoteController::class, 'cancelFinalize'])->whereNumber('id')->name('v1.quotes.cancel-finalize'); + // 견적 변환 + Route::post('/{id}/convert-to-bidding', [QuoteController::class, 'convertToBidding'])->whereNumber('id')->name('v1.quotes.convert-to-bidding'); + Route::post('/{id}/convert-to-order', [QuoteController::class, 'convertToOrder'])->whereNumber('id')->name('v1.quotes.convert-to-order'); + // 히스토리 Route::get('/{id}/histories', [QuoteController::class, 'histories'])->whereNumber('id')->name('v1.quotes.histories'); Route::post('/{id}/histories', [QuoteController::class, 'addHistory'])->whereNumber('id')->name('v1.quotes.histories.store'); Route::put('/{id}/histories/{historyId}', [QuoteController::class, 'updateHistory'])->whereNumber('id')->whereNumber('historyId')->name('v1.quotes.histories.update'); Route::delete('/{id}/histories/{historyId}', [QuoteController::class, 'deleteHistory'])->whereNumber('id')->whereNumber('historyId')->name('v1.quotes.histories.destroy'); + Route::post('/{id}/send-history', [QuoteController::class, 'sendHistory'])->whereNumber('id')->name('v1.quotes.send-history'); // 견적서 문서 관리 Route::get('/{id}/document', [QuoteController::class, 'getDocument'])->whereNumber('id')->name('v1.quotes.document.show'); @@ -98,6 +105,7 @@ Route::get('/{id}', [BiddingController::class, 'show'])->whereNumber('id')->name('v1.biddings.show'); Route::put('/{id}', [BiddingController::class, 'update'])->whereNumber('id')->name('v1.biddings.update'); Route::delete('/{id}', [BiddingController::class, 'destroy'])->whereNumber('id')->name('v1.biddings.destroy'); + Route::patch('/{id}/status', [BiddingController::class, 'updateStatus'])->whereNumber('id')->name('v1.biddings.status'); }); // Pricing API (단가 관리) @@ -106,11 +114,17 @@ Route::post('', [PricingController::class, 'store'])->name('v1.pricing.store'); Route::get('/types', [PricingController::class, 'types'])->name('v1.pricing.types'); Route::get('/summary', [PricingController::class, 'summary'])->name('v1.pricing.summary'); + Route::get('/stats', [PricingController::class, 'stats'])->name('v1.pricing.stats'); + Route::get('/cost', [PricingController::class, 'cost'])->name('v1.pricing.cost'); + Route::post('/by-items', [PricingController::class, 'byItems'])->name('v1.pricing.by-items'); Route::get('/history/{itemId}', [PricingController::class, 'history'])->whereNumber('itemId')->name('v1.pricing.history'); Route::post('/bulk-upsert', [PricingController::class, 'bulkUpsert'])->name('v1.pricing.bulk-upsert'); + Route::delete('/bulk', [PricingController::class, 'bulkDestroy'])->name('v1.pricing.bulk-destroy'); Route::get('/{id}', [PricingController::class, 'show'])->whereNumber('id')->name('v1.pricing.show'); Route::put('/{id}', [PricingController::class, 'update'])->whereNumber('id')->name('v1.pricing.update'); Route::delete('/{id}', [PricingController::class, 'destroy'])->whereNumber('id')->name('v1.pricing.destroy'); + Route::post('/{id}/finalize', [PricingController::class, 'finalize'])->whereNumber('id')->name('v1.pricing.finalize'); + Route::get('/{id}/revisions', [PricingController::class, 'revisions'])->whereNumber('id')->name('v1.pricing.revisions'); }); // Estimate API (견적/설계) From aa7678c3580e59d38c28265c9d44eb82b3c15bad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 09:29:19 +0900 Subject: [PATCH 33/57] =?UTF-8?q?feat:=20=EC=88=98=EB=8F=99=20=ED=92=88?= =?UTF-8?q?=EB=AA=A9=20=EB=8B=A8=EA=B0=80=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=94=94=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QuoteController에 getItemPrices 엔드포인트 추가 - QuoteCalculationService에 품목 코드 배열로 단가 조회 기능 추가 - 불필요한 디버그 로그 제거 Co-Authored-By: Claude Opus 4.5 --- .../Controllers/Api/V1/QuoteController.php | 33 +++++++++++-------- .../Quote/QuoteCalculationService.php | 30 +++++++++++++++++ app/Services/Quote/QuoteService.php | 1 + 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/app/Http/Controllers/Api/V1/QuoteController.php b/app/Http/Controllers/Api/V1/QuoteController.php index 1527f76..2221218 100644 --- a/app/Http/Controllers/Api/V1/QuoteController.php +++ b/app/Http/Controllers/Api/V1/QuoteController.php @@ -81,19 +81,8 @@ public function store(QuoteStoreRequest $request) */ public function update(QuoteUpdateRequest $request, int $id) { - $validated = $request->validated(); - - // 🔍 디버깅: 요청 데이터 확인 - \Log::info('🔍 [QuoteController::update] 요청 수신', [ - 'id' => $id, - 'raw_options_keys' => $request->input('options') ? array_keys($request->input('options')) : null, - 'raw_options_detail_items_count' => $request->input('options.detail_items') ? count($request->input('options.detail_items')) : 0, - 'validated_options_keys' => isset($validated['options']) ? array_keys($validated['options']) : null, - 'validated_options_detail_items_count' => isset($validated['options']['detail_items']) ? count($validated['options']['detail_items']) : 0, - ]); - - return ApiResponse::handle(function () use ($validated, $id) { - return $this->quoteService->update($id, $validated); + return ApiResponse::handle(function () use ($request, $id) { + return $this->quoteService->update($id, $request->validated()); }, __('message.quote.updated')); } @@ -270,4 +259,22 @@ public function sendHistory(int $id) return $this->documentService->getSendHistory($id); }, __('message.fetched')); } + + /** + * 품목 단가 조회 + * + * 품목 코드 배열을 받아 단가를 조회합니다. + * 수동 품목 추가 시 단가를 조회하여 견적금액에 반영합니다. + */ + public function getItemPrices(\Illuminate\Http\Request $request) + { + $request->validate([ + 'item_codes' => 'required|array|min:1', + 'item_codes.*' => 'required|string', + ]); + + return ApiResponse::handle(function () use ($request) { + return $this->calculationService->getItemPrices($request->input('item_codes')); + }, __('message.fetched')); + } } diff --git a/app/Services/Quote/QuoteCalculationService.php b/app/Services/Quote/QuoteCalculationService.php index e28a98e..48a27fb 100644 --- a/app/Services/Quote/QuoteCalculationService.php +++ b/app/Services/Quote/QuoteCalculationService.php @@ -455,4 +455,34 @@ private function getDefaultInputSchema(?string $productCategory = null): array return $commonSchema; } + + /** + * 품목 단가 조회 + * + * 품목 코드 배열을 받아 단가를 조회합니다. + * FormulaEvaluatorService의 getItemPrice 로직을 활용합니다. + * + * @param array $itemCodes 품목 코드 배열 + * @return array 품목별 단가 정보 [item_code => ['unit_price' => number, 'item_name' => string]] + */ + public function getItemPrices(array $itemCodes): array + { + $tenantId = $this->tenantId(); + + if (! $tenantId) { + return []; + } + + $result = []; + + foreach ($itemCodes as $itemCode) { + $price = $this->formulaEvaluator->getItemPrice($itemCode, $tenantId); + $result[$itemCode] = [ + 'item_code' => $itemCode, + 'unit_price' => $price, + ]; + } + + return $result; + } } diff --git a/app/Services/Quote/QuoteService.php b/app/Services/Quote/QuoteService.php index bfb7a88..7e5a74a 100644 --- a/app/Services/Quote/QuoteService.php +++ b/app/Services/Quote/QuoteService.php @@ -13,6 +13,7 @@ use App\Services\Service; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; From 7acaac8340b919bfad19f4c9ee2e53a2bc66ae19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 11:03:19 +0900 Subject: [PATCH 34/57] =?UTF-8?q?feat:=20=EC=B6=9C=EA=B8=88=20=EC=98=A4?= =?UTF-8?q?=EB=8A=98=EC=9D=98=20=EC=9D=B4=EC=8A=88=EB=A5=BC=20=EC=9D=BC?= =?UTF-8?q?=EC=9D=BC=20=EB=88=84=EA=B3=84=20=EB=B0=A9=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 출금 건별 개별 이슈 → 해당일 누계 1건으로 upsert - 첫 건: "거래처명 출금 금액원" - 2건+: "첫거래처명 외 N건 출금 합계 금액원" - 삭제 시 남은 건수/금액으로 자동 갱신 - FCM 푸시 미발송 (알림 설정 미구현) - 기존 개별 source_id 레거시 이슈 자동 정리 Co-Authored-By: Claude Opus 4.5 --- app/Services/TodayIssueObserverService.php | 107 ++++++++++++++++----- lang/ko/message.php | 1 + 2 files changed, 83 insertions(+), 25 deletions(-) diff --git a/app/Services/TodayIssueObserverService.php b/app/Services/TodayIssueObserverService.php index b3c6aa5..7ac892e 100644 --- a/app/Services/TodayIssueObserverService.php +++ b/app/Services/TodayIssueObserverService.php @@ -369,7 +369,12 @@ public function handleDepositDeleted(Deposit $deposit): void } /** - * 출금 등록 이슈 생성 + * 출금 등록 이슈 생성 (일일 누계 방식) + * + * - 첫 번째 출금: "거래처명 출금 금액원" + * - 두 번째 이후: "첫번째거래처명 외 N건 출금 합계 금액원" + * - source_id=null로 일일 1건만 유지 (upsert) + * - FCM 푸시 미발송 (알림 설정 미구현) */ public function handleWithdrawalCreated(Withdrawal $withdrawal): void { @@ -379,36 +384,88 @@ public function handleWithdrawalCreated(Withdrawal $withdrawal): void 'amount' => $withdrawal->amount, ]); - $clientName = $withdrawal->display_client_name ?: $withdrawal->merchant_name ?: __('message.today_issue.unknown_client'); - $amount = number_format($withdrawal->amount ?? 0); - - Log::info('TodayIssue: Creating withdrawal issue', [ - 'withdrawal_id' => $withdrawal->id, - 'client' => $clientName, - 'amount' => $amount, - ]); - - $this->createIssueWithFcm( - tenantId: $withdrawal->tenant_id, - sourceType: TodayIssue::SOURCE_WITHDRAWAL, - sourceId: $withdrawal->id, - badge: TodayIssue::BADGE_WITHDRAWAL, - content: __('message.today_issue.withdrawal_registered', [ - 'client' => $clientName, - 'amount' => $amount, - ]), - path: "/accounting/withdrawals/{$withdrawal->id}", - needsApproval: false, - expiresAt: Carbon::now()->addDays(7) - ); + $this->upsertWithdrawalDailyIssue($withdrawal->tenant_id); } /** - * 출금 삭제 시 이슈 삭제 + * 출금 삭제 시 일일 누계 이슈 갱신 */ public function handleWithdrawalDeleted(Withdrawal $withdrawal): void { - TodayIssue::removeBySource($withdrawal->tenant_id, TodayIssue::SOURCE_WITHDRAWAL, $withdrawal->id); + $this->upsertWithdrawalDailyIssue($withdrawal->tenant_id); + } + + /** + * 출금 일일 누계 이슈 upsert + * + * 오늘 날짜의 출금 건수/합계를 집계하여 이슈 1건으로 관리 + */ + private function upsertWithdrawalDailyIssue(int $tenantId): void + { + // 기존 개별 출금 이슈 정리 (source_id가 있는 레거시 데이터) + TodayIssue::where('tenant_id', $tenantId) + ->where('source_type', TodayIssue::SOURCE_WITHDRAWAL) + ->whereNotNull('source_id') + ->delete(); + + // 오늘 날짜 출금 조회 (출금일 기준, soft delete 제외) + $todayWithdrawals = Withdrawal::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->whereDate('withdrawal_date', Carbon::today()) + ->whereNull('deleted_at') + ->orderBy('created_at', 'asc') + ->get(); + + $count = $todayWithdrawals->count(); + $totalAmount = $todayWithdrawals->sum('amount'); + + Log::info('TodayIssue: Withdrawal daily summary', [ + 'tenant_id' => $tenantId, + 'count' => $count, + 'total_amount' => $totalAmount, + ]); + + // 출금이 없으면 이슈 삭제 + if ($count === 0) { + TodayIssue::where('tenant_id', $tenantId) + ->where('source_type', TodayIssue::SOURCE_WITHDRAWAL) + ->whereNull('source_id') + ->delete(); + + return; + } + + // 첫 번째 출금의 거래처명 + $first = $todayWithdrawals->first(); + $firstClientName = $first->display_client_name + ?: $first->merchant_name + ?: __('message.today_issue.unknown_client'); + + // 1건이면 단건 메시지, 2건 이상이면 누계 메시지 + if ($count === 1) { + $content = __('message.today_issue.withdrawal_registered', [ + 'client' => $firstClientName, + 'amount' => number_format($totalAmount), + ]); + } else { + $content = __('message.today_issue.withdrawal_daily_summary', [ + 'client' => $firstClientName, + 'count' => $count - 1, + 'amount' => number_format($totalAmount), + ]); + } + + // source_id=null 로 일일 1건 upsert (FCM 미발송) + TodayIssue::createIssue( + tenantId: $tenantId, + sourceType: TodayIssue::SOURCE_WITHDRAWAL, + sourceId: null, + badge: TodayIssue::BADGE_WITHDRAWAL, + content: $content, + path: '/accounting/withdrawals', + needsApproval: false, + expiresAt: Carbon::now()->addDays(7) + ); } /** diff --git a/lang/ko/message.php b/lang/ko/message.php index 5a31536..2e03b3d 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -515,6 +515,7 @@ 'new_client' => ':name 등록', 'deposit_registered' => ':client 입금 :amount원', 'withdrawal_registered' => ':client 출금 :amount원', + 'withdrawal_daily_summary' => ':client 외 :count건 출금 합계 :amount원', // 기안 상태 변경 알림 'draft_approved' => ':title 결재가 승인되었습니다', From a25d267550c74335ff72ed34be659f9f2f445f28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 13:48:03 +0900 Subject: [PATCH 35/57] =?UTF-8?q?fix:=20=EC=9E=85=EA=B3=A0=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20order=5Fqty=20=ED=95=84=EC=88=98=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StoreReceivingRequest에서 order_qty를 required → nullable로 변경 - 입고 등록 시 발주수량 없이도 등록 가능하도록 수정 Co-Authored-By: Claude Opus 4.5 --- app/Http/Requests/V1/Receiving/StoreReceivingRequest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php b/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php index e7a9c05..4cc2380 100644 --- a/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php +++ b/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php @@ -21,7 +21,7 @@ public function rules(): array 'item_name' => ['required', 'string', 'max:200'], 'specification' => ['nullable', 'string', 'max:200'], 'supplier' => ['required', 'string', 'max:100'], - 'order_qty' => ['required', 'numeric', 'min:0'], + 'order_qty' => ['nullable', 'numeric', 'min:0'], 'order_unit' => ['nullable', 'string', 'max:20'], 'due_date' => ['nullable', 'date'], 'status' => ['nullable', 'string', 'in:order_completed,shipping,inspection_pending,receiving_pending'], From e8fc42c14dda2ea96e60f309d5feaef1b456aec8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 14:40:32 +0900 Subject: [PATCH 36/57] =?UTF-8?q?fix:=20=EC=9E=85=EA=B3=A0=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EB=82=A0=EC=A7=9C=20=ED=95=84=ED=84=B0=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=9D=84=20created=5Fat=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - receiving_date 기준 → created_at(작성일) 기준으로 변경 - 입고처리 전(receiving_date=NULL) 데이터가 필터에서 누락되는 문제 해결 - creator 관계 eager loading 추가 Co-Authored-By: Claude Opus 4.5 --- app/Services/ReceivingService.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/Services/ReceivingService.php b/app/Services/ReceivingService.php index 89a34ae..35ffa59 100644 --- a/app/Services/ReceivingService.php +++ b/app/Services/ReceivingService.php @@ -16,6 +16,7 @@ public function index(array $params): LengthAwarePaginator $tenantId = $this->tenantId(); $query = Receiving::query() + ->with('creator:id,name') ->where('tenant_id', $tenantId); // 검색어 필터 @@ -41,12 +42,12 @@ public function index(array $params): LengthAwarePaginator } } - // 날짜 범위 필터 + // 날짜 범위 필터 (작성일 기준) if (! empty($params['start_date'])) { - $query->where('receiving_date', '>=', $params['start_date']); + $query->whereDate('created_at', '>=', $params['start_date']); } if (! empty($params['end_date'])) { - $query->where('receiving_date', '<=', $params['end_date']); + $query->whereDate('created_at', '<=', $params['end_date']); } // 정렬 From 847717e631b94746cc4a4522009732fa332a09b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 15:04:55 +0900 Subject: [PATCH 37/57] =?UTF-8?q?feat(=EA=B1=B0=EB=9E=98=EC=B2=98):=20clie?= =?UTF-8?q?nt=5Ftype=20=ED=95=84=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ClientService.index()에 client_type 파라미터 필터 추가 - 쉼표 구분 복수 값 지원 (예: PURCHASE,BOTH) - 매입 가능 거래처만 조회하는 용도 Co-Authored-By: Claude Opus 4.5 --- app/Services/ClientService.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/Services/ClientService.php b/app/Services/ClientService.php index 6f44428..b3af238 100644 --- a/app/Services/ClientService.php +++ b/app/Services/ClientService.php @@ -21,6 +21,7 @@ public function index(array $params) $size = (int) ($params['size'] ?? 20); $q = trim((string) ($params['q'] ?? '')); $onlyActive = $params['only_active'] ?? null; + $clientType = $params['client_type'] ?? null; $query = Client::query()->where('tenant_id', $tenantId); @@ -36,6 +37,12 @@ public function index(array $params) $query->where('is_active', (bool) $onlyActive); } + // 거래처 유형 필터 (쉼표 구분 복수 값 지원: PURCHASE,BOTH) + if ($clientType !== null && $clientType !== '') { + $types = array_map('trim', explode(',', $clientType)); + $query->whereIn('client_type', $types); + } + $query->orderBy('client_code')->orderBy('id'); $paginator = $query->paginate($size, ['*'], 'page', $page); From f7ad9ae36e8e0fca0d199205755a8bc33efa1a4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 15:05:03 +0900 Subject: [PATCH 38/57] =?UTF-8?q?feat(=EC=9E=AC=EA=B3=A0):=20stock=5Ftrans?= =?UTF-8?q?actions=20=EC=9E=85=EC=B6=9C=EA=B3=A0=20=EA=B1=B0=EB=9E=98=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stock_transactions 마이그레이션 생성 (type, qty, balance_qty, reference) - StockTransaction 모델 (IN/OUT/RESERVE/RELEASE 타입, 사유 상수) - StockService 5개 메서드에 거래 이력 기록 추가 - increaseFromReceiving → IN - decreaseFIFO → OUT (LOT별) - reserve → RESERVE (LOT별) - releaseReservation → RELEASE (LOT별) - decreaseForShipment → OUT (LOT별) - Stock 모델에 transactions() 관계 추가 - 기존 audit_logs 기록은 유지 (감사 로그와 거래 이력 목적 분리) Co-Authored-By: Claude Opus 4.5 --- app/Models/Tenants/Stock.php | 8 ++ app/Models/Tenants/StockTransaction.php | 114 +++++++++++++++ app/Services/StockService.php | 131 +++++++++++++++++- ...000001_create_stock_transactions_table.php | 69 +++++++++ 4 files changed, 317 insertions(+), 5 deletions(-) create mode 100644 app/Models/Tenants/StockTransaction.php create mode 100644 database/migrations/2026_01_29_000001_create_stock_transactions_table.php diff --git a/app/Models/Tenants/Stock.php b/app/Models/Tenants/Stock.php index 15ea604..0326499 100644 --- a/app/Models/Tenants/Stock.php +++ b/app/Models/Tenants/Stock.php @@ -82,6 +82,14 @@ public function lots(): HasMany return $this->hasMany(StockLot::class)->orderBy('fifo_order'); } + /** + * 거래 이력 관계 + */ + public function transactions(): HasMany + { + return $this->hasMany(StockTransaction::class)->orderByDesc('created_at'); + } + /** * 생성자 관계 */ diff --git a/app/Models/Tenants/StockTransaction.php b/app/Models/Tenants/StockTransaction.php new file mode 100644 index 0000000..2c5fad5 --- /dev/null +++ b/app/Models/Tenants/StockTransaction.php @@ -0,0 +1,114 @@ + '입고', + self::TYPE_OUT => '출고', + self::TYPE_RESERVE => '예약', + self::TYPE_RELEASE => '예약해제', + ]; + + // 사유 상수 + public const REASON_RECEIVING = 'receiving'; + + public const REASON_WORK_ORDER_INPUT = 'work_order_input'; + + public const REASON_SHIPMENT = 'shipment'; + + public const REASON_ORDER_CONFIRM = 'order_confirm'; + + public const REASON_ORDER_CANCEL = 'order_cancel'; + + public const REASONS = [ + self::REASON_RECEIVING => '입고', + self::REASON_WORK_ORDER_INPUT => '생산투입', + self::REASON_SHIPMENT => '출하', + self::REASON_ORDER_CONFIRM => '수주확정', + self::REASON_ORDER_CANCEL => '수주취소', + ]; + + protected $fillable = [ + 'tenant_id', + 'stock_id', + 'stock_lot_id', + 'type', + 'qty', + 'balance_qty', + 'reference_type', + 'reference_id', + 'lot_no', + 'reason', + 'remark', + 'item_code', + 'item_name', + 'created_by', + ]; + + protected $casts = [ + 'stock_id' => 'integer', + 'stock_lot_id' => 'integer', + 'qty' => 'decimal:3', + 'balance_qty' => 'decimal:3', + 'reference_id' => 'integer', + 'created_by' => 'integer', + 'created_at' => 'datetime', + ]; + + // ===== 관계 ===== + + public function stock(): BelongsTo + { + return $this->belongsTo(Stock::class); + } + + public function stockLot(): BelongsTo + { + return $this->belongsTo(StockLot::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(\App\Models\Members\User::class, 'created_by'); + } + + // ===== Accessors ===== + + public function getTypeLabelAttribute(): string + { + return self::TYPES[$this->type] ?? $this->type; + } + + public function getReasonLabelAttribute(): string + { + return self::REASONS[$this->reason] ?? ($this->reason ?? '-'); + } +} \ No newline at end of file diff --git a/app/Services/StockService.php b/app/Services/StockService.php index 629482e..764a8b5 100644 --- a/app/Services/StockService.php +++ b/app/Services/StockService.php @@ -6,6 +6,7 @@ use App\Models\Tenants\Receiving; use App\Models\Tenants\Stock; use App\Models\Tenants\StockLot; +use App\Models\Tenants\StockTransaction; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -277,7 +278,19 @@ public function increaseFromReceiving(Receiving $receiving): StockLot // 4. Stock 정보 갱신 (LOT 기반) $stock->refreshFromLots(); - // 5. 감사 로그 기록 + // 5. 거래 이력 기록 + $this->recordTransaction( + stock: $stock, + type: StockTransaction::TYPE_IN, + qty: $receiving->receiving_qty, + reason: StockTransaction::REASON_RECEIVING, + referenceType: 'receiving', + referenceId: $receiving->id, + lotNo: $receiving->lot_no, + stockLotId: $stockLot->id + ); + + // 6. 감사 로그 기록 $this->logStockChange( stock: $stock, action: 'stock_increase', @@ -448,7 +461,21 @@ public function decreaseFIFO(int $itemId, float $qty, string $reason, int $refer $stock->last_issue_date = now(); $stock->save(); - // 6. 감사 로그 기록 + // 6. 거래 이력 기록 (LOT별) + foreach ($deductedLots as $dl) { + $this->recordTransaction( + stock: $stock, + type: StockTransaction::TYPE_OUT, + qty: -$dl['deducted_qty'], + reason: $reason, + referenceType: $reason, + referenceId: $referenceId, + lotNo: $dl['lot_no'], + stockLotId: $dl['lot_id'] + ); + } + + // 7. 감사 로그 기록 $this->logStockChange( stock: $stock, action: 'stock_decrease', @@ -591,7 +618,21 @@ public function reserve(int $itemId, float $qty, int $orderId): void // 4. Stock 정보 갱신 $stock->refreshFromLots(); - // 5. 감사 로그 기록 + // 5. 거래 이력 기록 (LOT별) + foreach ($reservedLots as $rl) { + $this->recordTransaction( + stock: $stock, + type: StockTransaction::TYPE_RESERVE, + qty: $rl['reserved_qty'], + reason: StockTransaction::REASON_ORDER_CONFIRM, + referenceType: 'order', + referenceId: $orderId, + lotNo: $rl['lot_no'], + stockLotId: $rl['lot_id'] + ); + } + + // 6. 감사 로그 기록 $this->logStockChange( stock: $stock, action: 'stock_reserve', @@ -680,7 +721,21 @@ public function releaseReservation(int $itemId, float $qty, int $orderId): void // 3. Stock 정보 갱신 $stock->refreshFromLots(); - // 4. 감사 로그 기록 + // 4. 거래 이력 기록 (LOT별) + foreach ($releasedLots as $rl) { + $this->recordTransaction( + stock: $stock, + type: StockTransaction::TYPE_RELEASE, + qty: -$rl['released_qty'], + reason: StockTransaction::REASON_ORDER_CANCEL, + referenceType: 'order', + referenceId: $orderId, + lotNo: $rl['lot_no'], + stockLotId: $rl['lot_id'] + ); + } + + // 5. 감사 로그 기록 $this->logStockChange( stock: $stock, action: 'stock_release', @@ -839,7 +894,21 @@ public function decreaseForShipment(int $itemId, float $qty, int $shipmentId, ?i $stock->last_issue_date = now(); $stock->save(); - // 5. 감사 로그 기록 + // 5. 거래 이력 기록 (LOT별) + foreach ($deductedLots as $dl) { + $this->recordTransaction( + stock: $stock, + type: StockTransaction::TYPE_OUT, + qty: -$dl['deducted_qty'], + reason: StockTransaction::REASON_SHIPMENT, + referenceType: 'shipment', + referenceId: $shipmentId, + lotNo: $dl['lot_no'], + stockLotId: $dl['lot_id'] + ); + } + + // 6. 감사 로그 기록 $this->logStockChange( stock: $stock, action: 'stock_decrease', @@ -861,6 +930,58 @@ public function decreaseForShipment(int $itemId, float $qty, int $shipmentId, ?i }); } + /** + * 재고 거래 이력 기록 + * + * @param Stock $stock 재고 + * @param string $type 거래유형 (IN, OUT, RESERVE, RELEASE) + * @param float $qty 변동 수량 (입고: 양수, 출고: 음수) + * @param string $reason 사유 + * @param string $referenceType 참조 유형 + * @param int $referenceId 참조 ID + * @param string|null $lotNo LOT번호 + * @param int|null $stockLotId StockLot ID + * @param string|null $remark 비고 + */ + private function recordTransaction( + Stock $stock, + string $type, + float $qty, + string $reason, + string $referenceType, + int $referenceId, + ?string $lotNo = null, + ?int $stockLotId = null, + ?string $remark = null + ): void { + try { + StockTransaction::create([ + 'tenant_id' => $stock->tenant_id, + 'stock_id' => $stock->id, + 'stock_lot_id' => $stockLotId, + 'type' => $type, + 'qty' => $qty, + 'balance_qty' => (float) $stock->stock_qty, + 'reference_type' => $referenceType, + 'reference_id' => $referenceId, + 'lot_no' => $lotNo, + 'reason' => $reason, + 'remark' => $remark, + 'item_code' => $stock->item_code, + 'item_name' => $stock->item_name, + 'created_by' => $this->apiUserId(), + ]); + } catch (\Exception $e) { + // 거래 이력 기록 실패는 비즈니스 로직에 영향을 주지 않음 + Log::warning('Failed to record stock transaction', [ + 'stock_id' => $stock->id, + 'type' => $type, + 'qty' => $qty, + 'error' => $e->getMessage(), + ]); + } + } + /** * 재고 변경 감사 로그 기록 */ diff --git a/database/migrations/2026_01_29_000001_create_stock_transactions_table.php b/database/migrations/2026_01_29_000001_create_stock_transactions_table.php new file mode 100644 index 0000000..fa11f96 --- /dev/null +++ b/database/migrations/2026_01_29_000001_create_stock_transactions_table.php @@ -0,0 +1,69 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + + // 재고 참조 + $table->unsignedBigInteger('stock_id')->comment('재고 ID'); + $table->unsignedBigInteger('stock_lot_id')->nullable()->comment('LOT ID (입고/출고 시)'); + + // 거래 유형 + $table->string('type', 20)->comment('거래유형: IN(입고), OUT(출고), RESERVE(예약), RELEASE(예약해제)'); + + // 수량 + $table->decimal('qty', 15, 3)->comment('변동 수량 (양수: 증가, 음수: 감소)'); + $table->decimal('balance_qty', 15, 3)->comment('거래 후 재고 잔량'); + + // 참조 정보 (다형성) + $table->string('reference_type', 50)->nullable()->comment('참조 유형: receiving, work_order, shipment, order'); + $table->unsignedBigInteger('reference_id')->nullable()->comment('참조 ID'); + + // 상세 정보 + $table->string('lot_no', 50)->nullable()->comment('LOT번호'); + $table->string('reason', 50)->nullable()->comment('사유: receiving, work_order_input, shipment, order_confirm, order_cancel'); + $table->string('remark', 500)->nullable()->comment('비고'); + + // 품목 스냅샷 (조회 성능용) + $table->string('item_code', 50)->comment('품목코드'); + $table->string('item_name', 200)->comment('품목명'); + + // 감사 정보 + $table->unsignedBigInteger('created_by')->nullable()->comment('생성자'); + $table->timestamp('created_at')->useCurrent()->comment('생성일시'); + + // 인덱스 + $table->index('tenant_id'); + $table->index('stock_id'); + $table->index('stock_lot_id'); + $table->index('type'); + $table->index('reference_type'); + $table->index(['stock_id', 'created_at']); + $table->index(['tenant_id', 'item_code']); + $table->index(['tenant_id', 'type', 'created_at']); + $table->index(['reference_type', 'reference_id']); + + // 외래키 + $table->foreign('stock_id')->references('id')->on('stocks')->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('stock_transactions'); + } +}; \ No newline at end of file From 99a6c89d41e6f7d0687497cc343607b41ec33d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 15:05:14 +0900 Subject: [PATCH 39/57] =?UTF-8?q?feat:=20=EA=B2=AC=EC=A0=81=20=ED=95=A0?= =?UTF-8?q?=EC=9D=B8=EA=B8=88=EC=95=A1(discount=5Famount)=20=EC=A7=81?= =?UTF-8?q?=EC=A0=91=20=EC=9E=85=EB=A0=A5=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QuoteStoreRequest/UpdateRequest에 discount_amount 필드 추가 - QuoteService: 프론트엔드에서 계산한 할인금액 우선 사용, 없으면 비율로 계산 Co-Authored-By: Claude Opus 4.5 --- app/Http/Requests/Quote/QuoteStoreRequest.php | 1 + app/Http/Requests/Quote/QuoteUpdateRequest.php | 1 + app/Services/Quote/QuoteService.php | 10 ++++++++-- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/Http/Requests/Quote/QuoteStoreRequest.php b/app/Http/Requests/Quote/QuoteStoreRequest.php index effb09f..c496c26 100644 --- a/app/Http/Requests/Quote/QuoteStoreRequest.php +++ b/app/Http/Requests/Quote/QuoteStoreRequest.php @@ -71,6 +71,7 @@ public function rules(): array 'labor_cost' => 'nullable|numeric|min:0', 'install_cost' => 'nullable|numeric|min:0', 'discount_rate' => 'nullable|numeric|min:0|max:100', + 'discount_amount' => 'nullable|numeric|min:0', 'total_amount' => 'nullable|numeric|min:0', // 기타 정보 diff --git a/app/Http/Requests/Quote/QuoteUpdateRequest.php b/app/Http/Requests/Quote/QuoteUpdateRequest.php index a72bfc2..e9fd9f7 100644 --- a/app/Http/Requests/Quote/QuoteUpdateRequest.php +++ b/app/Http/Requests/Quote/QuoteUpdateRequest.php @@ -69,6 +69,7 @@ public function rules(): array 'labor_cost' => 'nullable|numeric|min:0', 'install_cost' => 'nullable|numeric|min:0', 'discount_rate' => 'nullable|numeric|min:0|max:100', + 'discount_amount' => 'nullable|numeric|min:0', 'total_amount' => 'nullable|numeric|min:0', // 기타 정보 diff --git a/app/Services/Quote/QuoteService.php b/app/Services/Quote/QuoteService.php index 7e5a74a..be699c4 100644 --- a/app/Services/Quote/QuoteService.php +++ b/app/Services/Quote/QuoteService.php @@ -222,7 +222,10 @@ public function store(array $data): Quote $installCost = (float) ($data['install_cost'] ?? 0); $subtotal = $materialCost + $laborCost + $installCost; $discountRate = (float) ($data['discount_rate'] ?? 0); - $discountAmount = $subtotal * ($discountRate / 100); + // 프론트엔드에서 직접 계산한 할인금액이 있으면 사용, 없으면 subtotal에서 계산 + $discountAmount = isset($data['discount_amount']) + ? (float) $data['discount_amount'] + : $subtotal * ($discountRate / 100); $totalAmount = $subtotal - $discountAmount; // 견적 생성 @@ -318,7 +321,10 @@ public function update(int $id, array $data): Quote $installCost = (float) ($data['install_cost'] ?? $quote->install_cost); $subtotal = $materialCost + $laborCost + $installCost; $discountRate = (float) ($data['discount_rate'] ?? $quote->discount_rate); - $discountAmount = $subtotal * ($discountRate / 100); + // 프론트엔드에서 직접 계산한 할인금액이 있으면 사용, 없으면 subtotal에서 계산 + $discountAmount = isset($data['discount_amount']) + ? (float) $data['discount_amount'] + : $subtotal * ($discountRate / 100); $totalAmount = $subtotal - $discountAmount; // 업데이트 From 00a1257c63ad6d28188666e54df4a392b2898262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 15:29:01 +0900 Subject: [PATCH 40/57] =?UTF-8?q?fix:=20ApprovalStep=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=20Eloquent=20Model=20import=20=EB=88=84=EB=9D=BD=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- app/Models/Tenants/ApprovalStep.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/Models/Tenants/ApprovalStep.php b/app/Models/Tenants/ApprovalStep.php index 65540ea..5a7c554 100644 --- a/app/Models/Tenants/ApprovalStep.php +++ b/app/Models/Tenants/ApprovalStep.php @@ -3,6 +3,7 @@ namespace App\Models\Tenants; use App\Models\Members\User; +use App\Traits\Auditable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -22,6 +23,7 @@ */ class ApprovalStep extends Model { + use Auditable; protected $table = 'approval_steps'; protected $casts = [ From 189b38c93699a13141668e0d7d599f49072aa5e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 15:33:54 +0900 Subject: [PATCH 41/57] =?UTF-8?q?feat:=20Auditable=20=ED=8A=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=2097?= =?UTF-8?q?=EA=B0=9C=20=EB=AA=A8=EB=8D=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auditable 트레이트 신규 생성 (bootAuditable 패턴) - creating: created_by/updated_by 자동 채우기 - updating: updated_by 자동 채우기 - deleting: deleted_by 채우기 + saveQuietly() - created/updated/deleted: audit_logs 자동 기록 - 기존 AuditLogger 패턴과 동일한 try/catch 조용한 실패 - 변경된 필드만 before/after 기록 (updated 이벤트) - auditExclude 프로퍼티로 모델별 제외 필드 설정 가능 - 제외 대상: Attendance, StockTransaction, TodayIssue 등 고빈도/시스템 모델 Co-Authored-By: Claude Opus 4.5 --- app/Models/BadDebts/BadDebt.php | 3 +- app/Models/Bidding/Bidding.php | 3 +- app/Models/CategoryGroup.php | 3 +- app/Models/Commons/Category.php | 3 +- app/Models/Commons/CategoryField.php | 3 +- app/Models/Commons/CategoryLog.php | 3 +- app/Models/Commons/CategoryTemplate.php | 3 +- app/Models/Commons/Classification.php | 3 +- app/Models/Commons/Menu.php | 3 +- app/Models/Construction/Contract.php | 3 +- app/Models/Construction/HandoverReport.php | 3 +- .../Construction/HandoverReportItem.php | 3 +- .../Construction/HandoverReportManager.php | 3 +- app/Models/Construction/StructureReview.php | 3 +- app/Models/Documents/Document.php | 3 +- app/Models/Estimate/Estimate.php | 3 +- app/Models/Estimate/EstimateItem.php | 3 +- app/Models/ItemMaster/CustomTab.php | 3 +- app/Models/ItemMaster/EntityRelationship.php | 3 +- app/Models/ItemMaster/ItemBomItem.php | 3 +- app/Models/ItemMaster/ItemField.php | 3 +- app/Models/ItemMaster/ItemPage.php | 3 +- app/Models/ItemMaster/ItemSection.php | 3 +- app/Models/ItemMaster/TabColumn.php | 3 +- app/Models/ItemMaster/UnitOption.php | 3 +- app/Models/Items/Item.php | 3 +- app/Models/Items/ItemReceipt.php | 3 +- app/Models/Labor.php | 3 +- app/Models/Materials/Material.php | 3 +- app/Models/Members/UserRole.php | 3 +- app/Models/Members/UserTenant.php | 3 +- app/Models/NotificationSetting.php | 3 +- app/Models/NotificationSettingGroup.php | 3 +- app/Models/NotificationSettingGroupState.php | 3 +- app/Models/Orders/Client.php | 3 +- app/Models/Orders/ClientGroup.php | 3 +- app/Models/Orders/Order.php | 3 +- app/Models/Orders/OrderItem.php | 3 +- app/Models/Permissions/Role.php | 3 +- app/Models/Popups/Popup.php | 3 +- app/Models/Process.php | 3 +- app/Models/Production/WorkOrder.php | 3 +- app/Models/Production/WorkOrderAssignee.php | 3 +- .../Production/WorkOrderBendingDetail.php | 3 +- app/Models/Production/WorkOrderIssue.php | 3 +- app/Models/Production/WorkOrderItem.php | 3 +- app/Models/Production/WorkResult.php | 3 +- app/Models/Products/CommonCode.php | 3 +- app/Models/Products/Price.php | 3 +- app/Models/Products/PriceRevision.php | 3 +- app/Models/Products/Product.php | 3 +- app/Models/Products/ProductComponent.php | 3 +- app/Models/PushDeviceToken.php | 3 +- app/Models/PushNotificationSetting.php | 3 +- app/Models/Qualitys/Inspection.php | 3 +- app/Models/Quote/Quote.php | 3 +- app/Models/Quote/QuoteFormula.php | 3 +- app/Models/Quote/QuoteFormulaCategory.php | 3 +- app/Models/Quote/QuoteItem.php | 3 +- app/Models/Quote/QuoteRevision.php | 3 +- app/Models/Tenants/Approval.php | 3 +- app/Models/Tenants/ApprovalForm.php | 3 +- app/Models/Tenants/ApprovalLine.php | 3 +- app/Models/Tenants/BankAccount.php | 3 +- app/Models/Tenants/Bill.php | 3 +- app/Models/Tenants/BillInstallment.php | 3 + app/Models/Tenants/Card.php | 3 +- app/Models/Tenants/Department.php | 3 +- app/Models/Tenants/Deposit.php | 3 +- app/Models/Tenants/ExpectedExpense.php | 3 +- app/Models/Tenants/ExpenseAccount.php | 3 +- app/Models/Tenants/Leave.php | 3 +- app/Models/Tenants/LeaveBalance.php | 3 +- app/Models/Tenants/LeaveGrant.php | 3 +- app/Models/Tenants/LeavePolicy.php | 3 +- app/Models/Tenants/Loan.php | 3 +- app/Models/Tenants/Payment.php | 3 +- app/Models/Tenants/Payroll.php | 3 +- app/Models/Tenants/Plan.php | 3 +- app/Models/Tenants/Position.php | 3 +- app/Models/Tenants/Purchase.php | 3 +- app/Models/Tenants/Receiving.php | 3 +- app/Models/Tenants/Salary.php | 3 +- app/Models/Tenants/Sale.php | 3 +- app/Models/Tenants/Schedule.php | 4 +- app/Models/Tenants/Shipment.php | 3 +- app/Models/Tenants/ShipmentItem.php | 3 +- app/Models/Tenants/Site.php | 3 +- app/Models/Tenants/SiteBriefing.php | 3 +- app/Models/Tenants/Stock.php | 3 +- app/Models/Tenants/StockLot.php | 3 +- app/Models/Tenants/Subscription.php | 3 +- app/Models/Tenants/TaxInvoice.php | 3 +- app/Models/Tenants/TenantUserProfile.php | 3 +- app/Models/Tenants/Withdrawal.php | 3 +- app/Models/UserInvitation.php | 3 +- app/Traits/Auditable.php | 124 ++++++++++++++++++ 97 files changed, 317 insertions(+), 96 deletions(-) create mode 100644 app/Traits/Auditable.php diff --git a/app/Models/BadDebts/BadDebt.php b/app/Models/BadDebts/BadDebt.php index a811897..5a51977 100644 --- a/app/Models/BadDebts/BadDebt.php +++ b/app/Models/BadDebts/BadDebt.php @@ -4,6 +4,7 @@ use App\Models\Members\User; use App\Models\Orders\Client; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -12,7 +13,7 @@ class BadDebt extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Bidding/Bidding.php b/app/Models/Bidding/Bidding.php index c8d68f7..23d8760 100644 --- a/app/Models/Bidding/Bidding.php +++ b/app/Models/Bidding/Bidding.php @@ -5,6 +5,7 @@ use App\Models\Members\User; use App\Models\Orders\Client; use App\Models\Quote\Quote; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -13,7 +14,7 @@ class Bidding extends Model { - use BelongsToTenant, HasFactory, SoftDeletes; + use Auditable, BelongsToTenant, HasFactory, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/CategoryGroup.php b/app/Models/CategoryGroup.php index e7b6acd..86772a4 100644 --- a/app/Models/CategoryGroup.php +++ b/app/Models/CategoryGroup.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Models\Tenants\Tenant; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -15,7 +16,7 @@ */ class CategoryGroup extends Model { - use BelongsToTenant; + use Auditable, BelongsToTenant; protected $table = 'category_groups'; diff --git a/app/Models/Commons/Category.php b/app/Models/Commons/Category.php index aad2f61..695a410 100644 --- a/app/Models/Commons/Category.php +++ b/app/Models/Commons/Category.php @@ -2,6 +2,7 @@ namespace App\Models\Commons; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -9,7 +10,7 @@ class Category extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $fillable = [ 'tenant_id', 'parent_id', 'code_group', 'code', 'name', diff --git a/app/Models/Commons/CategoryField.php b/app/Models/Commons/CategoryField.php index 7b92073..2e4a4e9 100644 --- a/app/Models/Commons/CategoryField.php +++ b/app/Models/Commons/CategoryField.php @@ -2,6 +2,7 @@ namespace App\Models\Commons; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -9,7 +10,7 @@ class CategoryField extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $table = 'category_fields'; diff --git a/app/Models/Commons/CategoryLog.php b/app/Models/Commons/CategoryLog.php index ddacf0c..293ccdf 100644 --- a/app/Models/Commons/CategoryLog.php +++ b/app/Models/Commons/CategoryLog.php @@ -2,13 +2,14 @@ namespace App\Models\Commons; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; class CategoryLog extends Model { - use BelongsToTenant, ModelTrait; + use Auditable, BelongsToTenant, ModelTrait; protected $table = 'category_logs'; diff --git a/app/Models/Commons/CategoryTemplate.php b/app/Models/Commons/CategoryTemplate.php index edf6d9b..e0f46f7 100644 --- a/app/Models/Commons/CategoryTemplate.php +++ b/app/Models/Commons/CategoryTemplate.php @@ -2,13 +2,14 @@ namespace App\Models\Commons; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; class CategoryTemplate extends Model { - use BelongsToTenant, ModelTrait; + use Auditable, BelongsToTenant, ModelTrait; protected $table = 'category_templates'; diff --git a/app/Models/Commons/Classification.php b/app/Models/Commons/Classification.php index 205c500..5ce2ced 100644 --- a/app/Models/Commons/Classification.php +++ b/app/Models/Commons/Classification.php @@ -2,6 +2,7 @@ namespace App\Models\Commons; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -9,7 +10,7 @@ class Classification extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Commons/Menu.php b/app/Models/Commons/Menu.php index 4dc5afd..76289d5 100644 --- a/app/Models/Commons/Menu.php +++ b/app/Models/Commons/Menu.php @@ -3,6 +3,7 @@ namespace App\Models\Commons; use App\Models\Scopes\TenantScope; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -15,7 +16,7 @@ */ class Menu extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $fillable = [ 'tenant_id', 'parent_id', 'global_menu_id', 'name', 'url', 'is_active', 'sort_order', diff --git a/app/Models/Construction/Contract.php b/app/Models/Construction/Contract.php index 04d55cf..1416b6b 100644 --- a/app/Models/Construction/Contract.php +++ b/app/Models/Construction/Contract.php @@ -3,6 +3,7 @@ namespace App\Models\Construction; use App\Models\Members\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -42,7 +43,7 @@ */ class Contract extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $table = 'contracts'; diff --git a/app/Models/Construction/HandoverReport.php b/app/Models/Construction/HandoverReport.php index 47f05dd..be0be71 100644 --- a/app/Models/Construction/HandoverReport.php +++ b/app/Models/Construction/HandoverReport.php @@ -3,6 +3,7 @@ namespace App\Models\Construction; use App\Models\Members\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -49,7 +50,7 @@ */ class HandoverReport extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $table = 'handover_reports'; diff --git a/app/Models/Construction/HandoverReportItem.php b/app/Models/Construction/HandoverReportItem.php index cdcb5aa..19ca992 100644 --- a/app/Models/Construction/HandoverReportItem.php +++ b/app/Models/Construction/HandoverReportItem.php @@ -3,6 +3,7 @@ namespace App\Models\Construction; use App\Models\Members\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -25,7 +26,7 @@ */ class HandoverReportItem extends Model { - use BelongsToTenant; + use Auditable, BelongsToTenant; protected $table = 'handover_report_items'; diff --git a/app/Models/Construction/HandoverReportManager.php b/app/Models/Construction/HandoverReportManager.php index 42ae25c..5dd1873 100644 --- a/app/Models/Construction/HandoverReportManager.php +++ b/app/Models/Construction/HandoverReportManager.php @@ -3,6 +3,7 @@ namespace App\Models\Construction; use App\Models\Members\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -24,7 +25,7 @@ */ class HandoverReportManager extends Model { - use BelongsToTenant; + use Auditable, BelongsToTenant; protected $table = 'handover_report_managers'; diff --git a/app/Models/Construction/StructureReview.php b/app/Models/Construction/StructureReview.php index 21efe2c..1dbee0d 100644 --- a/app/Models/Construction/StructureReview.php +++ b/app/Models/Construction/StructureReview.php @@ -4,6 +4,7 @@ use App\Models\Members\User; use App\Models\Site; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -38,7 +39,7 @@ */ class StructureReview extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $table = 'structure_reviews'; diff --git a/app/Models/Documents/Document.php b/app/Models/Documents/Document.php index 8823054..6f3b2c2 100644 --- a/app/Models/Documents/Document.php +++ b/app/Models/Documents/Document.php @@ -3,6 +3,7 @@ namespace App\Models\Documents; use App\Models\Members\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -32,7 +33,7 @@ */ class Document extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $table = 'documents'; diff --git a/app/Models/Estimate/Estimate.php b/app/Models/Estimate/Estimate.php index e41beb4..11257e3 100644 --- a/app/Models/Estimate/Estimate.php +++ b/app/Models/Estimate/Estimate.php @@ -3,6 +3,7 @@ namespace App\Models\Estimate; use App\Models\Commons\Category; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -12,7 +13,7 @@ class Estimate extends Model { - use BelongsToTenant, HasFactory, SoftDeletes; + use Auditable, BelongsToTenant, HasFactory, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Estimate/EstimateItem.php b/app/Models/Estimate/EstimateItem.php index 032fbea..2c6fdd3 100644 --- a/app/Models/Estimate/EstimateItem.php +++ b/app/Models/Estimate/EstimateItem.php @@ -2,6 +2,7 @@ namespace App\Models\Estimate; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -10,7 +11,7 @@ class EstimateItem extends Model { - use BelongsToTenant, HasFactory, SoftDeletes; + use Auditable, BelongsToTenant, HasFactory, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/ItemMaster/CustomTab.php b/app/Models/ItemMaster/CustomTab.php index fd9d3b4..f3926cf 100644 --- a/app/Models/ItemMaster/CustomTab.php +++ b/app/Models/ItemMaster/CustomTab.php @@ -2,6 +2,7 @@ namespace App\Models\ItemMaster; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -9,7 +10,7 @@ class CustomTab extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/ItemMaster/EntityRelationship.php b/app/Models/ItemMaster/EntityRelationship.php index 25c4371..04a1252 100644 --- a/app/Models/ItemMaster/EntityRelationship.php +++ b/app/Models/ItemMaster/EntityRelationship.php @@ -2,6 +2,7 @@ namespace App\Models\ItemMaster; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -17,7 +18,7 @@ */ class EntityRelationship extends Model { - use BelongsToTenant, ModelTrait; + use Auditable, BelongsToTenant, ModelTrait; protected $fillable = [ 'tenant_id', diff --git a/app/Models/ItemMaster/ItemBomItem.php b/app/Models/ItemMaster/ItemBomItem.php index 789c003..91faa81 100644 --- a/app/Models/ItemMaster/ItemBomItem.php +++ b/app/Models/ItemMaster/ItemBomItem.php @@ -2,6 +2,7 @@ namespace App\Models\ItemMaster; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -9,7 +10,7 @@ class ItemBomItem extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/ItemMaster/ItemField.php b/app/Models/ItemMaster/ItemField.php index a262415..8d3ffe4 100644 --- a/app/Models/ItemMaster/ItemField.php +++ b/app/Models/ItemMaster/ItemField.php @@ -2,6 +2,7 @@ namespace App\Models\ItemMaster; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -9,7 +10,7 @@ class ItemField extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/ItemMaster/ItemPage.php b/app/Models/ItemMaster/ItemPage.php index aeaf7e0..e6023f0 100644 --- a/app/Models/ItemMaster/ItemPage.php +++ b/app/Models/ItemMaster/ItemPage.php @@ -2,6 +2,7 @@ namespace App\Models\ItemMaster; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -9,7 +10,7 @@ class ItemPage extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/ItemMaster/ItemSection.php b/app/Models/ItemMaster/ItemSection.php index af9f260..1b84d55 100644 --- a/app/Models/ItemMaster/ItemSection.php +++ b/app/Models/ItemMaster/ItemSection.php @@ -2,6 +2,7 @@ namespace App\Models\ItemMaster; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -9,7 +10,7 @@ class ItemSection extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/ItemMaster/TabColumn.php b/app/Models/ItemMaster/TabColumn.php index 68bde18..932ae5b 100644 --- a/app/Models/ItemMaster/TabColumn.php +++ b/app/Models/ItemMaster/TabColumn.php @@ -2,13 +2,14 @@ namespace App\Models\ItemMaster; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; class TabColumn extends Model { - use BelongsToTenant, ModelTrait; + use Auditable, BelongsToTenant, ModelTrait; protected $fillable = [ 'tenant_id', diff --git a/app/Models/ItemMaster/UnitOption.php b/app/Models/ItemMaster/UnitOption.php index bd73b73..1f542e0 100644 --- a/app/Models/ItemMaster/UnitOption.php +++ b/app/Models/ItemMaster/UnitOption.php @@ -2,6 +2,7 @@ namespace App\Models\ItemMaster; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -9,7 +10,7 @@ class UnitOption extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Items/Item.php b/app/Models/Items/Item.php index fa09dcc..97329f0 100644 --- a/app/Models/Items/Item.php +++ b/app/Models/Items/Item.php @@ -5,6 +5,7 @@ use App\Models\Commons\Category; use App\Models\Commons\File; use App\Models\Commons\Tag; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -18,7 +19,7 @@ */ class Item extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Items/ItemReceipt.php b/app/Models/Items/ItemReceipt.php index ae2a225..1030cb5 100644 --- a/app/Models/Items/ItemReceipt.php +++ b/app/Models/Items/ItemReceipt.php @@ -3,6 +3,7 @@ namespace App\Models\Items; use App\Models\Members\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; @@ -30,7 +31,7 @@ */ class ItemReceipt extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $table = 'item_receipts'; diff --git a/app/Models/Labor.php b/app/Models/Labor.php index 676c639..162df33 100644 --- a/app/Models/Labor.php +++ b/app/Models/Labor.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -9,7 +10,7 @@ class Labor extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $table = 'labors'; diff --git a/app/Models/Materials/Material.php b/app/Models/Materials/Material.php index 9eb7bbb..afc1fc1 100644 --- a/app/Models/Materials/Material.php +++ b/app/Models/Materials/Material.php @@ -6,6 +6,7 @@ use App\Models\Commons\File; use App\Models\Commons\Tag; use App\Models\Qualitys\Lot; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -16,7 +17,7 @@ */ class Material extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Members/UserRole.php b/app/Models/Members/UserRole.php index 8a3c401..492e7ad 100644 --- a/app/Models/Members/UserRole.php +++ b/app/Models/Members/UserRole.php @@ -4,6 +4,7 @@ use App\Models\Permissions\Role; use App\Models\Tenants\Tenant; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; @@ -13,7 +14,7 @@ */ class UserRole extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $fillable = [ 'user_id', 'tenant_id', 'role_id', 'assigned_at', diff --git a/app/Models/Members/UserTenant.php b/app/Models/Members/UserTenant.php index d8a01e6..3e37934 100644 --- a/app/Models/Members/UserTenant.php +++ b/app/Models/Members/UserTenant.php @@ -3,6 +3,7 @@ namespace App\Models\Members; use App\Models\Tenants\Tenant; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -13,7 +14,7 @@ */ class UserTenant extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $fillable = [ 'user_id', 'tenant_id', 'is_active', 'is_default', 'joined_at', 'left_at', diff --git a/app/Models/NotificationSetting.php b/app/Models/NotificationSetting.php index bed7dc6..327c4f1 100644 --- a/app/Models/NotificationSetting.php +++ b/app/Models/NotificationSetting.php @@ -2,13 +2,14 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class NotificationSetting extends Model { - use BelongsToTenant; + use Auditable, BelongsToTenant; protected $fillable = [ 'tenant_id', diff --git a/app/Models/NotificationSettingGroup.php b/app/Models/NotificationSettingGroup.php index a2ae4b0..a62c589 100644 --- a/app/Models/NotificationSettingGroup.php +++ b/app/Models/NotificationSettingGroup.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -9,7 +10,7 @@ class NotificationSettingGroup extends Model { - use BelongsToTenant; + use Auditable, BelongsToTenant; protected $fillable = [ 'tenant_id', diff --git a/app/Models/NotificationSettingGroupState.php b/app/Models/NotificationSettingGroupState.php index a780f98..be54650 100644 --- a/app/Models/NotificationSettingGroupState.php +++ b/app/Models/NotificationSettingGroupState.php @@ -2,13 +2,14 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class NotificationSettingGroupState extends Model { - use BelongsToTenant; + use Auditable, BelongsToTenant; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Orders/Client.php b/app/Models/Orders/Client.php index bd9bea2..7cf878b 100644 --- a/app/Models/Orders/Client.php +++ b/app/Models/Orders/Client.php @@ -3,6 +3,7 @@ namespace App\Models\Orders; use App\Models\BadDebts\BadDebt; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -10,7 +11,7 @@ class Client extends Model { - use BelongsToTenant, ModelTrait; + use Auditable, BelongsToTenant, ModelTrait; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Orders/ClientGroup.php b/app/Models/Orders/ClientGroup.php index 48162b4..d354df7 100644 --- a/app/Models/Orders/ClientGroup.php +++ b/app/Models/Orders/ClientGroup.php @@ -2,6 +2,7 @@ namespace App\Models\Orders; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -9,7 +10,7 @@ class ClientGroup extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Orders/Order.php b/app/Models/Orders/Order.php index eedeb89..434c834 100644 --- a/app/Models/Orders/Order.php +++ b/app/Models/Orders/Order.php @@ -8,6 +8,7 @@ use App\Models\Quote\Quote; use App\Models\Tenants\Sale; use App\Models\Tenants\Shipment; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -21,7 +22,7 @@ */ class Order extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; // 상태 코드 public const STATUS_DRAFT = 'DRAFT'; diff --git a/app/Models/Orders/OrderItem.php b/app/Models/Orders/OrderItem.php index 3b72bfe..11932e7 100644 --- a/app/Models/Orders/OrderItem.php +++ b/app/Models/Orders/OrderItem.php @@ -5,6 +5,7 @@ use App\Models\Items\Item; use App\Models\Quote\Quote; use App\Models\Quote\QuoteItem; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -18,7 +19,7 @@ */ class OrderItem extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $table = 'order_items'; diff --git a/app/Models/Permissions/Role.php b/app/Models/Permissions/Role.php index 5828a4b..cb529e4 100644 --- a/app/Models/Permissions/Role.php +++ b/app/Models/Permissions/Role.php @@ -6,6 +6,7 @@ use App\Models\Members\User; use App\Models\Members\UserRole; use App\Models\Tenants\Tenant; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; @@ -15,7 +16,7 @@ */ class Role extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Popups/Popup.php b/app/Models/Popups/Popup.php index c3a427e..ba6fe06 100644 --- a/app/Models/Popups/Popup.php +++ b/app/Models/Popups/Popup.php @@ -4,6 +4,7 @@ use App\Models\Members\User; use App\Models\Tenants\Department; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -12,7 +13,7 @@ class Popup extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Process.php b/app/Models/Process.php index cb769d2..49ad213 100644 --- a/app/Models/Process.php +++ b/app/Models/Process.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -12,7 +13,7 @@ class Process extends Model { - use BelongsToTenant; + use Auditable, BelongsToTenant; use HasFactory; use ModelTrait; use SoftDeletes; diff --git a/app/Models/Production/WorkOrder.php b/app/Models/Production/WorkOrder.php index 2d224f6..b4ec0b9 100644 --- a/app/Models/Production/WorkOrder.php +++ b/app/Models/Production/WorkOrder.php @@ -7,6 +7,7 @@ use App\Models\Process; use App\Models\Tenants\Department; use App\Models\Tenants\Shipment; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -22,7 +23,7 @@ */ class WorkOrder extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $table = 'work_orders'; diff --git a/app/Models/Production/WorkOrderAssignee.php b/app/Models/Production/WorkOrderAssignee.php index b9429a2..267ed25 100644 --- a/app/Models/Production/WorkOrderAssignee.php +++ b/app/Models/Production/WorkOrderAssignee.php @@ -3,6 +3,7 @@ namespace App\Models\Production; use App\Models\Members\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -15,7 +16,7 @@ */ class WorkOrderAssignee extends Model { - use BelongsToTenant, ModelTrait; + use Auditable, BelongsToTenant, ModelTrait; protected $table = 'work_order_assignees'; diff --git a/app/Models/Production/WorkOrderBendingDetail.php b/app/Models/Production/WorkOrderBendingDetail.php index 733fea4..50ae644 100644 --- a/app/Models/Production/WorkOrderBendingDetail.php +++ b/app/Models/Production/WorkOrderBendingDetail.php @@ -2,6 +2,7 @@ namespace App\Models\Production; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -13,7 +14,7 @@ */ class WorkOrderBendingDetail extends Model { - use BelongsToTenant; + use Auditable, BelongsToTenant; protected $table = 'work_order_bending_details'; diff --git a/app/Models/Production/WorkOrderIssue.php b/app/Models/Production/WorkOrderIssue.php index bc3de57..37855c3 100644 --- a/app/Models/Production/WorkOrderIssue.php +++ b/app/Models/Production/WorkOrderIssue.php @@ -3,6 +3,7 @@ namespace App\Models\Production; use App\Models\Members\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -14,7 +15,7 @@ */ class WorkOrderIssue extends Model { - use BelongsToTenant; + use Auditable, BelongsToTenant; protected $table = 'work_order_issues'; diff --git a/app/Models/Production/WorkOrderItem.php b/app/Models/Production/WorkOrderItem.php index 095cdac..f59d99b 100644 --- a/app/Models/Production/WorkOrderItem.php +++ b/app/Models/Production/WorkOrderItem.php @@ -3,6 +3,7 @@ namespace App\Models\Production; use App\Models\Items\Item; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -12,7 +13,7 @@ */ class WorkOrderItem extends Model { - use BelongsToTenant; + use Auditable, BelongsToTenant; protected $table = 'work_order_items'; diff --git a/app/Models/Production/WorkResult.php b/app/Models/Production/WorkResult.php index 42d1483..9a80e68 100644 --- a/app/Models/Production/WorkResult.php +++ b/app/Models/Production/WorkResult.php @@ -3,6 +3,7 @@ namespace App\Models\Production; use App\Models\Members\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -16,7 +17,7 @@ */ class WorkResult extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $table = 'work_results'; diff --git a/app/Models/Products/CommonCode.php b/app/Models/Products/CommonCode.php index fedb3ba..36dc1c1 100644 --- a/app/Models/Products/CommonCode.php +++ b/app/Models/Products/CommonCode.php @@ -2,6 +2,7 @@ namespace App\Models\Products; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -12,7 +13,7 @@ */ class CommonCode extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $table = 'common_codes'; diff --git a/app/Models/Products/Price.php b/app/Models/Products/Price.php index 5f45f29..13ef9f4 100644 --- a/app/Models/Products/Price.php +++ b/app/Models/Products/Price.php @@ -3,6 +3,7 @@ namespace App\Models\Products; use App\Models\Orders\ClientGroup; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -12,7 +13,7 @@ class Price extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $table = 'prices'; diff --git a/app/Models/Products/PriceRevision.php b/app/Models/Products/PriceRevision.php index 6740cf7..fd4a7c8 100644 --- a/app/Models/Products/PriceRevision.php +++ b/app/Models/Products/PriceRevision.php @@ -3,13 +3,14 @@ namespace App\Models\Products; use App\Models\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class PriceRevision extends Model { - use BelongsToTenant; + use Auditable, BelongsToTenant; protected $table = 'price_revisions'; diff --git a/app/Models/Products/Product.php b/app/Models/Products/Product.php index 718fe46..a0b11c6 100644 --- a/app/Models/Products/Product.php +++ b/app/Models/Products/Product.php @@ -5,6 +5,7 @@ use App\Models\Commons\Category; use App\Models\Commons\File; use App\Models\Commons\Tag; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -12,7 +13,7 @@ class Product extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $fillable = [ 'tenant_id', 'code', 'name', 'unit', 'category_id', diff --git a/app/Models/Products/ProductComponent.php b/app/Models/Products/ProductComponent.php index f6f6fd1..4d0603c 100644 --- a/app/Models/Products/ProductComponent.php +++ b/app/Models/Products/ProductComponent.php @@ -3,6 +3,7 @@ namespace App\Models\Products; use App\Models\Materials\Material; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -10,7 +11,7 @@ class ProductComponent extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $table = 'product_components'; diff --git a/app/Models/PushDeviceToken.php b/app/Models/PushDeviceToken.php index b4a1ff6..8c043e3 100644 --- a/app/Models/PushDeviceToken.php +++ b/app/Models/PushDeviceToken.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -9,7 +10,7 @@ class PushDeviceToken extends Model { - use BelongsToTenant; + use Auditable, BelongsToTenant; use SoftDeletes; protected $fillable = [ diff --git a/app/Models/PushNotificationSetting.php b/app/Models/PushNotificationSetting.php index 6cbf0df..a91ed12 100644 --- a/app/Models/PushNotificationSetting.php +++ b/app/Models/PushNotificationSetting.php @@ -2,13 +2,14 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class PushNotificationSetting extends Model { - use BelongsToTenant; + use Auditable, BelongsToTenant; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Qualitys/Inspection.php b/app/Models/Qualitys/Inspection.php index 7ac6545..f6453b3 100644 --- a/app/Models/Qualitys/Inspection.php +++ b/app/Models/Qualitys/Inspection.php @@ -4,6 +4,7 @@ use App\Models\Items\Item; use App\Models\Members\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; @@ -32,7 +33,7 @@ */ class Inspection extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $table = 'inspections'; diff --git a/app/Models/Quote/Quote.php b/app/Models/Quote/Quote.php index 82cc1fb..7953a7d 100644 --- a/app/Models/Quote/Quote.php +++ b/app/Models/Quote/Quote.php @@ -7,6 +7,7 @@ use App\Models\Orders\Client; use App\Models\Orders\Order; use App\Models\Tenants\SiteBriefing; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -16,7 +17,7 @@ class Quote extends Model { - use BelongsToTenant, HasFactory, SoftDeletes; + use Auditable, BelongsToTenant, HasFactory, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Quote/QuoteFormula.php b/app/Models/Quote/QuoteFormula.php index 2a59611..81bfa08 100644 --- a/app/Models/Quote/QuoteFormula.php +++ b/app/Models/Quote/QuoteFormula.php @@ -3,6 +3,7 @@ namespace App\Models\Quote; use App\Models\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Builder; @@ -29,7 +30,7 @@ */ class QuoteFormula extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $table = 'quote_formulas'; diff --git a/app/Models/Quote/QuoteFormulaCategory.php b/app/Models/Quote/QuoteFormulaCategory.php index 33eed53..310de6c 100644 --- a/app/Models/Quote/QuoteFormulaCategory.php +++ b/app/Models/Quote/QuoteFormulaCategory.php @@ -3,6 +3,7 @@ namespace App\Models\Quote; use App\Models\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Builder; @@ -26,7 +27,7 @@ */ class QuoteFormulaCategory extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $table = 'quote_formula_categories'; diff --git a/app/Models/Quote/QuoteItem.php b/app/Models/Quote/QuoteItem.php index 8bf11b9..f95be0a 100644 --- a/app/Models/Quote/QuoteItem.php +++ b/app/Models/Quote/QuoteItem.php @@ -2,6 +2,7 @@ namespace App\Models\Quote; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -9,7 +10,7 @@ class QuoteItem extends Model { - use BelongsToTenant, HasFactory; + use Auditable, BelongsToTenant, HasFactory; protected $fillable = [ 'quote_id', diff --git a/app/Models/Quote/QuoteRevision.php b/app/Models/Quote/QuoteRevision.php index 988fbc3..9761f85 100644 --- a/app/Models/Quote/QuoteRevision.php +++ b/app/Models/Quote/QuoteRevision.php @@ -3,6 +3,7 @@ namespace App\Models\Quote; use App\Models\Members\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -10,7 +11,7 @@ class QuoteRevision extends Model { - use BelongsToTenant, HasFactory; + use Auditable, BelongsToTenant, HasFactory; const UPDATED_AT = null; diff --git a/app/Models/Tenants/Approval.php b/app/Models/Tenants/Approval.php index f4e1edb..9ecc871 100644 --- a/app/Models/Tenants/Approval.php +++ b/app/Models/Tenants/Approval.php @@ -3,6 +3,7 @@ namespace App\Models\Tenants; use App\Models\Members\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -30,7 +31,7 @@ */ class Approval extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $table = 'approvals'; diff --git a/app/Models/Tenants/ApprovalForm.php b/app/Models/Tenants/ApprovalForm.php index 5158788..f013df7 100644 --- a/app/Models/Tenants/ApprovalForm.php +++ b/app/Models/Tenants/ApprovalForm.php @@ -3,6 +3,7 @@ namespace App\Models\Tenants; use App\Models\Members\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -25,7 +26,7 @@ */ class ApprovalForm extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $table = 'approval_forms'; diff --git a/app/Models/Tenants/ApprovalLine.php b/app/Models/Tenants/ApprovalLine.php index e156205..41f9d1f 100644 --- a/app/Models/Tenants/ApprovalLine.php +++ b/app/Models/Tenants/ApprovalLine.php @@ -3,6 +3,7 @@ namespace App\Models\Tenants; use App\Models\Members\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -22,7 +23,7 @@ */ class ApprovalLine extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $table = 'approval_lines'; diff --git a/app/Models/Tenants/BankAccount.php b/app/Models/Tenants/BankAccount.php index 5a686d9..416663d 100644 --- a/app/Models/Tenants/BankAccount.php +++ b/app/Models/Tenants/BankAccount.php @@ -3,6 +3,7 @@ namespace App\Models\Tenants; use App\Models\Members\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -28,7 +29,7 @@ */ class BankAccount extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $table = 'bank_accounts'; diff --git a/app/Models/Tenants/Bill.php b/app/Models/Tenants/Bill.php index 878984f..1200fa7 100644 --- a/app/Models/Tenants/Bill.php +++ b/app/Models/Tenants/Bill.php @@ -2,6 +2,7 @@ namespace App\Models\Tenants; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -10,7 +11,7 @@ class Bill extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Tenants/BillInstallment.php b/app/Models/Tenants/BillInstallment.php index 40d119c..41656e2 100644 --- a/app/Models/Tenants/BillInstallment.php +++ b/app/Models/Tenants/BillInstallment.php @@ -2,11 +2,14 @@ namespace App\Models\Tenants; +use App\Traits\Auditable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class BillInstallment extends Model { + use Auditable; + protected $fillable = [ 'bill_id', 'installment_date', diff --git a/app/Models/Tenants/Card.php b/app/Models/Tenants/Card.php index 46c9c6f..eb1eaf7 100644 --- a/app/Models/Tenants/Card.php +++ b/app/Models/Tenants/Card.php @@ -3,6 +3,7 @@ namespace App\Models\Tenants; use App\Models\Members\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -29,7 +30,7 @@ */ class Card extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $table = 'cards'; diff --git a/app/Models/Tenants/Department.php b/app/Models/Tenants/Department.php index 6e3ecba..91eee01 100644 --- a/app/Models/Tenants/Department.php +++ b/app/Models/Tenants/Department.php @@ -5,6 +5,7 @@ use App\Models\Members\User; use App\Models\Permissions\PermissionOverride; use App\Models\Tenants\Pivots\DepartmentUser; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -16,7 +17,7 @@ class Department extends Model { - use BelongsToTenant, HasRoles, ModelTrait, SoftDeletes; // 부서도 권한/역할을 가짐 + use Auditable, BelongsToTenant, HasRoles, ModelTrait, SoftDeletes; // 부서도 권한/역할을 가짐 protected $table = 'departments'; diff --git a/app/Models/Tenants/Deposit.php b/app/Models/Tenants/Deposit.php index 6f62790..610f1fe 100644 --- a/app/Models/Tenants/Deposit.php +++ b/app/Models/Tenants/Deposit.php @@ -2,6 +2,7 @@ namespace App\Models\Tenants; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -9,7 +10,7 @@ class Deposit extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Tenants/ExpectedExpense.php b/app/Models/Tenants/ExpectedExpense.php index 0600839..2f48512 100644 --- a/app/Models/Tenants/ExpectedExpense.php +++ b/app/Models/Tenants/ExpectedExpense.php @@ -2,6 +2,7 @@ namespace App\Models\Tenants; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -11,7 +12,7 @@ class ExpectedExpense extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Tenants/ExpenseAccount.php b/app/Models/Tenants/ExpenseAccount.php index 1895412..b57c677 100644 --- a/app/Models/Tenants/ExpenseAccount.php +++ b/app/Models/Tenants/ExpenseAccount.php @@ -3,6 +3,7 @@ namespace App\Models\Tenants; use App\Models\Orders\Client; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -17,7 +18,7 @@ */ class ExpenseAccount extends Model { - use BelongsToTenant, HasFactory, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, HasFactory, ModelTrait, SoftDeletes; protected $table = 'expense_accounts'; diff --git a/app/Models/Tenants/Leave.php b/app/Models/Tenants/Leave.php index 44cb75e..dc5a9af 100644 --- a/app/Models/Tenants/Leave.php +++ b/app/Models/Tenants/Leave.php @@ -3,6 +3,7 @@ namespace App\Models\Tenants; use App\Models\Members\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -30,7 +31,7 @@ */ class Leave extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $table = 'leaves'; diff --git a/app/Models/Tenants/LeaveBalance.php b/app/Models/Tenants/LeaveBalance.php index f26ac15..ec276c0 100644 --- a/app/Models/Tenants/LeaveBalance.php +++ b/app/Models/Tenants/LeaveBalance.php @@ -3,6 +3,7 @@ namespace App\Models\Tenants; use App\Models\Members\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -20,7 +21,7 @@ */ class LeaveBalance extends Model { - use BelongsToTenant; + use Auditable, BelongsToTenant; protected $table = 'leave_balances'; diff --git a/app/Models/Tenants/LeaveGrant.php b/app/Models/Tenants/LeaveGrant.php index 25abff0..f2dfa59 100644 --- a/app/Models/Tenants/LeaveGrant.php +++ b/app/Models/Tenants/LeaveGrant.php @@ -3,6 +3,7 @@ namespace App\Models\Tenants; use App\Models\Members\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -24,7 +25,7 @@ */ class LeaveGrant extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $table = 'leave_grants'; diff --git a/app/Models/Tenants/LeavePolicy.php b/app/Models/Tenants/LeavePolicy.php index f65f48d..e61998b 100644 --- a/app/Models/Tenants/LeavePolicy.php +++ b/app/Models/Tenants/LeavePolicy.php @@ -2,6 +2,7 @@ namespace App\Models\Tenants; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -25,7 +26,7 @@ */ class LeavePolicy extends Model { - use BelongsToTenant; + use Auditable, BelongsToTenant; protected $table = 'leave_policies'; diff --git a/app/Models/Tenants/Loan.php b/app/Models/Tenants/Loan.php index 80b403e..542a3ad 100644 --- a/app/Models/Tenants/Loan.php +++ b/app/Models/Tenants/Loan.php @@ -2,6 +2,7 @@ namespace App\Models\Tenants; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -11,7 +12,7 @@ class Loan extends Model { - use BelongsToTenant, HasFactory, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, HasFactory, ModelTrait, SoftDeletes; // ========================================================================= // 상수 정의 diff --git a/app/Models/Tenants/Payment.php b/app/Models/Tenants/Payment.php index 1bbe7b9..86d1e87 100644 --- a/app/Models/Tenants/Payment.php +++ b/app/Models/Tenants/Payment.php @@ -2,6 +2,7 @@ namespace App\Models\Tenants; +use App\Traits\Auditable; use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -24,7 +25,7 @@ */ class Payment extends Model { - use SoftDeletes; + use Auditable, SoftDeletes; // ========================================================================= // 상수 정의 diff --git a/app/Models/Tenants/Payroll.php b/app/Models/Tenants/Payroll.php index 2805f60..da12f95 100644 --- a/app/Models/Tenants/Payroll.php +++ b/app/Models/Tenants/Payroll.php @@ -3,6 +3,7 @@ namespace App\Models\Tenants; use App\Models\Members\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -41,7 +42,7 @@ */ class Payroll extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $table = 'payrolls'; diff --git a/app/Models/Tenants/Plan.php b/app/Models/Tenants/Plan.php index 8f587e9..bb0589e 100644 --- a/app/Models/Tenants/Plan.php +++ b/app/Models/Tenants/Plan.php @@ -2,6 +2,7 @@ namespace App\Models\Tenants; +use App\Traits\Auditable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -23,7 +24,7 @@ */ class Plan extends Model { - use SoftDeletes; + use Auditable, SoftDeletes; // ========================================================================= // 상수 정의 diff --git a/app/Models/Tenants/Position.php b/app/Models/Tenants/Position.php index b9bdbd9..b81b305 100644 --- a/app/Models/Tenants/Position.php +++ b/app/Models/Tenants/Position.php @@ -2,6 +2,7 @@ namespace App\Models\Tenants; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -20,7 +21,7 @@ */ class Position extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $table = 'positions'; diff --git a/app/Models/Tenants/Purchase.php b/app/Models/Tenants/Purchase.php index 7d26e2e..320ffe0 100644 --- a/app/Models/Tenants/Purchase.php +++ b/app/Models/Tenants/Purchase.php @@ -2,6 +2,7 @@ namespace App\Models\Tenants; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -9,7 +10,7 @@ class Purchase extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Tenants/Receiving.php b/app/Models/Tenants/Receiving.php index e370ba1..e81a97a 100644 --- a/app/Models/Tenants/Receiving.php +++ b/app/Models/Tenants/Receiving.php @@ -2,6 +2,7 @@ namespace App\Models\Tenants; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -9,7 +10,7 @@ class Receiving extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Tenants/Salary.php b/app/Models/Tenants/Salary.php index 4352ec5..b1ec22d 100644 --- a/app/Models/Tenants/Salary.php +++ b/app/Models/Tenants/Salary.php @@ -3,6 +3,7 @@ namespace App\Models\Tenants; use App\Models\Members\User; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -34,7 +35,7 @@ */ class Salary extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $table = 'salaries'; diff --git a/app/Models/Tenants/Sale.php b/app/Models/Tenants/Sale.php index aa9577f..ac1ecd3 100644 --- a/app/Models/Tenants/Sale.php +++ b/app/Models/Tenants/Sale.php @@ -3,6 +3,7 @@ namespace App\Models\Tenants; use App\Models\Orders\Order; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -10,7 +11,7 @@ class Sale extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; /** * 매출 생성 시점 상수 diff --git a/app/Models/Tenants/Schedule.php b/app/Models/Tenants/Schedule.php index 4cd9cc3..60eb371 100644 --- a/app/Models/Tenants/Schedule.php +++ b/app/Models/Tenants/Schedule.php @@ -3,7 +3,7 @@ namespace App\Models\Tenants; use App\Models\Members\User; -use Illuminate\Database\Eloquent\Builder; +use App\Traits\Auditable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; @@ -16,7 +16,7 @@ */ class Schedule extends Model { - use SoftDeletes; + use Auditable, SoftDeletes; protected $table = 'schedules'; diff --git a/app/Models/Tenants/Shipment.php b/app/Models/Tenants/Shipment.php index 3bb9d3e..a02db8c 100644 --- a/app/Models/Tenants/Shipment.php +++ b/app/Models/Tenants/Shipment.php @@ -5,6 +5,7 @@ use App\Models\Orders\Order; use App\Models\Production\WorkOrder; use App\Models\Products\CommonCode; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -13,7 +14,7 @@ class Shipment extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Tenants/ShipmentItem.php b/app/Models/Tenants/ShipmentItem.php index 37a8c91..88d7eb8 100644 --- a/app/Models/Tenants/ShipmentItem.php +++ b/app/Models/Tenants/ShipmentItem.php @@ -2,6 +2,7 @@ namespace App\Models\Tenants; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -9,7 +10,7 @@ class ShipmentItem extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Tenants/Site.php b/app/Models/Tenants/Site.php index 7c175be..14f9ed0 100644 --- a/app/Models/Tenants/Site.php +++ b/app/Models/Tenants/Site.php @@ -4,6 +4,7 @@ use App\Models\Members\User; use App\Models\Orders\Client; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -30,7 +31,7 @@ */ class Site extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $table = 'sites'; diff --git a/app/Models/Tenants/SiteBriefing.php b/app/Models/Tenants/SiteBriefing.php index 187cc3e..68d2abf 100644 --- a/app/Models/Tenants/SiteBriefing.php +++ b/app/Models/Tenants/SiteBriefing.php @@ -5,6 +5,7 @@ use App\Models\Members\User; use App\Models\Orders\Client; use App\Models\Quote\Quote; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; @@ -45,7 +46,7 @@ */ class SiteBriefing extends Model { - use BelongsToTenant, ModelTrait, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $table = 'site_briefings'; diff --git a/app/Models/Tenants/Stock.php b/app/Models/Tenants/Stock.php index 0326499..0b73407 100644 --- a/app/Models/Tenants/Stock.php +++ b/app/Models/Tenants/Stock.php @@ -2,6 +2,7 @@ namespace App\Models\Tenants; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -10,7 +11,7 @@ class Stock extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Tenants/StockLot.php b/app/Models/Tenants/StockLot.php index f0c854e..7bd28e9 100644 --- a/app/Models/Tenants/StockLot.php +++ b/app/Models/Tenants/StockLot.php @@ -2,6 +2,7 @@ namespace App\Models\Tenants; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -9,7 +10,7 @@ class StockLot extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Tenants/Subscription.php b/app/Models/Tenants/Subscription.php index f826633..ad15a61 100644 --- a/app/Models/Tenants/Subscription.php +++ b/app/Models/Tenants/Subscription.php @@ -2,6 +2,7 @@ namespace App\Models\Tenants; +use App\Traits\Auditable; use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -25,7 +26,7 @@ */ class Subscription extends Model { - use SoftDeletes; + use Auditable, SoftDeletes; // ========================================================================= // 상수 정의 diff --git a/app/Models/Tenants/TaxInvoice.php b/app/Models/Tenants/TaxInvoice.php index 3f2ce19..95862e6 100644 --- a/app/Models/Tenants/TaxInvoice.php +++ b/app/Models/Tenants/TaxInvoice.php @@ -2,6 +2,7 @@ namespace App\Models\Tenants; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -10,7 +11,7 @@ class TaxInvoice extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; // ========================================================================= // 상수 정의 diff --git a/app/Models/Tenants/TenantUserProfile.php b/app/Models/Tenants/TenantUserProfile.php index ab9368a..1cf15a4 100644 --- a/app/Models/Tenants/TenantUserProfile.php +++ b/app/Models/Tenants/TenantUserProfile.php @@ -3,7 +3,7 @@ namespace App\Models\Tenants; use App\Models\Members\User; -use Illuminate\Database\Eloquent\Model; +use App\Traits\Auditable; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** @@ -20,6 +20,7 @@ */ class TenantUserProfile extends Model { + use Auditable; protected $casts = [ 'json_extra' => 'array', ]; diff --git a/app/Models/Tenants/Withdrawal.php b/app/Models/Tenants/Withdrawal.php index 082ef1a..ded2143 100644 --- a/app/Models/Tenants/Withdrawal.php +++ b/app/Models/Tenants/Withdrawal.php @@ -2,6 +2,7 @@ namespace App\Models\Tenants; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -9,7 +10,7 @@ class Withdrawal extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, SoftDeletes; protected $fillable = [ 'tenant_id', diff --git a/app/Models/UserInvitation.php b/app/Models/UserInvitation.php index 799f802..195919e 100644 --- a/app/Models/UserInvitation.php +++ b/app/Models/UserInvitation.php @@ -5,6 +5,7 @@ use App\Models\Members\User; use App\Models\Permissions\Role; use App\Models\Tenants\Tenant; +use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -12,7 +13,7 @@ class UserInvitation extends Model { - use BelongsToTenant; + use Auditable, BelongsToTenant; // 상태 상수 public const STATUS_PENDING = 'pending'; diff --git a/app/Traits/Auditable.php b/app/Traits/Auditable.php new file mode 100644 index 0000000..5f14cd8 --- /dev/null +++ b/app/Traits/Auditable.php @@ -0,0 +1,124 @@ +isFillable('created_by') && ! $model->created_by) { + $model->created_by = $actorId; + } + if ($model->isFillable('updated_by') && ! $model->updated_by) { + $model->updated_by = $actorId; + } + } + }); + + static::updating(function ($model) { + $actorId = static::resolveActorId(); + if ($actorId && $model->isFillable('updated_by')) { + $model->updated_by = $actorId; + } + }); + + static::deleting(function ($model) { + $actorId = static::resolveActorId(); + if ($actorId && $model->isFillable('deleted_by')) { + $model->deleted_by = $actorId; + $model->saveQuietly(); + } + }); + + static::created(function ($model) { + $model->logAuditEvent('created', null, $model->toAuditSnapshot()); + }); + + static::updated(function ($model) { + $dirty = $model->getChanges(); + $excluded = $model->getAuditExcludedFields(); + $changed = array_diff_key($dirty, array_flip($excluded)); + + if (empty($changed)) { + return; + } + + $before = []; + $after = []; + foreach ($changed as $key => $value) { + $before[$key] = $model->getOriginal($key); + $after[$key] = $value; + } + + $model->logAuditEvent('updated', $before, $after); + }); + + static::deleted(function ($model) { + $model->logAuditEvent('deleted', $model->toAuditSnapshot(), null); + }); + } + + public function getAuditExcludedFields(): array + { + $defaults = [ + 'created_at', 'updated_at', 'deleted_at', + 'created_by', 'updated_by', 'deleted_by', + ]; + + $custom = property_exists($this, 'auditExclude') ? $this->auditExclude : []; + + return array_merge($defaults, $custom); + } + + public function getAuditTargetType(): string + { + $className = class_basename(static::class); + + return Str::snake($className); + } + + protected function toAuditSnapshot(): array + { + $excluded = $this->getAuditExcludedFields(); + + return array_diff_key($this->attributesToArray(), array_flip($excluded)); + } + + protected function logAuditEvent(string $action, ?array $before, ?array $after): void + { + try { + $tenantId = $this->tenant_id ?? null; + if (! $tenantId) { + return; + } + + $request = request(); + + AuditLog::create([ + 'tenant_id' => $tenantId, + 'target_type' => $this->getAuditTargetType(), + 'target_id' => $this->getKey(), + 'action' => $action, + 'before' => $before, + 'after' => $after, + 'actor_id' => static::resolveActorId(), + 'ip' => $request?->ip(), + 'ua' => $request?->userAgent(), + 'created_at' => now(), + ]); + } catch (\Throwable $e) { + // 감사 로그 실패는 업무 흐름을 방해하지 않음 + } + } + + protected static function resolveActorId(): ?int + { + return auth()->id(); + } +} From 45a15fe64ff5f6884ecc6b5923fecd8cb3f59ba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 15:36:11 +0900 Subject: [PATCH 42/57] =?UTF-8?q?fix:=20BiddingController=20namespace=20?= =?UTF-8?q?=EB=8C=80=EC=86=8C=EB=AC=B8=EC=9E=90=20=EC=88=98=EC=A0=95=20(v1?= =?UTF-8?q?=20=E2=86=92=20V1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Linux 대소문자 구분으로 Swagger 문서 생성 실패 해결 Co-Authored-By: Claude Opus 4.5 --- app/Http/Controllers/Api/V1/BiddingController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/Api/V1/BiddingController.php b/app/Http/Controllers/Api/V1/BiddingController.php index 01a0051..f788117 100644 --- a/app/Http/Controllers/Api/V1/BiddingController.php +++ b/app/Http/Controllers/Api/V1/BiddingController.php @@ -1,6 +1,6 @@ Date: Thu, 29 Jan 2026 15:38:09 +0900 Subject: [PATCH 43/57] =?UTF-8?q?fix:=20TenantSettingController=20namespac?= =?UTF-8?q?e=20=EB=8C=80=EC=86=8C=EB=AC=B8=EC=9E=90=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(v1=20=E2=86=92=20V1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- app/Http/Controllers/Api/V1/TenantSettingController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/Api/V1/TenantSettingController.php b/app/Http/Controllers/Api/V1/TenantSettingController.php index bf06135..621afa6 100644 --- a/app/Http/Controllers/Api/V1/TenantSettingController.php +++ b/app/Http/Controllers/Api/V1/TenantSettingController.php @@ -1,6 +1,6 @@ Date: Thu, 29 Jan 2026 16:28:57 +0900 Subject: [PATCH 44/57] =?UTF-8?q?fix:=20TenantUserProfile=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20Eloquent=20Model=20import=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- app/Models/Tenants/TenantUserProfile.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Models/Tenants/TenantUserProfile.php b/app/Models/Tenants/TenantUserProfile.php index 1cf15a4..4d4763b 100644 --- a/app/Models/Tenants/TenantUserProfile.php +++ b/app/Models/Tenants/TenantUserProfile.php @@ -4,6 +4,7 @@ use App\Models\Members\User; use App\Traits\Auditable; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** From 4f2a329e4e6baf9193cbcd3d7cc5535fe487937c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 16:42:41 +0900 Subject: [PATCH 45/57] =?UTF-8?q?fix:=20=EC=B6=9C=EA=B8=88=20=EC=98=A4?= =?UTF-8?q?=EB=8A=98=EC=9D=98=20=EC=9D=B4=EC=8A=88=20path=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=20(1=EA=B1=B4:=20=EC=83=81=EC=84=B8,=202=EA=B1=B4+:?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- app/Services/TodayIssueObserverService.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/Services/TodayIssueObserverService.php b/app/Services/TodayIssueObserverService.php index 7ac892e..7f8bc4d 100644 --- a/app/Services/TodayIssueObserverService.php +++ b/app/Services/TodayIssueObserverService.php @@ -441,18 +441,20 @@ private function upsertWithdrawalDailyIssue(int $tenantId): void ?: $first->merchant_name ?: __('message.today_issue.unknown_client'); - // 1건이면 단건 메시지, 2건 이상이면 누계 메시지 + // 1건이면 단건 메시지+상세경로, 2건 이상이면 누계 메시지+목록경로 if ($count === 1) { $content = __('message.today_issue.withdrawal_registered', [ 'client' => $firstClientName, 'amount' => number_format($totalAmount), ]); + $path = "/accounting/withdrawals/{$first->id}"; } else { $content = __('message.today_issue.withdrawal_daily_summary', [ 'client' => $firstClientName, 'count' => $count - 1, 'amount' => number_format($totalAmount), ]); + $path = '/accounting/withdrawals'; } // source_id=null 로 일일 1건 upsert (FCM 미발송) @@ -462,7 +464,7 @@ private function upsertWithdrawalDailyIssue(int $tenantId): void sourceId: null, badge: TodayIssue::BADGE_WITHDRAWAL, content: $content, - path: '/accounting/withdrawals', + path: $path, needsApproval: false, expiresAt: Carbon::now()->addDays(7) ); From c88048db67b95d9e19b85d70d95187e0998dbead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 17:13:36 +0900 Subject: [PATCH 46/57] =?UTF-8?q?feat:=20sam=5Fstat=20=ED=86=B5=EA=B3=84?= =?UTF-8?q?=20DB=20=EC=9D=B8=ED=94=84=EB=9D=BC=20=EA=B5=AC=EC=B6=95=20(Pha?= =?UTF-8?q?se=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sam_stat DB 연결 설정 (config/database.php, .env) - 메타 테이블 마이그레이션 (stat_definitions, stat_job_logs) - dim_date 차원 테이블 + DimDateSeeder (2020~2030, 4018건) - 기반 모델: BaseStatModel, StatDefinition, StatJobLog, DimDate - 집계 커맨드: stat:aggregate-daily, stat:aggregate-monthly - StatAggregatorService + StatDomainServiceInterface 골격 Co-Authored-By: Claude Opus 4.5 --- .../Commands/StatAggregateDailyCommand.php | 60 ++++++ .../Commands/StatAggregateMonthlyCommand.php | 54 +++++ app/Models/Stats/BaseStatModel.php | 12 ++ app/Models/Stats/Dimensions/DimDate.php | 24 +++ app/Models/Stats/StatDefinition.php | 14 ++ app/Models/Stats/StatJobLog.php | 53 +++++ app/Services/Stats/StatAggregatorService.php | 187 ++++++++++++++++++ .../Stats/StatDomainServiceInterface.php | 22 +++ config/database.php | 20 ++ ...9_164541_create_stat_definitions_table.php | 32 +++ ...1_29_164542_create_stat_job_logs_table.php | 36 ++++ ...026_01_29_164742_create_dim_date_table.php | 33 ++++ database/seeders/DimDateSeeder.php | 81 ++++++++ 13 files changed, 628 insertions(+) create mode 100644 app/Console/Commands/StatAggregateDailyCommand.php create mode 100644 app/Console/Commands/StatAggregateMonthlyCommand.php create mode 100644 app/Models/Stats/BaseStatModel.php create mode 100644 app/Models/Stats/Dimensions/DimDate.php create mode 100644 app/Models/Stats/StatDefinition.php create mode 100644 app/Models/Stats/StatJobLog.php create mode 100644 app/Services/Stats/StatAggregatorService.php create mode 100644 app/Services/Stats/StatDomainServiceInterface.php create mode 100644 database/migrations/stats/2026_01_29_164541_create_stat_definitions_table.php create mode 100644 database/migrations/stats/2026_01_29_164542_create_stat_job_logs_table.php create mode 100644 database/migrations/stats/2026_01_29_164742_create_dim_date_table.php create mode 100644 database/seeders/DimDateSeeder.php diff --git a/app/Console/Commands/StatAggregateDailyCommand.php b/app/Console/Commands/StatAggregateDailyCommand.php new file mode 100644 index 0000000..0acaf10 --- /dev/null +++ b/app/Console/Commands/StatAggregateDailyCommand.php @@ -0,0 +1,60 @@ +option('date') + ? Carbon::parse($this->option('date')) + : Carbon::yesterday(); + + $domain = $this->option('domain'); + $tenantId = $this->option('tenant') ? (int) $this->option('tenant') : null; + + $this->info("📊 일간 통계 집계 시작: {$date->format('Y-m-d')}"); + + if ($domain) { + $this->info(" 도메인 필터: {$domain}"); + } + if ($tenantId) { + $this->info(" 테넌트 필터: {$tenantId}"); + } + + try { + $result = $aggregator->aggregateDaily($date, $domain, $tenantId); + + $this->info('✅ 일간 집계 완료:'); + $this->info(" - 처리 테넌트: {$result['tenants_processed']}"); + $this->info(" - 처리 도메인: {$result['domains_processed']}"); + $this->info(" - 소요 시간: {$result['duration_ms']}ms"); + + if (! empty($result['errors'])) { + $this->warn(' ⚠️ 에러 발생: '.count($result['errors']).'건'); + foreach ($result['errors'] as $error) { + $this->error(" - {$error}"); + } + + return self::FAILURE; + } + + return self::SUCCESS; + } catch (\Throwable $e) { + $this->error("❌ 집계 실패: {$e->getMessage()}"); + + return self::FAILURE; + } + } +} diff --git a/app/Console/Commands/StatAggregateMonthlyCommand.php b/app/Console/Commands/StatAggregateMonthlyCommand.php new file mode 100644 index 0000000..e942213 --- /dev/null +++ b/app/Console/Commands/StatAggregateMonthlyCommand.php @@ -0,0 +1,54 @@ +subMonth(); + $year = $this->option('year') ? (int) $this->option('year') : $lastMonth->year; + $month = $this->option('month') ? (int) $this->option('month') : $lastMonth->month; + + $domain = $this->option('domain'); + $tenantId = $this->option('tenant') ? (int) $this->option('tenant') : null; + + $this->info("📊 월간 통계 집계 시작: {$year}-".str_pad($month, 2, '0', STR_PAD_LEFT)); + + try { + $result = $aggregator->aggregateMonthly($year, $month, $domain, $tenantId); + + $this->info('✅ 월간 집계 완료:'); + $this->info(" - 처리 테넌트: {$result['tenants_processed']}"); + $this->info(" - 처리 도메인: {$result['domains_processed']}"); + $this->info(" - 소요 시간: {$result['duration_ms']}ms"); + + if (! empty($result['errors'])) { + $this->warn(' ⚠️ 에러 발생: '.count($result['errors']).'건'); + foreach ($result['errors'] as $error) { + $this->error(" - {$error}"); + } + + return self::FAILURE; + } + + return self::SUCCESS; + } catch (\Throwable $e) { + $this->error("❌ 집계 실패: {$e->getMessage()}"); + + return self::FAILURE; + } + } +} diff --git a/app/Models/Stats/BaseStatModel.php b/app/Models/Stats/BaseStatModel.php new file mode 100644 index 0000000..ddaa464 --- /dev/null +++ b/app/Models/Stats/BaseStatModel.php @@ -0,0 +1,12 @@ + 'date', + 'is_weekend' => 'boolean', + 'is_holiday' => 'boolean', + ]; +} diff --git a/app/Models/Stats/StatDefinition.php b/app/Models/Stats/StatDefinition.php new file mode 100644 index 0000000..fa290c0 --- /dev/null +++ b/app/Models/Stats/StatDefinition.php @@ -0,0 +1,14 @@ + 'array', + 'config' => 'array', + 'is_active' => 'boolean', + ]; +} diff --git a/app/Models/Stats/StatJobLog.php b/app/Models/Stats/StatJobLog.php new file mode 100644 index 0000000..c4d350a --- /dev/null +++ b/app/Models/Stats/StatJobLog.php @@ -0,0 +1,53 @@ + 'date', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'created_at' => 'datetime', + ]; + + public function markRunning(): void + { + $this->update([ + 'status' => 'running', + 'started_at' => now(), + ]); + } + + public function markCompleted(int $recordsProcessed = 0): void + { + $durationMs = $this->started_at + ? (int) now()->diffInMilliseconds($this->started_at) + : null; + + $this->update([ + 'status' => 'completed', + 'records_processed' => $recordsProcessed, + 'completed_at' => now(), + 'duration_ms' => $durationMs, + ]); + } + + public function markFailed(string $errorMessage): void + { + $durationMs = $this->started_at + ? (int) now()->diffInMilliseconds($this->started_at) + : null; + + $this->update([ + 'status' => 'failed', + 'error_message' => $errorMessage, + 'completed_at' => now(), + 'duration_ms' => $durationMs, + ]); + } +} diff --git a/app/Services/Stats/StatAggregatorService.php b/app/Services/Stats/StatAggregatorService.php new file mode 100644 index 0000000..f87fd08 --- /dev/null +++ b/app/Services/Stats/StatAggregatorService.php @@ -0,0 +1,187 @@ + SalesStatService::class, + // 'finance' => FinanceStatService::class, + // 'production' => ProductionStatService::class, + ]; + } + + /** + * 월간 도메인 서비스 매핑 + */ + private function getMonthlyDomainServices(): array + { + return [ + // 'sales' => SalesStatService::class, + // 'finance' => FinanceStatService::class, + // 'production' => ProductionStatService::class, + ]; + } + + /** + * 일간 통계 집계 + */ + public function aggregateDaily(Carbon $date, ?string $domain = null, ?int $tenantId = null): array + { + $startTime = microtime(true); + $errors = []; + $tenantsProcessed = 0; + $domainsProcessed = 0; + + $tenants = $this->getTargetTenants($tenantId); + $services = $this->getDailyDomainServices(); + + if ($domain) { + $services = array_intersect_key($services, [$domain => true]); + } + + foreach ($tenants as $tenant) { + foreach ($services as $domainKey => $serviceClass) { + $jobLog = $this->createJobLog($tenant->id, "{$domainKey}_daily", $date); + + try { + $jobLog->markRunning(); + + /** @var StatDomainServiceInterface $service */ + $service = app($serviceClass); + $recordCount = $service->aggregateDaily($tenant->id, $date); + + $jobLog->markCompleted($recordCount); + $domainsProcessed++; + } catch (\Throwable $e) { + $errorMsg = "[{$domainKey}] tenant={$tenant->id}: {$e->getMessage()}"; + $errors[] = $errorMsg; + $jobLog->markFailed($e->getMessage()); + + Log::error('stat:aggregate-daily 실패', [ + 'domain' => $domainKey, + 'tenant_id' => $tenant->id, + 'date' => $date->format('Y-m-d'), + 'error' => $e->getMessage(), + ]); + } + } + $tenantsProcessed++; + } + + $durationMs = (int) ((microtime(true) - $startTime) * 1000); + + return [ + 'tenants_processed' => $tenantsProcessed, + 'domains_processed' => $domainsProcessed, + 'errors' => $errors, + 'duration_ms' => $durationMs, + ]; + } + + /** + * 월간 통계 집계 + */ + public function aggregateMonthly(int $year, int $month, ?string $domain = null, ?int $tenantId = null): array + { + $startTime = microtime(true); + $errors = []; + $tenantsProcessed = 0; + $domainsProcessed = 0; + + $tenants = $this->getTargetTenants($tenantId); + $services = $this->getMonthlyDomainServices(); + + if ($domain) { + $services = array_intersect_key($services, [$domain => true]); + } + + $targetDate = Carbon::create($year, $month, 1); + + foreach ($tenants as $tenant) { + foreach ($services as $domainKey => $serviceClass) { + $jobLog = $this->createJobLog($tenant->id, "{$domainKey}_monthly", $targetDate); + + try { + $jobLog->markRunning(); + + /** @var StatDomainServiceInterface $service */ + $service = app($serviceClass); + $recordCount = $service->aggregateMonthly($tenant->id, $year, $month); + + $jobLog->markCompleted($recordCount); + $domainsProcessed++; + } catch (\Throwable $e) { + $errorMsg = "[{$domainKey}] tenant={$tenant->id}: {$e->getMessage()}"; + $errors[] = $errorMsg; + $jobLog->markFailed($e->getMessage()); + + Log::error('stat:aggregate-monthly 실패', [ + 'domain' => $domainKey, + 'tenant_id' => $tenant->id, + 'year' => $year, + 'month' => $month, + 'error' => $e->getMessage(), + ]); + } + } + $tenantsProcessed++; + } + + $durationMs = (int) ((microtime(true) - $startTime) * 1000); + + return [ + 'tenants_processed' => $tenantsProcessed, + 'domains_processed' => $domainsProcessed, + 'errors' => $errors, + 'duration_ms' => $durationMs, + ]; + } + + /** + * 대상 테넌트 목록 조회 + */ + private function getTargetTenants(?int $tenantId): \Illuminate\Database\Eloquent\Collection + { + $query = Tenant::query()->where('tenant_st_code', '!=', 'none'); + + if ($tenantId) { + $query->where('id', $tenantId); + } + + return $query->get(); + } + + /** + * 집계 작업 로그 생성 (멱등: 동일 조건이면 기존 레코드 재사용) + */ + private function createJobLog(int $tenantId, string $jobType, Carbon $targetDate): StatJobLog + { + return StatJobLog::updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'job_type' => $jobType, + 'target_date' => $targetDate->format('Y-m-d'), + ], + [ + 'status' => 'pending', + 'records_processed' => 0, + 'error_message' => null, + 'started_at' => null, + 'completed_at' => null, + 'duration_ms' => null, + 'created_at' => now(), + ] + ); + } +} diff --git a/app/Services/Stats/StatDomainServiceInterface.php b/app/Services/Stats/StatDomainServiceInterface.php new file mode 100644 index 0000000..9af1449 --- /dev/null +++ b/app/Services/Stats/StatDomainServiceInterface.php @@ -0,0 +1,22 @@ + [ + 'driver' => 'mysql', + 'host' => env('STAT_DB_HOST', env('DB_HOST', '127.0.0.1')), + 'port' => env('STAT_DB_PORT', env('DB_PORT', '3306')), + 'database' => env('STAT_DB_DATABASE', 'sam_stat'), + 'username' => env('STAT_DB_USERNAME', env('DB_USERNAME', 'root')), + 'password' => env('STAT_DB_PASSWORD', env('DB_PASSWORD', '')), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + // 5130 레거시 DB (chandj) 'chandj' => [ 'driver' => 'mysql', diff --git a/database/migrations/stats/2026_01_29_164541_create_stat_definitions_table.php b/database/migrations/stats/2026_01_29_164541_create_stat_definitions_table.php new file mode 100644 index 0000000..d08a621 --- /dev/null +++ b/database/migrations/stats/2026_01_29_164541_create_stat_definitions_table.php @@ -0,0 +1,32 @@ +connection)->create('stat_definitions', function (Blueprint $table) { + $table->id(); + $table->string('code', 100)->unique()->comment('통계 코드 (sales_daily_revenue)'); + $table->string('domain', 50)->index()->comment('도메인 (sales, finance, production)'); + $table->string('name', 200)->comment('통계명 (일일 매출액)'); + $table->text('description')->nullable(); + $table->json('source_tables')->comment('원본 테이블 목록'); + $table->string('aggregation', 20)->default('daily')->index()->comment('집계 주기'); + $table->text('query_template')->nullable()->comment('집계 SQL 템플릿'); + $table->boolean('is_active')->default(true)->index(); + $table->json('config')->nullable()->comment('추가 설정 (임계값, 단위 등)'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_definitions'); + } +}; diff --git a/database/migrations/stats/2026_01_29_164542_create_stat_job_logs_table.php b/database/migrations/stats/2026_01_29_164542_create_stat_job_logs_table.php new file mode 100644 index 0000000..aa6040f --- /dev/null +++ b/database/migrations/stats/2026_01_29_164542_create_stat_job_logs_table.php @@ -0,0 +1,36 @@ +connection)->create('stat_job_logs', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->string('job_type', 100)->comment('작업 유형 (sales_daily, finance_monthly)'); + $table->date('target_date')->comment('집계 대상 날짜'); + $table->enum('status', ['pending', 'running', 'completed', 'failed'])->default('pending'); + $table->unsignedInteger('records_processed')->default(0); + $table->text('error_message')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->unsignedInteger('duration_ms')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index(['tenant_id', 'job_type']); + $table->index('status'); + $table->index('target_date'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_job_logs'); + } +}; diff --git a/database/migrations/stats/2026_01_29_164742_create_dim_date_table.php b/database/migrations/stats/2026_01_29_164742_create_dim_date_table.php new file mode 100644 index 0000000..2da3326 --- /dev/null +++ b/database/migrations/stats/2026_01_29_164742_create_dim_date_table.php @@ -0,0 +1,33 @@ +connection)->create('dim_date', function (Blueprint $table) { + $table->date('date_key')->primary()->comment('날짜 키'); + $table->smallInteger('year')->comment('연도'); + $table->tinyInteger('quarter')->comment('분기 (1~4)'); + $table->tinyInteger('month')->comment('월'); + $table->tinyInteger('week')->comment('ISO 주차'); + $table->tinyInteger('day_of_week')->comment('요일 (1:월~7:일)'); + $table->tinyInteger('day_of_month')->comment('일'); + $table->boolean('is_weekend')->comment('주말 여부'); + $table->boolean('is_holiday')->default(false)->comment('공휴일 여부'); + $table->string('holiday_name', 100)->nullable()->comment('공휴일명'); + $table->smallInteger('fiscal_year')->nullable()->comment('회계연도'); + $table->tinyInteger('fiscal_quarter')->nullable()->comment('회계분기'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('dim_date'); + } +}; diff --git a/database/seeders/DimDateSeeder.php b/database/seeders/DimDateSeeder.php new file mode 100644 index 0000000..036b033 --- /dev/null +++ b/database/seeders/DimDateSeeder.php @@ -0,0 +1,81 @@ +getKoreanHolidays(); + + $batch = []; + $batchSize = 500; + + foreach ($period as $date) { + $dateStr = $date->format('Y-m-d'); + $holiday = $holidays[$dateStr] ?? null; + + $batch[] = [ + 'date_key' => $dateStr, + 'year' => $date->year, + 'quarter' => $date->quarter, + 'month' => $date->month, + 'week' => (int) $date->isoWeek(), + 'day_of_week' => $date->dayOfWeekIso, + 'day_of_month' => $date->day, + 'is_weekend' => $date->isWeekend(), + 'is_holiday' => $holiday !== null, + 'holiday_name' => $holiday, + 'fiscal_year' => $date->year, + 'fiscal_quarter' => $date->quarter, + ]; + + if (count($batch) >= $batchSize) { + DB::connection('sam_stat')->table('dim_date')->insert($batch); + $batch = []; + } + } + + if (! empty($batch)) { + DB::connection('sam_stat')->table('dim_date')->insert($batch); + } + + $totalCount = DB::connection('sam_stat')->table('dim_date')->count(); + $this->command->info("dim_date 시딩 완료: {$totalCount}건 (2020-01-01 ~ 2030-12-31)"); + } + + /** + * 한국 공휴일 목록 (고정 공휴일만, 음력 공휴일은 수동 추가 필요) + */ + private function getKoreanHolidays(): array + { + $holidays = []; + + for ($year = 2020; $year <= 2030; $year++) { + // 고정 공휴일 + $fixed = [ + "{$year}-01-01" => '신정', + "{$year}-03-01" => '삼일절', + "{$year}-05-05" => '어린이날', + "{$year}-06-06" => '현충일', + "{$year}-08-15" => '광복절', + "{$year}-10-03" => '개천절', + "{$year}-10-09" => '한글날', + "{$year}-12-25" => '크리스마스', + ]; + + $holidays = array_merge($holidays, $fixed); + } + + return $holidays; + } +} From e882d33de1663b4485cd64adda7d6020e32ed9ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 19:30:30 +0900 Subject: [PATCH 47/57] =?UTF-8?q?feat:=20sam=5Fstat=20P0=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=A7=91=EA=B3=84=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(Phase=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 영업(Sales), 재무(Finance), 생산(Production) 3개 도메인 구현 - 일간/월간 통계 테이블 6개 마이그레이션 생성 - 도메인별 StatService (SalesStatService, FinanceStatService, ProductionStatService) - Daily/Monthly 6개 Eloquent 모델 생성 - StatAggregatorService에 도메인 서비스 매핑 활성화 - StatJobLog duration_ms abs() 처리 - 스케줄러 등록 (일간 02:00, 월간 1일 03:00) Co-Authored-By: Claude Opus 4.5 --- app/Models/Stats/Daily/StatFinanceDaily.php | 26 +++ .../Stats/Daily/StatProductionDaily.php | 21 ++ app/Models/Stats/Daily/StatSalesDaily.php | 18 ++ .../Stats/Monthly/StatFinanceMonthly.php | 22 ++ .../Stats/Monthly/StatProductionMonthly.php | 20 ++ app/Models/Stats/Monthly/StatSalesMonthly.php | 20 ++ app/Models/Stats/StatJobLog.php | 4 +- app/Services/Stats/FinanceStatService.php | 172 ++++++++++++++++ app/Services/Stats/ProductionStatService.php | 149 ++++++++++++++ app/Services/Stats/SalesStatService.php | 192 ++++++++++++++++++ app/Services/Stats/StatAggregatorService.php | 14 +- ...9_174339_create_stat_sales_daily_table.php | 55 +++++ ...174340_create_stat_finance_daily_table.php | 59 ++++++ ...4340_create_stat_finance_monthly_table.php | 42 ++++ ...174340_create_stat_sales_monthly_table.php | 46 +++++ ...341_create_stat_production_daily_table.php | 54 +++++ ...1_create_stat_production_monthly_table.php | 41 ++++ routes/console.php | 26 +++ 18 files changed, 972 insertions(+), 9 deletions(-) create mode 100644 app/Models/Stats/Daily/StatFinanceDaily.php create mode 100644 app/Models/Stats/Daily/StatProductionDaily.php create mode 100644 app/Models/Stats/Daily/StatSalesDaily.php create mode 100644 app/Models/Stats/Monthly/StatFinanceMonthly.php create mode 100644 app/Models/Stats/Monthly/StatProductionMonthly.php create mode 100644 app/Models/Stats/Monthly/StatSalesMonthly.php create mode 100644 app/Services/Stats/FinanceStatService.php create mode 100644 app/Services/Stats/ProductionStatService.php create mode 100644 app/Services/Stats/SalesStatService.php create mode 100644 database/migrations/stats/2026_01_29_174339_create_stat_sales_daily_table.php create mode 100644 database/migrations/stats/2026_01_29_174340_create_stat_finance_daily_table.php create mode 100644 database/migrations/stats/2026_01_29_174340_create_stat_finance_monthly_table.php create mode 100644 database/migrations/stats/2026_01_29_174340_create_stat_sales_monthly_table.php create mode 100644 database/migrations/stats/2026_01_29_174341_create_stat_production_daily_table.php create mode 100644 database/migrations/stats/2026_01_29_174341_create_stat_production_monthly_table.php diff --git a/app/Models/Stats/Daily/StatFinanceDaily.php b/app/Models/Stats/Daily/StatFinanceDaily.php new file mode 100644 index 0000000..033df9e --- /dev/null +++ b/app/Models/Stats/Daily/StatFinanceDaily.php @@ -0,0 +1,26 @@ + 'date', + 'deposit_amount' => 'decimal:2', + 'withdrawal_amount' => 'decimal:2', + 'net_cashflow' => 'decimal:2', + 'purchase_amount' => 'decimal:2', + 'purchase_tax_amount' => 'decimal:2', + 'receivable_balance' => 'decimal:2', + 'payable_balance' => 'decimal:2', + 'overdue_receivable' => 'decimal:2', + 'bill_issued_amount' => 'decimal:2', + 'bill_matured_amount' => 'decimal:2', + 'card_transaction_amount' => 'decimal:2', + 'bank_balance_total' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/Daily/StatProductionDaily.php b/app/Models/Stats/Daily/StatProductionDaily.php new file mode 100644 index 0000000..9674a4c --- /dev/null +++ b/app/Models/Stats/Daily/StatProductionDaily.php @@ -0,0 +1,21 @@ + 'date', + 'production_qty' => 'decimal:2', + 'defect_qty' => 'decimal:2', + 'defect_rate' => 'decimal:2', + 'planned_hours' => 'decimal:2', + 'actual_hours' => 'decimal:2', + 'efficiency_rate' => 'decimal:2', + 'delivery_rate' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/Daily/StatSalesDaily.php b/app/Models/Stats/Daily/StatSalesDaily.php new file mode 100644 index 0000000..6fe01bd --- /dev/null +++ b/app/Models/Stats/Daily/StatSalesDaily.php @@ -0,0 +1,18 @@ + 'date', + 'order_amount' => 'decimal:2', + 'sales_amount' => 'decimal:2', + 'sales_tax_amount' => 'decimal:2', + 'shipment_amount' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/Monthly/StatFinanceMonthly.php b/app/Models/Stats/Monthly/StatFinanceMonthly.php new file mode 100644 index 0000000..031085b --- /dev/null +++ b/app/Models/Stats/Monthly/StatFinanceMonthly.php @@ -0,0 +1,22 @@ + 'decimal:2', + 'withdrawal_total' => 'decimal:2', + 'net_cashflow' => 'decimal:2', + 'purchase_total' => 'decimal:2', + 'card_total' => 'decimal:2', + 'receivable_end' => 'decimal:2', + 'payable_end' => 'decimal:2', + 'bank_balance_end' => 'decimal:2', + 'mom_cashflow_change' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/Monthly/StatProductionMonthly.php b/app/Models/Stats/Monthly/StatProductionMonthly.php new file mode 100644 index 0000000..86c33eb --- /dev/null +++ b/app/Models/Stats/Monthly/StatProductionMonthly.php @@ -0,0 +1,20 @@ + 'decimal:2', + 'defect_qty' => 'decimal:2', + 'avg_defect_rate' => 'decimal:2', + 'avg_efficiency_rate' => 'decimal:2', + 'avg_delivery_rate' => 'decimal:2', + 'total_planned_hours' => 'decimal:2', + 'total_actual_hours' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/Monthly/StatSalesMonthly.php b/app/Models/Stats/Monthly/StatSalesMonthly.php new file mode 100644 index 0000000..49a846e --- /dev/null +++ b/app/Models/Stats/Monthly/StatSalesMonthly.php @@ -0,0 +1,20 @@ + 'decimal:2', + 'sales_amount' => 'decimal:2', + 'shipment_amount' => 'decimal:2', + 'avg_order_amount' => 'decimal:2', + 'top_client_amount' => 'decimal:2', + 'mom_growth_rate' => 'decimal:2', + 'yoy_growth_rate' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/StatJobLog.php b/app/Models/Stats/StatJobLog.php index c4d350a..b0f79b4 100644 --- a/app/Models/Stats/StatJobLog.php +++ b/app/Models/Stats/StatJobLog.php @@ -26,7 +26,7 @@ public function markRunning(): void public function markCompleted(int $recordsProcessed = 0): void { $durationMs = $this->started_at - ? (int) now()->diffInMilliseconds($this->started_at) + ? abs((int) now()->diffInMilliseconds($this->started_at)) : null; $this->update([ @@ -40,7 +40,7 @@ public function markCompleted(int $recordsProcessed = 0): void public function markFailed(string $errorMessage): void { $durationMs = $this->started_at - ? (int) now()->diffInMilliseconds($this->started_at) + ? abs((int) now()->diffInMilliseconds($this->started_at)) : null; $this->update([ diff --git a/app/Services/Stats/FinanceStatService.php b/app/Services/Stats/FinanceStatService.php new file mode 100644 index 0000000..376f959 --- /dev/null +++ b/app/Services/Stats/FinanceStatService.php @@ -0,0 +1,172 @@ +format('Y-m-d'); + + // 입금 (deposits) + $depositStats = DB::connection('mysql') + ->table('deposits') + ->where('tenant_id', $tenantId) + ->where('deposit_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw('COUNT(*) as cnt, COALESCE(SUM(amount), 0) as total') + ->first(); + + // 출금 (withdrawals) + $withdrawalStats = DB::connection('mysql') + ->table('withdrawals') + ->where('tenant_id', $tenantId) + ->where('withdrawal_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw('COUNT(*) as cnt, COALESCE(SUM(amount), 0) as total') + ->first(); + + // 매입 (purchases) + $purchaseStats = DB::connection('mysql') + ->table('purchases') + ->where('tenant_id', $tenantId) + ->where('purchase_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw(' + COUNT(*) as cnt, + COALESCE(SUM(supply_amount), 0) as supply_total, + COALESCE(SUM(tax_amount), 0) as tax_total + ') + ->first(); + + // 어음 발행 (bills - issued on this date) + $billIssuedStats = DB::connection('mysql') + ->table('bills') + ->where('tenant_id', $tenantId) + ->where('issue_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw('COUNT(*) as cnt, COALESCE(SUM(amount), 0) as total') + ->first(); + + // 어음 만기 (bills - matured on this date) + $billMaturedStats = DB::connection('mysql') + ->table('bills') + ->where('tenant_id', $tenantId) + ->where('maturity_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw('COUNT(*) as cnt, COALESCE(SUM(amount), 0) as total') + ->first(); + + // 카드 거래 (withdrawals with card_id) + $cardStats = DB::connection('mysql') + ->table('withdrawals') + ->where('tenant_id', $tenantId) + ->where('withdrawal_date', $dateStr) + ->whereNotNull('card_id') + ->whereNull('deleted_at') + ->selectRaw('COUNT(*) as cnt, COALESCE(SUM(amount), 0) as total') + ->first(); + + // 은행 잔액 합계 (bank_transactions - 계좌별 최신 잔액) + $bankBalance = DB::connection('mysql') + ->query() + ->fromSub(function ($query) use ($tenantId, $dateStr) { + $query->from('bank_transactions') + ->where('tenant_id', $tenantId) + ->where('transaction_date', '<=', $dateStr) + ->whereNull('deleted_at') + ->selectRaw('bank_account_id, balance_after as latest_balance, + ROW_NUMBER() OVER(PARTITION BY bank_account_id ORDER BY transaction_date DESC, id DESC) as rn'); + }, 'sub') + ->where('rn', 1) + ->selectRaw('COALESCE(SUM(latest_balance), 0) as total_balance') + ->first(); + + $depositAmount = $depositStats->total ?? 0; + $withdrawalAmount = $withdrawalStats->total ?? 0; + + StatFinanceDaily::updateOrCreate( + ['tenant_id' => $tenantId, 'stat_date' => $dateStr], + [ + 'deposit_count' => $depositStats->cnt ?? 0, + 'deposit_amount' => $depositAmount, + 'withdrawal_count' => $withdrawalStats->cnt ?? 0, + 'withdrawal_amount' => $withdrawalAmount, + 'net_cashflow' => $depositAmount - $withdrawalAmount, + 'purchase_count' => $purchaseStats->cnt ?? 0, + 'purchase_amount' => $purchaseStats->supply_total ?? 0, + 'purchase_tax_amount' => $purchaseStats->tax_total ?? 0, + 'receivable_balance' => 0, // Phase 3에서 미수금 로직 추가 + 'payable_balance' => 0, + 'overdue_receivable' => 0, + 'bill_issued_count' => $billIssuedStats->cnt ?? 0, + 'bill_issued_amount' => $billIssuedStats->total ?? 0, + 'bill_matured_count' => $billMaturedStats->cnt ?? 0, + 'bill_matured_amount' => $billMaturedStats->total ?? 0, + 'card_transaction_count' => $cardStats->cnt ?? 0, + 'card_transaction_amount' => $cardStats->total ?? 0, + 'bank_balance_total' => $bankBalance->total_balance ?? 0, + ] + ); + + return 1; + } + + public function aggregateMonthly(int $tenantId, int $year, int $month): int + { + $dailySum = StatFinanceDaily::where('tenant_id', $tenantId) + ->whereYear('stat_date', $year) + ->whereMonth('stat_date', $month) + ->selectRaw(' + SUM(deposit_amount) as deposit_total, + SUM(withdrawal_amount) as withdrawal_total, + SUM(net_cashflow) as net_cashflow, + SUM(purchase_amount) as purchase_total, + SUM(card_transaction_amount) as card_total + ') + ->first(); + + // 월말 데이터 (해당 월의 마지막 일간 레코드) + $lastDay = StatFinanceDaily::where('tenant_id', $tenantId) + ->whereYear('stat_date', $year) + ->whereMonth('stat_date', $month) + ->orderByDesc('stat_date') + ->first(); + + // 전월 대비 현금흐름 변화 + $prevMonth = StatFinanceMonthly::where('tenant_id', $tenantId) + ->where(function ($q) use ($year, $month) { + $prev = Carbon::create($year, $month, 1)->subMonth(); + $q->where('stat_year', $prev->year)->where('stat_month', $prev->month); + }) + ->first(); + + $netCashflow = $dailySum->net_cashflow ?? 0; + $momChange = null; + if ($prevMonth && $prevMonth->net_cashflow != 0) { + $momChange = (($netCashflow - $prevMonth->net_cashflow) / abs($prevMonth->net_cashflow)) * 100; + } + + StatFinanceMonthly::updateOrCreate( + ['tenant_id' => $tenantId, 'stat_year' => $year, 'stat_month' => $month], + [ + 'deposit_total' => $dailySum->deposit_total ?? 0, + 'withdrawal_total' => $dailySum->withdrawal_total ?? 0, + 'net_cashflow' => $netCashflow, + 'purchase_total' => $dailySum->purchase_total ?? 0, + 'card_total' => $dailySum->card_total ?? 0, + 'receivable_end' => $lastDay->receivable_balance ?? 0, + 'payable_end' => $lastDay->payable_balance ?? 0, + 'bank_balance_end' => $lastDay->bank_balance_total ?? 0, + 'mom_cashflow_change' => $momChange, + ] + ); + + return 1; + } +} diff --git a/app/Services/Stats/ProductionStatService.php b/app/Services/Stats/ProductionStatService.php new file mode 100644 index 0000000..eb48b84 --- /dev/null +++ b/app/Services/Stats/ProductionStatService.php @@ -0,0 +1,149 @@ +format('Y-m-d'); + + // 작업지시 (work_orders) + $woCreated = DB::connection('mysql') + ->table('work_orders') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->whereNull('deleted_at') + ->count(); + + $woCompleted = DB::connection('mysql') + ->table('work_orders') + ->where('tenant_id', $tenantId) + ->whereDate('completed_at', $dateStr) + ->whereNull('deleted_at') + ->count(); + + $woInProgress = DB::connection('mysql') + ->table('work_orders') + ->where('tenant_id', $tenantId) + ->where('status', 'in_progress') + ->whereNull('deleted_at') + ->count(); + + // 납기 초과 (scheduled_date < today && status not completed/shipped) + $woOverdue = DB::connection('mysql') + ->table('work_orders') + ->where('tenant_id', $tenantId) + ->where('scheduled_date', '<', $dateStr) + ->whereNotIn('status', ['completed', 'shipped', 'cancelled']) + ->whereNull('deleted_at') + ->count(); + + // 생산 실적 (work_results) + $productionStats = DB::connection('mysql') + ->table('work_results') + ->where('tenant_id', $tenantId) + ->where('work_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw(' + COALESCE(SUM(production_qty), 0) as production_qty, + COALESCE(SUM(defect_qty), 0) as defect_qty, + COUNT(DISTINCT worker_id) as active_worker_count + ') + ->first(); + + $productionQty = $productionStats->production_qty ?? 0; + $defectQty = $productionStats->defect_qty ?? 0; + $defectRate = $productionQty > 0 ? ($defectQty / $productionQty) * 100 : 0; + + // 납기 준수 (당일 완료된 작업지시 중 scheduled_date >= completed_at인 것) + $onTimeCount = DB::connection('mysql') + ->table('work_orders') + ->where('tenant_id', $tenantId) + ->whereDate('completed_at', $dateStr) + ->whereNull('deleted_at') + ->whereRaw('DATE(completed_at) <= scheduled_date') + ->count(); + + $lateCount = $woCompleted - $onTimeCount; + $deliveryRate = $woCompleted > 0 ? ($onTimeCount / $woCompleted) * 100 : 0; + + StatProductionDaily::updateOrCreate( + ['tenant_id' => $tenantId, 'stat_date' => $dateStr], + [ + 'wo_created_count' => $woCreated, + 'wo_completed_count' => $woCompleted, + 'wo_in_progress_count' => $woInProgress, + 'wo_overdue_count' => $woOverdue, + 'production_qty' => $productionQty, + 'defect_qty' => $defectQty, + 'defect_rate' => round($defectRate, 2), + 'planned_hours' => 0, // 계획 공수 필드 없음 - 추후 확장 + 'actual_hours' => 0, + 'efficiency_rate' => 0, + 'active_worker_count' => $productionStats->active_worker_count ?? 0, + 'issue_count' => 0, // work_order_issues 테이블 확인 필요 + 'on_time_delivery_count' => $onTimeCount, + 'late_delivery_count' => max(0, $lateCount), + 'delivery_rate' => round($deliveryRate, 2), + ] + ); + + return 1; + } + + public function aggregateMonthly(int $tenantId, int $year, int $month): int + { + $dailyData = StatProductionDaily::where('tenant_id', $tenantId) + ->whereYear('stat_date', $year) + ->whereMonth('stat_date', $month) + ->selectRaw(' + SUM(wo_created_count) as wo_total_count, + SUM(wo_completed_count) as wo_completed_count, + SUM(production_qty) as production_qty, + SUM(defect_qty) as defect_qty, + SUM(planned_hours) as total_planned_hours, + SUM(actual_hours) as total_actual_hours, + SUM(issue_count) as issue_total_count + ') + ->first(); + + $productionQty = $dailyData->production_qty ?? 0; + $defectQty = $dailyData->defect_qty ?? 0; + $avgDefectRate = $productionQty > 0 ? ($defectQty / $productionQty) * 100 : 0; + + // 월평균 효율/납기 (일간 데이터의 평균) + $avgRates = StatProductionDaily::where('tenant_id', $tenantId) + ->whereYear('stat_date', $year) + ->whereMonth('stat_date', $month) + ->where('wo_completed_count', '>', 0) + ->selectRaw(' + AVG(efficiency_rate) as avg_efficiency_rate, + AVG(delivery_rate) as avg_delivery_rate + ') + ->first(); + + StatProductionMonthly::updateOrCreate( + ['tenant_id' => $tenantId, 'stat_year' => $year, 'stat_month' => $month], + [ + 'wo_total_count' => $dailyData->wo_total_count ?? 0, + 'wo_completed_count' => $dailyData->wo_completed_count ?? 0, + 'production_qty' => $productionQty, + 'defect_qty' => $defectQty, + 'avg_defect_rate' => round($avgDefectRate, 2), + 'avg_efficiency_rate' => round($avgRates->avg_efficiency_rate ?? 0, 2), + 'avg_delivery_rate' => round($avgRates->avg_delivery_rate ?? 0, 2), + 'total_planned_hours' => $dailyData->total_planned_hours ?? 0, + 'total_actual_hours' => $dailyData->total_actual_hours ?? 0, + 'issue_total_count' => $dailyData->issue_total_count ?? 0, + ] + ); + + return 1; + } +} diff --git a/app/Services/Stats/SalesStatService.php b/app/Services/Stats/SalesStatService.php new file mode 100644 index 0000000..e3654a1 --- /dev/null +++ b/app/Services/Stats/SalesStatService.php @@ -0,0 +1,192 @@ +format('Y-m-d'); + + // 수주 집계 (orders) + $orderStats = DB::connection('mysql') + ->table('orders') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->whereNull('deleted_at') + ->selectRaw(' + COUNT(*) as order_count, + COALESCE(SUM(total_amount), 0) as order_amount, + COALESCE(SUM(quantity), 0) as order_item_count, + SUM(CASE WHEN status_code = ? THEN 1 ELSE 0 END) as order_draft_count, + SUM(CASE WHEN status_code = ? THEN 1 ELSE 0 END) as order_confirmed_count, + SUM(CASE WHEN status_code = ? THEN 1 ELSE 0 END) as order_in_progress_count, + SUM(CASE WHEN status_code = ? THEN 1 ELSE 0 END) as order_completed_count, + SUM(CASE WHEN status_code = ? THEN 1 ELSE 0 END) as order_cancelled_count + ', ['draft', 'confirmed', 'in_progress', 'completed', 'cancelled']) + ->first(); + + // 매출 집계 (sales) + $salesStats = DB::connection('mysql') + ->table('sales') + ->where('tenant_id', $tenantId) + ->where('sale_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw(' + COUNT(*) as sales_count, + COALESCE(SUM(supply_amount), 0) as sales_amount, + COALESCE(SUM(tax_amount), 0) as sales_tax_amount + ') + ->first(); + + // 신규 고객 (당일 생성된 고객) + $newClientCount = DB::connection('mysql') + ->table('clients') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->count(); + + // 활성 고객 (당일 수주/매출에 연결된 고유 고객) + $activeClientCount = DB::connection('mysql') + ->table('orders') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->whereNull('deleted_at') + ->whereNotNull('client_id') + ->distinct('client_id') + ->count('client_id'); + + // 출하 집계 (shipments) + $shipmentStats = DB::connection('mysql') + ->table('shipments') + ->where('tenant_id', $tenantId) + ->where('scheduled_date', $dateStr) + ->where('status', 'completed') + ->whereNull('deleted_at') + ->selectRaw(' + COUNT(*) as shipment_count, + COALESCE(SUM(shipping_cost), 0) as shipment_amount + ') + ->first(); + + StatSalesDaily::updateOrCreate( + ['tenant_id' => $tenantId, 'stat_date' => $dateStr], + [ + 'order_count' => $orderStats->order_count ?? 0, + 'order_amount' => $orderStats->order_amount ?? 0, + 'order_item_count' => $orderStats->order_item_count ?? 0, + 'sales_count' => $salesStats->sales_count ?? 0, + 'sales_amount' => $salesStats->sales_amount ?? 0, + 'sales_tax_amount' => $salesStats->sales_tax_amount ?? 0, + 'new_client_count' => $newClientCount, + 'active_client_count' => $activeClientCount, + 'order_draft_count' => $orderStats->order_draft_count ?? 0, + 'order_confirmed_count' => $orderStats->order_confirmed_count ?? 0, + 'order_in_progress_count' => $orderStats->order_in_progress_count ?? 0, + 'order_completed_count' => $orderStats->order_completed_count ?? 0, + 'order_cancelled_count' => $orderStats->order_cancelled_count ?? 0, + 'shipment_count' => $shipmentStats->shipment_count ?? 0, + 'shipment_amount' => $shipmentStats->shipment_amount ?? 0, + ] + ); + + return 1; + } + + public function aggregateMonthly(int $tenantId, int $year, int $month): int + { + // 일간 데이터를 합산하여 월간 통계 생성 + $dailySum = StatSalesDaily::where('tenant_id', $tenantId) + ->whereYear('stat_date', $year) + ->whereMonth('stat_date', $month) + ->selectRaw(' + SUM(order_count) as order_count, + SUM(order_amount) as order_amount, + SUM(sales_count) as sales_count, + SUM(sales_amount) as sales_amount, + SUM(shipment_count) as shipment_count, + SUM(shipment_amount) as shipment_amount + ') + ->first(); + + // 월간 고유 거래 고객 수 + $startDate = Carbon::create($year, $month, 1)->format('Y-m-d'); + $endDate = Carbon::create($year, $month, 1)->endOfMonth()->format('Y-m-d'); + + $uniqueClientCount = DB::connection('mysql') + ->table('orders') + ->where('tenant_id', $tenantId) + ->whereBetween(DB::raw('DATE(created_at)'), [$startDate, $endDate]) + ->whereNull('deleted_at') + ->whereNotNull('client_id') + ->distinct('client_id') + ->count('client_id'); + + // 평균 수주 금액 + $orderCount = $dailySum->order_count ?? 0; + $orderAmount = $dailySum->order_amount ?? 0; + $avgOrderAmount = $orderCount > 0 ? $orderAmount / $orderCount : 0; + + // 최다 거래 고객 + $topClient = DB::connection('mysql') + ->table('orders') + ->where('tenant_id', $tenantId) + ->whereBetween(DB::raw('DATE(created_at)'), [$startDate, $endDate]) + ->whereNull('deleted_at') + ->whereNotNull('client_id') + ->groupBy('client_id') + ->orderByRaw('SUM(total_amount) DESC') + ->selectRaw('client_id, SUM(total_amount) as total') + ->first(); + + // 전월 대비 성장률 + $prevMonth = StatSalesMonthly::where('tenant_id', $tenantId) + ->where(function ($q) use ($year, $month) { + $prev = Carbon::create($year, $month, 1)->subMonth(); + $q->where('stat_year', $prev->year)->where('stat_month', $prev->month); + }) + ->first(); + + $salesAmount = $dailySum->sales_amount ?? 0; + $momGrowthRate = null; + if ($prevMonth && $prevMonth->sales_amount > 0) { + $momGrowthRate = (($salesAmount - $prevMonth->sales_amount) / $prevMonth->sales_amount) * 100; + } + + // 전년동월 대비 + $prevYear = StatSalesMonthly::where('tenant_id', $tenantId) + ->where('stat_year', $year - 1) + ->where('stat_month', $month) + ->first(); + + $yoyGrowthRate = null; + if ($prevYear && $prevYear->sales_amount > 0) { + $yoyGrowthRate = (($salesAmount - $prevYear->sales_amount) / $prevYear->sales_amount) * 100; + } + + StatSalesMonthly::updateOrCreate( + ['tenant_id' => $tenantId, 'stat_year' => $year, 'stat_month' => $month], + [ + 'order_count' => $dailySum->order_count ?? 0, + 'order_amount' => $orderAmount, + 'sales_count' => $dailySum->sales_count ?? 0, + 'sales_amount' => $salesAmount, + 'shipment_count' => $dailySum->shipment_count ?? 0, + 'shipment_amount' => $dailySum->shipment_amount ?? 0, + 'unique_client_count' => $uniqueClientCount, + 'avg_order_amount' => $avgOrderAmount, + 'top_client_id' => $topClient->client_id ?? null, + 'top_client_amount' => $topClient->total ?? 0, + 'mom_growth_rate' => $momGrowthRate, + 'yoy_growth_rate' => $yoyGrowthRate, + ] + ); + + return 1; + } +} diff --git a/app/Services/Stats/StatAggregatorService.php b/app/Services/Stats/StatAggregatorService.php index f87fd08..03597fd 100644 --- a/app/Services/Stats/StatAggregatorService.php +++ b/app/Services/Stats/StatAggregatorService.php @@ -10,14 +10,14 @@ class StatAggregatorService { /** - * 일간 도메인 서비스 매핑 (Phase 2에서 구현체 추가) + * 일간 도메인 서비스 매핑 */ private function getDailyDomainServices(): array { return [ - // 'sales' => SalesStatService::class, - // 'finance' => FinanceStatService::class, - // 'production' => ProductionStatService::class, + 'sales' => SalesStatService::class, + 'finance' => FinanceStatService::class, + 'production' => ProductionStatService::class, ]; } @@ -27,9 +27,9 @@ private function getDailyDomainServices(): array private function getMonthlyDomainServices(): array { return [ - // 'sales' => SalesStatService::class, - // 'finance' => FinanceStatService::class, - // 'production' => ProductionStatService::class, + 'sales' => SalesStatService::class, + 'finance' => FinanceStatService::class, + 'production' => ProductionStatService::class, ]; } diff --git a/database/migrations/stats/2026_01_29_174339_create_stat_sales_daily_table.php b/database/migrations/stats/2026_01_29_174339_create_stat_sales_daily_table.php new file mode 100644 index 0000000..97374cd --- /dev/null +++ b/database/migrations/stats/2026_01_29_174339_create_stat_sales_daily_table.php @@ -0,0 +1,55 @@ +connection)->create('stat_sales_daily', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->date('stat_date'); + + // 수주 + $table->unsignedInteger('order_count')->default(0)->comment('신규 수주 건수'); + $table->decimal('order_amount', 18, 2)->default(0)->comment('수주 금액'); + $table->unsignedInteger('order_item_count')->default(0)->comment('수주 품목 수'); + + // 매출 + $table->unsignedInteger('sales_count')->default(0)->comment('매출 건수'); + $table->decimal('sales_amount', 18, 2)->default(0)->comment('매출 금액'); + $table->decimal('sales_tax_amount', 18, 2)->default(0)->comment('세액'); + + // 고객 + $table->unsignedInteger('new_client_count')->default(0)->comment('신규 고객 수'); + $table->unsignedInteger('active_client_count')->default(0)->comment('활성 고객 수'); + + // 수주 상태별 건수 + $table->unsignedInteger('order_draft_count')->default(0); + $table->unsignedInteger('order_confirmed_count')->default(0); + $table->unsignedInteger('order_in_progress_count')->default(0); + $table->unsignedInteger('order_completed_count')->default(0); + $table->unsignedInteger('order_cancelled_count')->default(0); + + // 출하 + $table->unsignedInteger('shipment_count')->default(0); + $table->decimal('shipment_amount', 18, 2)->default(0); + + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_date'], 'uk_tenant_date'); + $table->index('stat_date'); + $table->index('tenant_id'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_sales_daily'); + } +}; diff --git a/database/migrations/stats/2026_01_29_174340_create_stat_finance_daily_table.php b/database/migrations/stats/2026_01_29_174340_create_stat_finance_daily_table.php new file mode 100644 index 0000000..6568949 --- /dev/null +++ b/database/migrations/stats/2026_01_29_174340_create_stat_finance_daily_table.php @@ -0,0 +1,59 @@ +connection)->create('stat_finance_daily', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->date('stat_date'); + + // 입출금 + $table->unsignedInteger('deposit_count')->default(0); + $table->decimal('deposit_amount', 18, 2)->default(0); + $table->unsignedInteger('withdrawal_count')->default(0); + $table->decimal('withdrawal_amount', 18, 2)->default(0); + $table->decimal('net_cashflow', 18, 2)->default(0)->comment('입금 - 출금'); + + // 매입 + $table->unsignedInteger('purchase_count')->default(0); + $table->decimal('purchase_amount', 18, 2)->default(0); + $table->decimal('purchase_tax_amount', 18, 2)->default(0); + + // 미수/미지급 + $table->decimal('receivable_balance', 18, 2)->default(0)->comment('미수금 잔액'); + $table->decimal('payable_balance', 18, 2)->default(0)->comment('미지급 잔액'); + $table->decimal('overdue_receivable', 18, 2)->default(0)->comment('연체 미수금'); + + // 어음 + $table->unsignedInteger('bill_issued_count')->default(0); + $table->decimal('bill_issued_amount', 18, 2)->default(0); + $table->unsignedInteger('bill_matured_count')->default(0); + $table->decimal('bill_matured_amount', 18, 2)->default(0); + + // 카드 + $table->unsignedInteger('card_transaction_count')->default(0); + $table->decimal('card_transaction_amount', 18, 2)->default(0); + + // 은행 + $table->decimal('bank_balance_total', 18, 2)->default(0)->comment('전 계좌 잔액 합계'); + + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_date'], 'uk_tenant_date'); + $table->index('stat_date'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_finance_daily'); + } +}; diff --git a/database/migrations/stats/2026_01_29_174340_create_stat_finance_monthly_table.php b/database/migrations/stats/2026_01_29_174340_create_stat_finance_monthly_table.php new file mode 100644 index 0000000..fe90203 --- /dev/null +++ b/database/migrations/stats/2026_01_29_174340_create_stat_finance_monthly_table.php @@ -0,0 +1,42 @@ +connection)->create('stat_finance_monthly', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->smallInteger('stat_year'); + $table->tinyInteger('stat_month'); + + $table->decimal('deposit_total', 18, 2)->default(0); + $table->decimal('withdrawal_total', 18, 2)->default(0); + $table->decimal('net_cashflow', 18, 2)->default(0); + $table->decimal('purchase_total', 18, 2)->default(0); + $table->decimal('card_total', 18, 2)->default(0); + + $table->decimal('receivable_end', 18, 2)->default(0)->comment('월말 미수금'); + $table->decimal('payable_end', 18, 2)->default(0)->comment('월말 미지급'); + $table->decimal('bank_balance_end', 18, 2)->default(0)->comment('월말 잔액'); + + $table->decimal('mom_cashflow_change', 8, 2)->nullable()->comment('전월 대비 현금흐름 변화 (%)'); + + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_year', 'stat_month'], 'uk_tenant_year_month'); + $table->index(['stat_year', 'stat_month']); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_finance_monthly'); + } +}; diff --git a/database/migrations/stats/2026_01_29_174340_create_stat_sales_monthly_table.php b/database/migrations/stats/2026_01_29_174340_create_stat_sales_monthly_table.php new file mode 100644 index 0000000..1669368 --- /dev/null +++ b/database/migrations/stats/2026_01_29_174340_create_stat_sales_monthly_table.php @@ -0,0 +1,46 @@ +connection)->create('stat_sales_monthly', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->smallInteger('stat_year'); + $table->tinyInteger('stat_month'); + + // 일일 합산 + $table->unsignedInteger('order_count')->default(0); + $table->decimal('order_amount', 18, 2)->default(0); + $table->unsignedInteger('sales_count')->default(0); + $table->decimal('sales_amount', 18, 2)->default(0); + $table->unsignedInteger('shipment_count')->default(0); + $table->decimal('shipment_amount', 18, 2)->default(0); + + // 월간 고유 지표 + $table->unsignedInteger('unique_client_count')->default(0)->comment('거래 고객 수'); + $table->decimal('avg_order_amount', 18, 2)->default(0)->comment('평균 수주 금액'); + $table->unsignedBigInteger('top_client_id')->nullable()->comment('최다 거래 고객'); + $table->decimal('top_client_amount', 18, 2)->default(0); + $table->decimal('mom_growth_rate', 8, 2)->nullable()->comment('전월 대비 성장률 (%)'); + $table->decimal('yoy_growth_rate', 8, 2)->nullable()->comment('전년동월 대비 (%)'); + + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_year', 'stat_month'], 'uk_tenant_year_month'); + $table->index(['stat_year', 'stat_month']); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_sales_monthly'); + } +}; diff --git a/database/migrations/stats/2026_01_29_174341_create_stat_production_daily_table.php b/database/migrations/stats/2026_01_29_174341_create_stat_production_daily_table.php new file mode 100644 index 0000000..b376fa4 --- /dev/null +++ b/database/migrations/stats/2026_01_29_174341_create_stat_production_daily_table.php @@ -0,0 +1,54 @@ +connection)->create('stat_production_daily', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->date('stat_date'); + + // 작업지시 + $table->unsignedInteger('wo_created_count')->default(0)->comment('신규 작업지시'); + $table->unsignedInteger('wo_completed_count')->default(0)->comment('완료 작업지시'); + $table->unsignedInteger('wo_in_progress_count')->default(0)->comment('진행중'); + $table->unsignedInteger('wo_overdue_count')->default(0)->comment('납기 초과'); + + // 생산량 + $table->decimal('production_qty', 18, 2)->default(0)->comment('생산 수량'); + $table->decimal('defect_qty', 18, 2)->default(0)->comment('불량 수량'); + $table->decimal('defect_rate', 5, 2)->default(0)->comment('불량률 (%)'); + + // 작업 효율 + $table->decimal('planned_hours', 10, 2)->default(0)->comment('계획 공수'); + $table->decimal('actual_hours', 10, 2)->default(0)->comment('실적 공수'); + $table->decimal('efficiency_rate', 5, 2)->default(0)->comment('효율 (%)'); + + // 작업자 + $table->unsignedInteger('active_worker_count')->default(0); + $table->unsignedInteger('issue_count')->default(0)->comment('발생 이슈 수'); + + // 납기 + $table->unsignedInteger('on_time_delivery_count')->default(0); + $table->unsignedInteger('late_delivery_count')->default(0); + $table->decimal('delivery_rate', 5, 2)->default(0)->comment('납기준수율 (%)'); + + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_date'], 'uk_tenant_date'); + $table->index('stat_date'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_production_daily'); + } +}; diff --git a/database/migrations/stats/2026_01_29_174341_create_stat_production_monthly_table.php b/database/migrations/stats/2026_01_29_174341_create_stat_production_monthly_table.php new file mode 100644 index 0000000..1ed7aa5 --- /dev/null +++ b/database/migrations/stats/2026_01_29_174341_create_stat_production_monthly_table.php @@ -0,0 +1,41 @@ +connection)->create('stat_production_monthly', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->smallInteger('stat_year'); + $table->tinyInteger('stat_month'); + + $table->unsignedInteger('wo_total_count')->default(0); + $table->unsignedInteger('wo_completed_count')->default(0); + $table->decimal('production_qty', 18, 2)->default(0); + $table->decimal('defect_qty', 18, 2)->default(0); + $table->decimal('avg_defect_rate', 5, 2)->default(0); + $table->decimal('avg_efficiency_rate', 5, 2)->default(0); + $table->decimal('avg_delivery_rate', 5, 2)->default(0); + $table->decimal('total_planned_hours', 10, 2)->default(0); + $table->decimal('total_actual_hours', 10, 2)->default(0); + $table->unsignedInteger('issue_total_count')->default(0); + + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_year', 'stat_month'], 'uk_tenant_year_month'); + $table->index(['stat_year', 'stat_month']); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_production_monthly'); + } +}; diff --git a/routes/console.php b/routes/console.php index adc567c..0e9025a 100644 --- a/routes/console.php +++ b/routes/console.php @@ -93,3 +93,29 @@ ->onFailure(function () { \Illuminate\Support\Facades\Log::error('❌ storage:record-usage 스케줄러 실행 실패', ['time' => now()]); }); + +// ─── 통계 집계 (sam_stat DB) ─── + +// 매일 새벽 02:00에 일간 통계 집계 (전일 데이터) +Schedule::command('stat:aggregate-daily') + ->dailyAt('02:00') + ->withoutOverlapping() + ->appendOutputTo(storage_path('logs/scheduler.log')) + ->onSuccess(function () { + \Illuminate\Support\Facades\Log::info('✅ stat:aggregate-daily 스케줄러 실행 성공', ['time' => now()]); + }) + ->onFailure(function () { + \Illuminate\Support\Facades\Log::error('❌ stat:aggregate-daily 스케줄러 실행 실패', ['time' => now()]); + }); + +// 매월 1일 새벽 03:00에 월간 통계 집계 (전월 데이터) +Schedule::command('stat:aggregate-monthly') + ->monthlyOn(1, '03:00') + ->withoutOverlapping() + ->appendOutputTo(storage_path('logs/scheduler.log')) + ->onSuccess(function () { + \Illuminate\Support\Facades\Log::info('✅ stat:aggregate-monthly 스케줄러 실행 성공', ['time' => now()]); + }) + ->onFailure(function () { + \Illuminate\Support\Facades\Log::error('❌ stat:aggregate-monthly 스케줄러 실행 실패', ['time' => now()]); + }); From 6c9735581dedc2cc438196e96ce580ffb28c8cba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 19:30:46 +0900 Subject: [PATCH 48/57] =?UTF-8?q?feat:=20=EA=B2=AC=EC=A0=81=20=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=EB=A5=BC=20items+item=5Fdetails+prices=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EstimatePriceService 생성: items+item_details+prices JOIN 기반 단가 조회 - item_details.product_category/part_type/specification 컬럼 매핑 - items.attributes JSON으로 model_name/finishing_type 추가 차원 처리 - 세션 내 캐시로 중복 조회 방지 - MigrateBDModelsPrices 커맨드: 레거시 BDmodels + kd_price_tables → 85건 마이그레이션 - KyungdongFormulaHandler: KdPriceTable 의존 제거 → EstimatePriceService 사용 - FormulaEvaluatorService: W1 마진 140→160, 면적 공식 W1×(H1+550) 수정 - 가이드레일 H0+250, 케이스/L바/평철 W0+220 (레거시 일치) Co-Authored-By: Claude Opus 4.5 --- .../Commands/MigrateBDModelsPrices.php | 363 ++++++++++++++++++ app/Services/Quote/EstimatePriceService.php | 334 ++++++++++++++++ .../Quote/FormulaEvaluatorService.php | 14 +- .../Handlers/KyungdongFormulaHandler.php | 132 ++----- 4 files changed, 745 insertions(+), 98 deletions(-) create mode 100644 app/Console/Commands/MigrateBDModelsPrices.php create mode 100644 app/Services/Quote/EstimatePriceService.php diff --git a/app/Console/Commands/MigrateBDModelsPrices.php b/app/Console/Commands/MigrateBDModelsPrices.php new file mode 100644 index 0000000..b1a41fb --- /dev/null +++ b/app/Console/Commands/MigrateBDModelsPrices.php @@ -0,0 +1,363 @@ +option('dry-run'); + + $this->info('=== 경동 견적 단가 마이그레이션 ==='); + $this->info($dryRun ? '[DRY RUN] 실제 변경 없음' : '[LIVE] DB에 반영합니다'); + $this->newLine(); + + DB::beginTransaction(); + + try { + // 1. 레거시 BDmodels (chandj DB) + $this->migrateBDModels($dryRun); + + // 2. kd_price_tables (motor, shaft, pipe, angle, raw_material) + $this->migrateKdPriceTables($dryRun); + + if ($dryRun) { + DB::rollBack(); + $this->warn('[DRY RUN] 롤백 완료'); + } else { + DB::commit(); + $this->info('커밋 완료'); + } + + $this->newLine(); + $this->info("생성: {$this->created}건, 스킵: {$this->skipped}건"); + + return Command::SUCCESS; + } catch (\Exception $e) { + DB::rollBack(); + $this->error("오류: {$e->getMessage()}"); + + return Command::FAILURE; + } + } + + /** + * 레거시 chandj.BDmodels → items + item_details + prices + */ + private function migrateBDModels(bool $dryRun): void + { + $this->info('--- BDmodels (레거시) ---'); + + // chandj DB에서 BDmodels 조회 (chandj connection 사용) + $rows = DB::connection('chandj')->select(" + SELECT model_name, seconditem, finishing_type, spec, unitprice, description + FROM BDmodels + WHERE is_deleted = 0 + ORDER BY model_name, seconditem, finishing_type, spec + "); + + foreach ($rows as $row) { + $modelName = trim($row->model_name ?? ''); + $secondItem = trim($row->seconditem ?? ''); + $finishingType = trim($row->finishing_type ?? ''); + $spec = trim($row->spec ?? ''); + $unitPrice = (float) str_replace(',', '', $row->unitprice ?? '0'); + + // finishing_type 정규화: 'SUS마감' → 'SUS', 'EGI마감' → 'EGI' + $finishingType = str_replace('마감', '', $finishingType); + + if (empty($secondItem) || $unitPrice <= 0) { + $this->skipped++; + + continue; + } + + // 코드 생성 + $codeParts = ['BD', $secondItem]; + if ($modelName) { + $codeParts[] = $modelName; + } + if ($finishingType) { + $codeParts[] = $finishingType; + } + if ($spec) { + $codeParts[] = $spec; + } + $code = implode('-', $codeParts); + + // 이름 생성 + $nameParts = [$secondItem]; + if ($modelName) { + $nameParts[] = $modelName; + } + if ($finishingType) { + $nameParts[] = $finishingType; + } + if ($spec) { + $nameParts[] = $spec; + } + $name = implode(' ', $nameParts); + + $this->createEstimateItem( + code: $code, + name: $name, + productCategory: 'bdmodels', + partType: $secondItem, + specification: $spec ?: null, + attributes: array_filter([ + 'model_name' => $modelName ?: null, + 'finishing_type' => $finishingType ?: null, + 'bdmodel_source' => 'BDmodels', + 'description' => $row->description ?: null, + ]), + salesPrice: $unitPrice, + note: 'BDmodels 마이그레이션', + dryRun: $dryRun + ); + } + } + + /** + * kd_price_tables → items + item_details + prices + */ + private function migrateKdPriceTables(bool $dryRun): void + { + $this->info('--- kd_price_tables ---'); + + $rows = DB::table('kd_price_tables') + ->where('tenant_id', self::TENANT_ID) + ->where('is_active', true) + ->where('table_type', '!=', 'bdmodels') // BDmodels는 위에서 처리 + ->orderBy('table_type') + ->orderBy('item_code') + ->get(); + + foreach ($rows as $row) { + $tableType = $row->table_type; + $unitPrice = (float) $row->unit_price; + + if ($unitPrice <= 0) { + $this->skipped++; + + continue; + } + + switch ($tableType) { + case 'motor': + $this->migrateMotor($row, $dryRun); + break; + case 'shaft': + $this->migrateShaft($row, $dryRun); + break; + case 'pipe': + $this->migratePipe($row, $dryRun); + break; + case 'angle': + $this->migrateAngle($row, $dryRun); + break; + case 'raw_material': + $this->migrateRawMaterial($row, $dryRun); + break; + } + } + } + + private function migrateMotor(object $row, bool $dryRun): void + { + $category = $row->category; // 150K, 300K, 매립형, 노출형 등 + $code = "EST-MOTOR-{$category}"; + $name = "모터/제어기 {$category}"; + + $this->createEstimateItem( + code: $code, + name: $name, + productCategory: 'motor', + partType: $category, + specification: $row->spec2 ?? null, + attributes: ['price_unit' => $row->unit ?? 'EA'], + salesPrice: (float) $row->unit_price, + note: 'kd_price_tables motor 마이그레이션', + dryRun: $dryRun + ); + } + + private function migrateShaft(object $row, bool $dryRun): void + { + $size = $row->spec1; // 인치 + $length = $row->spec2; // 길이 + $code = "EST-SHAFT-{$size}-{$length}"; + $name = "감기샤프트 {$size}인치 {$length}m"; + + $this->createEstimateItem( + code: $code, + name: $name, + productCategory: 'shaft', + partType: $size, + specification: $length, + attributes: ['price_unit' => $row->unit ?? 'EA'], + salesPrice: (float) $row->unit_price, + note: 'kd_price_tables shaft 마이그레이션', + dryRun: $dryRun + ); + } + + private function migratePipe(object $row, bool $dryRun): void + { + $thickness = $row->spec1; + $length = $row->spec2; + $code = "EST-PIPE-{$thickness}-{$length}"; + $name = "각파이프 {$thickness}T {$length}mm"; + + $this->createEstimateItem( + code: $code, + name: $name, + productCategory: 'pipe', + partType: $thickness, + specification: $length, + attributes: ['price_unit' => $row->unit ?? 'EA'], + salesPrice: (float) $row->unit_price, + note: 'kd_price_tables pipe 마이그레이션', + dryRun: $dryRun + ); + } + + private function migrateAngle(object $row, bool $dryRun): void + { + $category = $row->category; // 스크린용, 철재용 + $bracketSize = $row->spec1; // 530*320, 600*350, 690*390 + $angleType = $row->spec2; // 앵글3T, 앵글4T + $code = "EST-ANGLE-{$category}-{$bracketSize}-{$angleType}"; + $name = "앵글 {$category} {$bracketSize} {$angleType}"; + + $this->createEstimateItem( + code: $code, + name: $name, + productCategory: 'angle', + partType: $category, + specification: $bracketSize, + attributes: [ + 'angle_type' => $angleType, + 'price_unit' => $row->unit ?? 'EA', + ], + salesPrice: (float) $row->unit_price, + note: 'kd_price_tables angle 마이그레이션', + dryRun: $dryRun + ); + } + + private function migrateRawMaterial(object $row, bool $dryRun): void + { + $name = $row->item_name; + $code = 'EST-RAW-'.preg_replace('/[^A-Za-z0-9가-힣]/', '', $name); + + $this->createEstimateItem( + code: $code, + name: $name, + productCategory: 'raw_material', + partType: $name, + specification: $row->spec1 ?? null, + attributes: ['price_unit' => $row->unit ?? 'EA'], + salesPrice: (float) $row->unit_price, + note: 'kd_price_tables raw_material 마이그레이션', + dryRun: $dryRun + ); + } + + /** + * 견적 품목 생성 (items + item_details + prices) + */ + private function createEstimateItem( + string $code, + string $name, + string $productCategory, + string $partType, + ?string $specification, + array $attributes, + float $salesPrice, + string $note, + bool $dryRun + ): void { + // 중복 체크 (code 기준) + $existing = DB::table('items') + ->where('tenant_id', self::TENANT_ID) + ->where('code', $code) + ->whereNull('deleted_at') + ->first(); + + if ($existing) { + $this->line(" [스킵] {$code} - 이미 존재"); + $this->skipped++; + + return; + } + + $this->line(" [생성] {$code} ({$name}) = {$salesPrice}"); + + if ($dryRun) { + $this->created++; + + return; + } + + $now = now(); + + // 1. items + $itemId = DB::table('items')->insertGetId([ + 'tenant_id' => self::TENANT_ID, + 'item_type' => 'PT', + 'code' => $code, + 'name' => $name, + 'unit' => 'EA', + 'attributes' => json_encode($attributes, JSON_UNESCAPED_UNICODE), + 'is_active' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + // 2. item_details + DB::table('item_details')->insert([ + 'item_id' => $itemId, + 'product_category' => $productCategory, + 'part_type' => $partType, + 'specification' => $specification, + 'item_name' => $name, + 'is_purchasable' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + // 3. prices + DB::table('prices')->insert([ + 'tenant_id' => self::TENANT_ID, + 'item_type_code' => 'PT', + 'item_id' => $itemId, + 'sales_price' => $salesPrice, + 'effective_from' => $now->toDateString(), + 'status' => 'active', + 'note' => $note, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->created++; + } +} \ No newline at end of file diff --git a/app/Services/Quote/EstimatePriceService.php b/app/Services/Quote/EstimatePriceService.php new file mode 100644 index 0000000..f66f336 --- /dev/null +++ b/app/Services/Quote/EstimatePriceService.php @@ -0,0 +1,334 @@ +tenantId = $tenantId; + } + + // ========================================================================= + // BDmodels 단가 조회 (절곡품) + // ========================================================================= + + /** + * BDmodels 단가 조회 + * + * item_details 컬럼 매핑: + * product_category = 'bdmodels' + * part_type = seconditem (가이드레일, 케이스, 마구리, L-BAR, 하단마감재, 보강평철, 연기차단재) + * specification = spec (120*70, 500*380 등) + * items.attributes: + * model_name = KSS01, KSS02 등 + * finishing_type = SUS, EGI + */ + public function getBDModelPrice( + string $secondItem, + ?string $modelName = null, + ?string $finishingType = null, + ?string $spec = null + ): float { + $cacheKey = "bdmodel:{$secondItem}:{$modelName}:{$finishingType}:{$spec}"; + + if (isset($this->cache[$cacheKey])) { + return $this->cache[$cacheKey]; + } + + $query = DB::table('items') + ->join('item_details', 'item_details.item_id', '=', 'items.id') + ->join('prices', 'prices.item_id', '=', 'items.id') + ->where('items.tenant_id', $this->tenantId) + ->where('items.is_active', true) + ->whereNull('items.deleted_at') + ->where('item_details.product_category', 'bdmodels') + ->where('item_details.part_type', $secondItem); + + if ($modelName) { + $query->where('items.attributes->model_name', $modelName); + } else { + $query->where(function ($q) { + $q->whereNull('items.attributes->model_name') + ->orWhere('items.attributes->model_name', ''); + }); + } + + if ($finishingType) { + $query->where('items.attributes->finishing_type', $finishingType); + } + + if ($spec) { + $query->where('item_details.specification', $spec); + } + + // 현재 유효한 단가 + $today = now()->toDateString(); + $query->where('prices.effective_from', '<=', $today) + ->where(function ($q) use ($today) { + $q->whereNull('prices.effective_to') + ->orWhere('prices.effective_to', '>=', $today); + }) + ->whereNull('prices.deleted_at'); + + $price = (float) ($query->value('prices.sales_price') ?? 0); + + $this->cache[$cacheKey] = $price; + + return $price; + } + + /** + * 케이스 단가 + */ + public function getCasePrice(string $spec): float + { + return $this->getBDModelPrice('케이스', null, null, $spec); + } + + /** + * 가이드레일 단가 + */ + public function getGuideRailPrice(string $modelName, string $finishingType, string $spec): float + { + return $this->getBDModelPrice('가이드레일', $modelName, $finishingType, $spec); + } + + /** + * 하단마감재(하장바) 단가 + */ + public function getBottomBarPrice(string $modelName, string $finishingType): float + { + return $this->getBDModelPrice('하단마감재', $modelName, $finishingType); + } + + /** + * L-BAR 단가 + */ + public function getLBarPrice(string $modelName): float + { + return $this->getBDModelPrice('L-BAR', $modelName); + } + + /** + * 보강평철 단가 + */ + public function getFlatBarPrice(): float + { + return $this->getBDModelPrice('보강평철'); + } + + /** + * 케이스 마구리 단가 + */ + public function getCaseCapPrice(string $spec): float + { + return $this->getBDModelPrice('마구리', null, null, $spec); + } + + /** + * 케이스용 연기차단재 단가 + */ + public function getCaseSmokeBlockPrice(): float + { + return $this->getBDModelPrice('케이스용 연기차단재'); + } + + /** + * 가이드레일용 연기차단재 단가 + */ + public function getRailSmokeBlockPrice(): float + { + return $this->getBDModelPrice('가이드레일용 연기차단재'); + } + + // ========================================================================= + // 모터/제어기 단가 + // ========================================================================= + + /** + * 모터 단가 + */ + public function getMotorPrice(string $motorCapacity): float + { + return $this->getEstimatePartPrice('motor', $motorCapacity); + } + + /** + * 제어기 단가 + */ + public function getControllerPrice(string $controllerType): float + { + return $this->getEstimatePartPrice('motor', $controllerType); + } + + // ========================================================================= + // 부자재 단가 + // ========================================================================= + + /** + * 샤프트 단가 + */ + public function getShaftPrice(string $size, float $length): float + { + $lengthStr = number_format($length, 1, '.', ''); + $cacheKey = "shaft:{$size}:{$lengthStr}"; + + if (isset($this->cache[$cacheKey])) { + return $this->cache[$cacheKey]; + } + + $price = $this->getEstimatePartPriceBySpec('shaft', $size, $lengthStr); + $this->cache[$cacheKey] = $price; + + return $price; + } + + /** + * 파이프 단가 + */ + public function getPipePrice(string $thickness, int $length): float + { + return $this->getEstimatePartPriceBySpec('pipe', $thickness, (string) $length); + } + + /** + * 앵글 단가 + */ + public function getAnglePrice(string $type, string $bracketSize, string $angleType): float + { + $cacheKey = "angle:{$type}:{$bracketSize}:{$angleType}"; + + if (isset($this->cache[$cacheKey])) { + return $this->cache[$cacheKey]; + } + + $today = now()->toDateString(); + + $price = (float) (DB::table('items') + ->join('item_details', 'item_details.item_id', '=', 'items.id') + ->join('prices', 'prices.item_id', '=', 'items.id') + ->where('items.tenant_id', $this->tenantId) + ->where('items.is_active', true) + ->whereNull('items.deleted_at') + ->where('item_details.product_category', 'angle') + ->where('item_details.part_type', $type) + ->where('item_details.specification', $bracketSize) + ->where('items.attributes->angle_type', $angleType) + ->where('prices.effective_from', '<=', $today) + ->where(function ($q) use ($today) { + $q->whereNull('prices.effective_to') + ->orWhere('prices.effective_to', '>=', $today); + }) + ->whereNull('prices.deleted_at') + ->value('prices.sales_price') ?? 0); + + $this->cache[$cacheKey] = $price; + + return $price; + } + + /** + * 원자재 단가 + */ + public function getRawMaterialPrice(string $materialName): float + { + return $this->getEstimatePartPrice('raw_material', $materialName); + } + + // ========================================================================= + // 내부 헬퍼 + // ========================================================================= + + /** + * product_category + part_type 기반 단가 조회 + */ + private function getEstimatePartPrice(string $productCategory, string $partType): float + { + $cacheKey = "{$productCategory}:{$partType}"; + + if (isset($this->cache[$cacheKey])) { + return $this->cache[$cacheKey]; + } + + $today = now()->toDateString(); + + $price = (float) (DB::table('items') + ->join('item_details', 'item_details.item_id', '=', 'items.id') + ->join('prices', 'prices.item_id', '=', 'items.id') + ->where('items.tenant_id', $this->tenantId) + ->where('items.is_active', true) + ->whereNull('items.deleted_at') + ->where('item_details.product_category', $productCategory) + ->where('item_details.part_type', $partType) + ->where('prices.effective_from', '<=', $today) + ->where(function ($q) use ($today) { + $q->whereNull('prices.effective_to') + ->orWhere('prices.effective_to', '>=', $today); + }) + ->whereNull('prices.deleted_at') + ->value('prices.sales_price') ?? 0); + + $this->cache[$cacheKey] = $price; + + return $price; + } + + /** + * product_category + spec1 + spec2 기반 단가 조회 + */ + private function getEstimatePartPriceBySpec(string $productCategory, string $spec1, string $spec2): float + { + $cacheKey = "{$productCategory}:{$spec1}:{$spec2}"; + + if (isset($this->cache[$cacheKey])) { + return $this->cache[$cacheKey]; + } + + $today = now()->toDateString(); + + $price = (float) (DB::table('items') + ->join('item_details', 'item_details.item_id', '=', 'items.id') + ->join('prices', 'prices.item_id', '=', 'items.id') + ->where('items.tenant_id', $this->tenantId) + ->where('items.is_active', true) + ->whereNull('items.deleted_at') + ->where('item_details.product_category', $productCategory) + ->where('item_details.part_type', $spec1) + ->where('item_details.specification', $spec2) + ->where('prices.effective_from', '<=', $today) + ->where(function ($q) use ($today) { + $q->whereNull('prices.effective_to') + ->orWhere('prices.effective_to', '>=', $today); + }) + ->whereNull('prices.deleted_at') + ->value('prices.sales_price') ?? 0); + + $this->cache[$cacheKey] = $price; + + return $price; + } + + /** + * 캐시 초기화 + */ + public function clearCache(): void + { + $this->cache = []; + } +} \ No newline at end of file diff --git a/app/Services/Quote/FormulaEvaluatorService.php b/app/Services/Quote/FormulaEvaluatorService.php index 7217ba0..d3942d8 100644 --- a/app/Services/Quote/FormulaEvaluatorService.php +++ b/app/Services/Quote/FormulaEvaluatorService.php @@ -658,7 +658,7 @@ public function calculateBomWithDebug( $marginW = 110; // 철재 마진 $marginH = 350; } else { - $marginW = 140; // 스크린 기본 마진 + $marginW = 160; // 스크린 기본 마진 $marginH = 350; } @@ -1625,9 +1625,9 @@ private function calculateKyungdongBom( $handler = new KyungdongFormulaHandler; // Step 3: 경동 전용 변수 계산 - $W1 = $W0 + 140; + $W1 = $W0 + 160; $H1 = $H0 + 350; - $area = ($W0 * ($H0 + 550)) / 1000000; + $area = ($W1 * ($H1 + 550)) / 1000000; // 중량 계산 (제품타입별) if ($productType === 'steel') { @@ -1665,8 +1665,8 @@ private function calculateKyungdongBom( [ 'var' => 'W1', 'desc' => '제작 폭', - 'formula' => 'W0 + 140', - 'calculation' => "{$W0} + 140", + 'formula' => 'W0 + 160', + 'calculation' => "{$W0} + 160", 'result' => $W1, 'unit' => 'mm', ], @@ -1681,8 +1681,8 @@ private function calculateKyungdongBom( [ 'var' => 'AREA', 'desc' => '면적', - 'formula' => '(W0 × (H0 + 550)) / 1,000,000', - 'calculation' => "({$W0} × ({$H0} + 550)) / 1,000,000", + 'formula' => '(W1 × (H1 + 550)) / 1,000,000', + 'calculation' => "({$W1} × ({$H1} + 550)) / 1,000,000", 'result' => round($area, 4), 'unit' => '㎡', ], diff --git a/app/Services/Quote/Handlers/KyungdongFormulaHandler.php b/app/Services/Quote/Handlers/KyungdongFormulaHandler.php index 6e27858..e00f4e5 100644 --- a/app/Services/Quote/Handlers/KyungdongFormulaHandler.php +++ b/app/Services/Quote/Handlers/KyungdongFormulaHandler.php @@ -2,7 +2,7 @@ namespace App\Services\Quote\Handlers; -use App\Models\Kyungdong\KdPriceTable; +use App\Services\Quote\EstimatePriceService; /** * 경동기업 전용 견적 계산 핸들러 @@ -14,6 +14,13 @@ class KyungdongFormulaHandler { private const TENANT_ID = 287; + private EstimatePriceService $priceService; + + public function __construct(?EstimatePriceService $priceService = null) + { + $this->priceService = $priceService ?? new EstimatePriceService(self::TENANT_ID); + } + // ========================================================================= // 모터 용량 계산 // ========================================================================= @@ -222,9 +229,12 @@ private function getMotorCapacityByWeight(float $weight, ?string $bracketInch = */ public function calculateScreenPrice(float $width, float $height): array { - // 면적 계산: W × (H + 550) / 1,000,000 - $calculateHeight = $height + 550; - $area = ($width * $calculateHeight) / 1000000; + // 면적 계산: W1 × (H1 + 550) / 1,000,000 + // W1 = W0 + 160, H1 = H0 + 350 (레거시 5130 공식) + $W1 = $width + 160; + $H1 = $height + 350; + $calculateHeight = $H1 + 550; + $area = ($W1 * $calculateHeight) / 1000000; // 원자재 단가 조회 (실리카/스크린) $unitPrice = $this->getRawMaterialPrice('실리카'); @@ -237,117 +247,55 @@ public function calculateScreenPrice(float $width, float $height): array } // ========================================================================= - // 단가 조회 메서드 (KdPriceTable 사용) + // 단가 조회 메서드 (EstimatePriceService 사용) // ========================================================================= - /** - * BDmodels 테이블에서 단가 조회 - * - * @param string $modelName 모델코드 (KSS01, KWS01 등) - * @param string $secondItem 부품분류 (케이스, 가이드레일, 하단마감재, L-BAR 등) - * @param string|null $finishingType 마감재질 (SUS, EGI) - * @param string|null $spec 규격 (120*70, 650*550 등) - * @return float 단가 - */ - public function getBDModelPrice( - string $modelName, - string $secondItem, - ?string $finishingType = null, - ?string $spec = null - ): float { - // BDmodels는 복잡한 구조이므로 items 테이블의 기존 데이터 활용 - // TODO: 필요시 kd_price_tables TYPE_BDMODELS 추가 - return 0.0; - } - - /** - * price_* 테이블에서 단가 조회 (모터, 샤프트, 파이프, 앵글) - * - * @param string $tableName 테이블명 (motor, shaft, pipe, angle) - * @param array $conditions 조회 조건 - * @return float 단가 - */ - public function getPriceFromTable(string $tableName, array $conditions): float - { - $query = KdPriceTable::where('table_type', $tableName)->active(); - - foreach ($conditions as $field => $value) { - $query->where($field, $value); - } - - $record = $query->first(); - - return (float) ($record?->unit_price ?? 0); - } - /** * 원자재 단가 조회 - * - * @param string $materialName 원자재명 (실리카, 스크린 등) - * @return float 단가 */ public function getRawMaterialPrice(string $materialName): float { - return KdPriceTable::getRawMaterialPrice($materialName); + return $this->priceService->getRawMaterialPrice($materialName); } /** * 모터 단가 조회 - * - * @param string $motorCapacity 모터 용량 (150K, 300K 등) - * @return float 단가 */ public function getMotorPrice(string $motorCapacity): float { - return KdPriceTable::getMotorPrice($motorCapacity); + return $this->priceService->getMotorPrice($motorCapacity); } /** * 제어기 단가 조회 - * - * @param string $controllerType 제어기 타입 (매립형, 노출형, 뒷박스) - * @return float 단가 */ public function getControllerPrice(string $controllerType): float { - return KdPriceTable::getControllerPrice($controllerType); + return $this->priceService->getControllerPrice($controllerType); } /** * 샤프트 단가 조회 - * - * @param string $size 사이즈 (3, 4, 5인치) - * @param float $length 길이 (m 단위) - * @return float 단가 */ public function getShaftPrice(string $size, float $length): float { - return KdPriceTable::getShaftPrice($size, $length); + return $this->priceService->getShaftPrice($size, $length); } /** * 파이프 단가 조회 - * - * @param string $thickness 두께 (1.4 등) - * @param int $length 길이 (3000, 6000) - * @return float 단가 */ public function getPipePrice(string $thickness, int $length): float { - return KdPriceTable::getPipePrice($thickness, $length); + return $this->priceService->getPipePrice($thickness, $length); } /** * 앵글 단가 조회 - * - * @param string $type 타입 (스크린용, 철재용) - * @param string $bracketSize 브라켓크기 (530*320, 600*350, 690*390) - * @param string $angleType 앵글타입 (앵글3T, 앵글4T) - * @return float 단가 */ public function getAnglePrice(string $type, string $bracketSize, string $angleType): float { - return KdPriceTable::getAnglePrice($type, $bracketSize, $angleType); + return $this->priceService->getAnglePrice($type, $bracketSize, $angleType); } // ========================================================================= @@ -376,18 +324,18 @@ public function calculateSteelItems(array $params): array // 절곡품 관련 파라미터 $caseSpec = $params['case_spec'] ?? '500*380'; - $caseLength = (float) ($params['case_length'] ?? $width); // mm 단위 + $caseLength = (float) ($params['case_length'] ?? ($width + 220)); // mm 단위 (레거시: W0+220) $guideType = $params['guide_type'] ?? '벽면형'; // 벽면형, 측면형, 혼합형 $guideSpec = $params['guide_spec'] ?? '120*70'; // 120*70, 120*100 - $guideLength = (float) ($params['guide_length'] ?? ($height + 550)) / 1000; // m 단위 - $bottomBarLength = (float) ($params['bottombar_length'] ?? $width) / 1000; // m 단위 - $lbarLength = (float) ($params['lbar_length'] ?? $width) / 1000; // m 단위 - $flatBarLength = (float) ($params['flatbar_length'] ?? $width) / 1000; // m 단위 + $guideLength = (float) ($params['guide_length'] ?? ($height + 250)) / 1000; // m 단위 (레거시: H0+250) + $bottomBarLength = (float) ($params['bottombar_length'] ?? $width) / 1000; // m 단위 (레거시: W0) + $lbarLength = (float) ($params['lbar_length'] ?? ($width + 220)) / 1000; // m 단위 (레거시: W0+220) + $flatBarLength = (float) ($params['flatbar_length'] ?? ($width + 220)) / 1000; // m 단위 (레거시: W0+220) $weightPlateQty = (int) ($params['weight_plate_qty'] ?? 0); // 무게평철 수량 $roundBarQty = (int) ($params['round_bar_qty'] ?? 0); // 환봉 수량 // 1. 케이스 (단가/1000 × 길이mm × 수량) - $casePrice = KdPriceTable::getCasePrice($caseSpec); + $casePrice = $this->priceService->getCasePrice($caseSpec); if ($casePrice > 0 && $caseLength > 0) { $totalPrice = ($casePrice / 1000) * $caseLength * $quantity; $items[] = [ @@ -402,7 +350,7 @@ public function calculateSteelItems(array $params): array } // 2. 케이스용 연기차단재 (단가 × 길이m × 수량) - $caseSmokePrice = KdPriceTable::getCaseSmokeBlockPrice(); + $caseSmokePrice = $this->priceService->getCaseSmokeBlockPrice(); if ($caseSmokePrice > 0 && $caseLength > 0) { $lengthM = $caseLength / 1000; $items[] = [ @@ -417,7 +365,7 @@ public function calculateSteelItems(array $params): array } // 3. 케이스 마구리 (단가 × 수량) - $caseCapPrice = KdPriceTable::getCaseCapPrice($caseSpec); + $caseCapPrice = $this->priceService->getCaseCapPrice($caseSpec); if ($caseCapPrice > 0) { $capQty = 2 * $quantity; // 좌우 2개 $items[] = [ @@ -436,7 +384,7 @@ public function calculateSteelItems(array $params): array $items = array_merge($items, $guideItems); // 5. 레일용 연기차단재 (단가 × 길이m × 2 × 수량) - $railSmokePrice = KdPriceTable::getRailSmokeBlockPrice(); + $railSmokePrice = $this->priceService->getRailSmokeBlockPrice(); if ($railSmokePrice > 0 && $guideLength > 0) { $railSmokeQty = 2 * $quantity; // 좌우 2개 $items[] = [ @@ -451,7 +399,7 @@ public function calculateSteelItems(array $params): array } // 6. 하장바 (단가 × 길이m × 수량) - $bottomBarPrice = KdPriceTable::getBottomBarPrice($modelName, $finishingType); + $bottomBarPrice = $this->priceService->getBottomBarPrice($modelName, $finishingType); if ($bottomBarPrice > 0 && $bottomBarLength > 0) { $items[] = [ 'category' => 'steel', @@ -465,7 +413,7 @@ public function calculateSteelItems(array $params): array } // 7. L바 (단가 × 길이m × 수량) - $lbarPrice = KdPriceTable::getLBarPrice($modelName); + $lbarPrice = $this->priceService->getLBarPrice($modelName); if ($lbarPrice > 0 && $lbarLength > 0) { $items[] = [ 'category' => 'steel', @@ -479,7 +427,7 @@ public function calculateSteelItems(array $params): array } // 8. 보강평철 (단가 × 길이m × 수량) - $flatBarPrice = KdPriceTable::getFlatBarPrice(); + $flatBarPrice = $this->priceService->getFlatBarPrice(); if ($flatBarPrice > 0 && $flatBarLength > 0) { $items[] = [ 'category' => 'steel', @@ -551,7 +499,7 @@ private function calculateGuideRails( switch ($guideType) { case '벽면형': // 120*70 × 2개 - $price = KdPriceTable::getGuideRailPrice($modelName, $finishingType, '120*70'); + $price = $this->priceService->getGuideRailPrice($modelName, $finishingType, '120*70'); if ($price > 0) { $guideQty = 2 * $quantity; $items[] = [ @@ -568,7 +516,7 @@ private function calculateGuideRails( case '측면형': // 120*100 × 2개 - $price = KdPriceTable::getGuideRailPrice($modelName, $finishingType, '120*100'); + $price = $this->priceService->getGuideRailPrice($modelName, $finishingType, '120*100'); if ($price > 0) { $guideQty = 2 * $quantity; $items[] = [ @@ -585,8 +533,8 @@ private function calculateGuideRails( case '혼합형': // 120*70 × 1개 + 120*100 × 1개 - $price70 = KdPriceTable::getGuideRailPrice($modelName, $finishingType, '120*70'); - $price100 = KdPriceTable::getGuideRailPrice($modelName, $finishingType, '120*100'); + $price70 = $this->priceService->getGuideRailPrice($modelName, $finishingType, '120*70'); + $price100 = $this->priceService->getGuideRailPrice($modelName, $finishingType, '120*100'); if ($price70 > 0) { $items[] = [ @@ -707,8 +655,10 @@ public function calculateDynamicItems(array $inputs): array $bracketInch = $inputs['bracket_inch'] ?? '5'; $productType = $inputs['product_type'] ?? 'screen'; - // 중량 계산 (5130 로직) - $area = ($width * ($height + 550)) / 1000000; + // 중량 계산 (5130 로직) - W1, H1 기반 + $W1 = $width + 160; + $H1 = $height + 350; + $area = ($W1 * ($H1 + 550)) / 1000000; $weight = $area * ($productType === 'steel' ? 25 : 2) + ($width / 1000) * 14.17; // 모터 용량/브라켓 크기 계산 From 595e3d59b4ce0be95f5c7c4e9dc3d7ea49928f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 20:19:50 +0900 Subject: [PATCH 49/57] =?UTF-8?q?feat:=20sam=5Fstat=20P1=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=ED=99=95=EC=9E=A5=20(Phase=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 차원 테이블: dim_client, dim_product 마이그레이션 + SCD Type 2 동기화 (DimensionSyncService) - 재고 통계: stat_inventory_daily + InventoryStatService (stocks, stock_transactions, inspections) - 견적/영업 통계: stat_quote_pipeline_daily + QuoteStatService (quotes, biddings, sales_prospects) - 인사/근태 통계: stat_hr_attendance_daily + HrStatService (attendances, leaves, user_tenants) - KPI/알림: stat_kpi_targets, stat_alerts + KpiAlertService + StatCheckKpiAlertsCommand - StatAggregatorService에 inventory, quote, hr 도메인 추가 (총 6개 도메인) - 스케줄러: stat:check-kpi-alerts 매일 09:00 등록 Co-Authored-By: Claude Opus 4.5 --- .../Commands/StatCheckKpiAlertsCommand.php | 33 ++++ .../Stats/Daily/StatHrAttendanceDaily.php | 17 ++ app/Models/Stats/Daily/StatInventoryDaily.php | 22 +++ .../Stats/Daily/StatQuotePipelineDaily.php | 18 ++ app/Models/Stats/Dimensions/DimClient.php | 18 ++ app/Models/Stats/Dimensions/DimProduct.php | 18 ++ app/Models/Stats/StatAlert.php | 19 ++ app/Models/Stats/StatKpiTarget.php | 12 ++ app/Services/Stats/DimensionSyncService.php | 163 ++++++++++++++++++ app/Services/Stats/HrStatService.php | 87 ++++++++++ app/Services/Stats/InventoryStatService.php | 97 +++++++++++ app/Services/Stats/KpiAlertService.php | 148 ++++++++++++++++ app/Services/Stats/QuoteStatService.php | 93 ++++++++++ app/Services/Stats/StatAggregatorService.php | 6 + ...6_01_29_193546_create_dim_client_table.php | 35 ++++ ..._01_29_193546_create_dim_product_table.php | 35 ++++ ...3547_create_stat_inventory_daily_table.php | 56 ++++++ ...create_stat_quote_pipeline_daily_table.php | 51 ++++++ ..._create_stat_hr_attendance_daily_table.php | 49 ++++++ ...9_193548_create_stat_kpi_targets_table.php | 37 ++++ ..._01_29_193549_create_stat_alerts_table.php | 40 +++++ routes/console.php | 11 ++ 22 files changed, 1065 insertions(+) create mode 100644 app/Console/Commands/StatCheckKpiAlertsCommand.php create mode 100644 app/Models/Stats/Daily/StatHrAttendanceDaily.php create mode 100644 app/Models/Stats/Daily/StatInventoryDaily.php create mode 100644 app/Models/Stats/Daily/StatQuotePipelineDaily.php create mode 100644 app/Models/Stats/Dimensions/DimClient.php create mode 100644 app/Models/Stats/Dimensions/DimProduct.php create mode 100644 app/Models/Stats/StatAlert.php create mode 100644 app/Models/Stats/StatKpiTarget.php create mode 100644 app/Services/Stats/DimensionSyncService.php create mode 100644 app/Services/Stats/HrStatService.php create mode 100644 app/Services/Stats/InventoryStatService.php create mode 100644 app/Services/Stats/KpiAlertService.php create mode 100644 app/Services/Stats/QuoteStatService.php create mode 100644 database/migrations/stats/2026_01_29_193546_create_dim_client_table.php create mode 100644 database/migrations/stats/2026_01_29_193546_create_dim_product_table.php create mode 100644 database/migrations/stats/2026_01_29_193547_create_stat_inventory_daily_table.php create mode 100644 database/migrations/stats/2026_01_29_193547_create_stat_quote_pipeline_daily_table.php create mode 100644 database/migrations/stats/2026_01_29_193548_create_stat_hr_attendance_daily_table.php create mode 100644 database/migrations/stats/2026_01_29_193548_create_stat_kpi_targets_table.php create mode 100644 database/migrations/stats/2026_01_29_193549_create_stat_alerts_table.php diff --git a/app/Console/Commands/StatCheckKpiAlertsCommand.php b/app/Console/Commands/StatCheckKpiAlertsCommand.php new file mode 100644 index 0000000..204175c --- /dev/null +++ b/app/Console/Commands/StatCheckKpiAlertsCommand.php @@ -0,0 +1,33 @@ +info('KPI 알림 체크 시작...'); + + $result = $service->checkKpiAlerts(); + + $this->info("알림 생성: {$result['alerts_created']}건"); + + if (! empty($result['errors'])) { + $this->warn('오류 발생:'); + foreach ($result['errors'] as $error) { + $this->error(" - {$error}"); + } + } + + $this->info('KPI 알림 체크 완료.'); + + return empty($result['errors']) ? self::SUCCESS : self::FAILURE; + } +} diff --git a/app/Models/Stats/Daily/StatHrAttendanceDaily.php b/app/Models/Stats/Daily/StatHrAttendanceDaily.php new file mode 100644 index 0000000..91b0444 --- /dev/null +++ b/app/Models/Stats/Daily/StatHrAttendanceDaily.php @@ -0,0 +1,17 @@ + 'date', + 'attendance_rate' => 'decimal:2', + 'overtime_hours' => 'decimal:2', + 'total_labor_cost' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/Daily/StatInventoryDaily.php b/app/Models/Stats/Daily/StatInventoryDaily.php new file mode 100644 index 0000000..ee9b227 --- /dev/null +++ b/app/Models/Stats/Daily/StatInventoryDaily.php @@ -0,0 +1,22 @@ + 'date', + 'total_stock_qty' => 'decimal:2', + 'total_stock_value' => 'decimal:2', + 'receipt_qty' => 'decimal:2', + 'receipt_amount' => 'decimal:2', + 'issue_qty' => 'decimal:2', + 'issue_amount' => 'decimal:2', + 'inspection_pass_rate' => 'decimal:2', + 'turnover_rate' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/Daily/StatQuotePipelineDaily.php b/app/Models/Stats/Daily/StatQuotePipelineDaily.php new file mode 100644 index 0000000..2449a83 --- /dev/null +++ b/app/Models/Stats/Daily/StatQuotePipelineDaily.php @@ -0,0 +1,18 @@ + 'date', + 'quote_amount' => 'decimal:2', + 'quote_conversion_rate' => 'decimal:2', + 'prospect_amount' => 'decimal:2', + 'bidding_amount' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/Dimensions/DimClient.php b/app/Models/Stats/Dimensions/DimClient.php new file mode 100644 index 0000000..9c7dbe6 --- /dev/null +++ b/app/Models/Stats/Dimensions/DimClient.php @@ -0,0 +1,18 @@ + 'date', + 'valid_to' => 'date', + 'is_current' => 'boolean', + ]; +} diff --git a/app/Models/Stats/Dimensions/DimProduct.php b/app/Models/Stats/Dimensions/DimProduct.php new file mode 100644 index 0000000..b8fc9da --- /dev/null +++ b/app/Models/Stats/Dimensions/DimProduct.php @@ -0,0 +1,18 @@ + 'date', + 'valid_to' => 'date', + 'is_current' => 'boolean', + ]; +} diff --git a/app/Models/Stats/StatAlert.php b/app/Models/Stats/StatAlert.php new file mode 100644 index 0000000..ad5fabe --- /dev/null +++ b/app/Models/Stats/StatAlert.php @@ -0,0 +1,19 @@ + 'decimal:2', + 'threshold_value' => 'decimal:2', + 'is_read' => 'boolean', + 'is_resolved' => 'boolean', + 'resolved_at' => 'datetime', + 'created_at' => 'datetime', + ]; +} diff --git a/app/Models/Stats/StatKpiTarget.php b/app/Models/Stats/StatKpiTarget.php new file mode 100644 index 0000000..14fa00f --- /dev/null +++ b/app/Models/Stats/StatKpiTarget.php @@ -0,0 +1,12 @@ + 'decimal:2', + ]; +} diff --git a/app/Services/Stats/DimensionSyncService.php b/app/Services/Stats/DimensionSyncService.php new file mode 100644 index 0000000..8f9ed63 --- /dev/null +++ b/app/Services/Stats/DimensionSyncService.php @@ -0,0 +1,163 @@ +format('Y-m-d'); + $synced = 0; + + $clients = DB::connection('mysql') + ->table('clients') + ->where('tenant_id', $tenantId) + ->select('id', 'tenant_id', 'name', 'client_group_id', 'client_type') + ->get(); + + foreach ($clients as $client) { + $groupName = null; + if ($client->client_group_id) { + $groupName = DB::connection('mysql') + ->table('client_groups') + ->where('id', $client->client_group_id) + ->value('group_name'); + } + + $current = DimClient::where('tenant_id', $tenantId) + ->where('client_id', $client->id) + ->where('is_current', true) + ->first(); + + if (! $current) { + DimClient::create([ + 'tenant_id' => $tenantId, + 'client_id' => $client->id, + 'client_name' => $client->name, + 'client_group_id' => $client->client_group_id, + 'client_group_name' => $groupName, + 'client_type' => $client->client_type, + 'region' => null, + 'valid_from' => $today, + 'valid_to' => null, + 'is_current' => true, + ]); + $synced++; + + continue; + } + + $changed = $current->client_name !== $client->name + || $current->client_group_id != $client->client_group_id + || $current->client_type !== $client->client_type; + + if ($changed) { + $current->update([ + 'valid_to' => $today, + 'is_current' => false, + ]); + + DimClient::create([ + 'tenant_id' => $tenantId, + 'client_id' => $client->id, + 'client_name' => $client->name, + 'client_group_id' => $client->client_group_id, + 'client_group_name' => $groupName, + 'client_type' => $client->client_type, + 'region' => null, + 'valid_from' => $today, + 'valid_to' => null, + 'is_current' => true, + ]); + $synced++; + } + } + + return $synced; + } + + /** + * 제품(품목) 차원 동기화 (SCD Type 2) + */ + public function syncProducts(int $tenantId): int + { + $today = Carbon::today()->format('Y-m-d'); + $synced = 0; + + $items = DB::connection('mysql') + ->table('items') + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->select('id', 'tenant_id', 'code', 'name', 'item_type', 'category_id') + ->get(); + + foreach ($items as $item) { + $categoryName = null; + if ($item->category_id) { + $categoryName = DB::connection('mysql') + ->table('categories') + ->where('id', $item->category_id) + ->value('name'); + } + + $current = DimProduct::where('tenant_id', $tenantId) + ->where('item_id', $item->id) + ->where('is_current', true) + ->first(); + + if (! $current) { + DimProduct::create([ + 'tenant_id' => $tenantId, + 'item_id' => $item->id, + 'item_code' => $item->code, + 'item_name' => $item->name, + 'item_type' => $item->item_type, + 'category_id' => $item->category_id, + 'category_name' => $categoryName, + 'valid_from' => $today, + 'valid_to' => null, + 'is_current' => true, + ]); + $synced++; + + continue; + } + + $changed = $current->item_name !== $item->name + || $current->item_code !== $item->code + || $current->item_type !== $item->item_type + || $current->category_id != $item->category_id; + + if ($changed) { + $current->update([ + 'valid_to' => $today, + 'is_current' => false, + ]); + + DimProduct::create([ + 'tenant_id' => $tenantId, + 'item_id' => $item->id, + 'item_code' => $item->code, + 'item_name' => $item->name, + 'item_type' => $item->item_type, + 'category_id' => $item->category_id, + 'category_name' => $categoryName, + 'valid_from' => $today, + 'valid_to' => null, + 'is_current' => true, + ]); + $synced++; + } + } + + return $synced; + } +} diff --git a/app/Services/Stats/HrStatService.php b/app/Services/Stats/HrStatService.php new file mode 100644 index 0000000..6b2b71e --- /dev/null +++ b/app/Services/Stats/HrStatService.php @@ -0,0 +1,87 @@ +format('Y-m-d'); + + // 전체 직원 수 (tenant_user_profiles 기준) + $totalEmployees = DB::connection('mysql') + ->table('user_tenants') + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->count(); + + // 근태 (attendances) + $attendanceStats = DB::connection('mysql') + ->table('attendances') + ->where('tenant_id', $tenantId) + ->where('base_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw(" + COUNT(*) as total_count, + SUM(CASE WHEN status = 'onTime' THEN 1 ELSE 0 END) as on_time_count, + SUM(CASE WHEN status = 'late' THEN 1 ELSE 0 END) as late_count, + SUM(CASE WHEN status = 'absent' THEN 1 ELSE 0 END) as absent_count, + SUM(CASE WHEN status = 'overtime' THEN 1 ELSE 0 END) as overtime_count + ") + ->first(); + + $attendanceCount = ($attendanceStats->on_time_count ?? 0) + + ($attendanceStats->late_count ?? 0) + + ($attendanceStats->overtime_count ?? 0); + $attendanceRate = $totalEmployees > 0 ? ($attendanceCount / $totalEmployees) * 100 : 0; + + // 휴가 (leaves) + $leaveStats = DB::connection('mysql') + ->table('leaves') + ->where('tenant_id', $tenantId) + ->where('start_date', '<=', $dateStr) + ->where('end_date', '>=', $dateStr) + ->where('status', 'approved') + ->whereNull('deleted_at') + ->selectRaw(" + COUNT(*) as total_count, + SUM(CASE WHEN leave_type = 'annual' THEN 1 ELSE 0 END) as annual_count, + SUM(CASE WHEN leave_type = 'sick' THEN 1 ELSE 0 END) as sick_count, + SUM(CASE WHEN leave_type NOT IN ('annual', 'sick') THEN 1 ELSE 0 END) as other_count + ") + ->first(); + + // 초과근무 (attendances status = 'overtime') + $overtimeCount = $attendanceStats->overtime_count ?? 0; + + StatHrAttendanceDaily::updateOrCreate( + ['tenant_id' => $tenantId, 'stat_date' => $dateStr], + [ + 'total_employees' => $totalEmployees, + 'attendance_count' => $attendanceCount, + 'late_count' => $attendanceStats->late_count ?? 0, + 'absent_count' => $attendanceStats->absent_count ?? 0, + 'attendance_rate' => $attendanceRate, + 'leave_count' => $leaveStats->total_count ?? 0, + 'leave_annual_count' => $leaveStats->annual_count ?? 0, + 'leave_sick_count' => $leaveStats->sick_count ?? 0, + 'leave_other_count' => $leaveStats->other_count ?? 0, + 'overtime_hours' => 0, // attendances에 시간 정보 없음 + 'overtime_employee_count' => $overtimeCount, + 'total_labor_cost' => 0, // 일간 인건비는 급여 정산 시 계산 + ] + ); + + return 1; + } + + public function aggregateMonthly(int $tenantId, int $year, int $month): int + { + // 인사 도메인은 일간 테이블만 운영 (월간은 Phase 4에서 필요시 추가) + return 0; + } +} diff --git a/app/Services/Stats/InventoryStatService.php b/app/Services/Stats/InventoryStatService.php new file mode 100644 index 0000000..4002b3c --- /dev/null +++ b/app/Services/Stats/InventoryStatService.php @@ -0,0 +1,97 @@ +format('Y-m-d'); + + // 재고 현황 (stocks 테이블 - 현재 스냅샷) + $stockSummary = DB::connection('mysql') + ->table('stocks') + ->where('tenant_id', $tenantId) + ->whereNull('deleted_at') + ->selectRaw(' + COUNT(*) as sku_count, + COALESCE(SUM(stock_qty), 0) as total_qty, + SUM(CASE WHEN stock_qty < safety_stock AND safety_stock > 0 THEN 1 ELSE 0 END) as below_safety, + SUM(CASE WHEN stock_qty = 0 THEN 1 ELSE 0 END) as zero_stock, + SUM(CASE WHEN stock_qty > safety_stock * 3 AND safety_stock > 0 THEN 1 ELSE 0 END) as excess_stock + ') + ->first(); + + // 입고 (stock_transactions type = 'receipt') + $receiptStats = DB::connection('mysql') + ->table('stock_transactions') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->where('type', 'receipt') + ->selectRaw('COUNT(*) as cnt, COALESCE(SUM(qty), 0) as total_qty') + ->first(); + + // 출고 (stock_transactions type = 'issue') + $issueStats = DB::connection('mysql') + ->table('stock_transactions') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->where('type', 'issue') + ->selectRaw('COUNT(*) as cnt, COALESCE(SUM(ABS(qty)), 0) as total_qty') + ->first(); + + // 품질검사 (inspections) + $inspectionStats = DB::connection('mysql') + ->table('inspections') + ->where('tenant_id', $tenantId) + ->where('inspection_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw(" + COUNT(*) as cnt, + SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) as pass_count, + SUM(CASE WHEN result = 'fail' THEN 1 ELSE 0 END) as fail_count + ") + ->first(); + + $inspectionCount = $inspectionStats->cnt ?? 0; + $passCount = $inspectionStats->pass_count ?? 0; + $failCount = $inspectionStats->fail_count ?? 0; + $passRate = $inspectionCount > 0 ? ($passCount / $inspectionCount) * 100 : 0; + + StatInventoryDaily::updateOrCreate( + ['tenant_id' => $tenantId, 'stat_date' => $dateStr], + [ + 'total_sku_count' => $stockSummary->sku_count ?? 0, + 'total_stock_qty' => $stockSummary->total_qty ?? 0, + 'total_stock_value' => 0, // 단가 정보 없어 Phase 4에서 보완 + 'receipt_count' => $receiptStats->cnt ?? 0, + 'receipt_qty' => $receiptStats->total_qty ?? 0, + 'receipt_amount' => 0, + 'issue_count' => $issueStats->cnt ?? 0, + 'issue_qty' => $issueStats->total_qty ?? 0, + 'issue_amount' => 0, + 'below_safety_count' => $stockSummary->below_safety ?? 0, + 'zero_stock_count' => $stockSummary->zero_stock ?? 0, + 'excess_stock_count' => $stockSummary->excess_stock ?? 0, + 'inspection_count' => $inspectionCount, + 'inspection_pass_count' => $passCount, + 'inspection_fail_count' => $failCount, + 'inspection_pass_rate' => $passRate, + 'turnover_rate' => 0, // 월간 집계에서 계산 + ] + ); + + return 1; + } + + public function aggregateMonthly(int $tenantId, int $year, int $month): int + { + // 재고 도메인은 일간 스냅샷 기반이므로 별도 월간 테이블 없음 + // 필요시 Phase 4에서 stat_inventory_monthly 추가 + return 0; + } +} diff --git a/app/Services/Stats/KpiAlertService.php b/app/Services/Stats/KpiAlertService.php new file mode 100644 index 0000000..d0119fb --- /dev/null +++ b/app/Services/Stats/KpiAlertService.php @@ -0,0 +1,148 @@ +get(); + + foreach ($tenants as $tenant) { + try { + $alertsCreated += $this->checkTenantKpi($tenant->id); + } catch (\Throwable $e) { + $errors[] = "tenant={$tenant->id}: {$e->getMessage()}"; + Log::error('stat:check-kpi-alerts 실패', [ + 'tenant_id' => $tenant->id, + 'error' => $e->getMessage(), + ]); + } + } + + return [ + 'alerts_created' => $alertsCreated, + 'errors' => $errors, + ]; + } + + private function checkTenantKpi(int $tenantId): int + { + $now = Carbon::now(); + $year = $now->year; + $month = $now->month; + $alertsCreated = 0; + + $targets = StatKpiTarget::where('tenant_id', $tenantId) + ->where('stat_year', $year) + ->where(function ($q) use ($month) { + $q->where('stat_month', $month)->orWhereNull('stat_month'); + }) + ->get(); + + foreach ($targets as $target) { + $currentValue = $this->getCurrentValue($tenantId, $target->domain, $target->metric_code, $year, $month); + + if ($currentValue === null) { + continue; + } + + $ratio = $target->target_value > 0 + ? ($currentValue / $target->target_value) * 100 + : 0; + + if ($ratio < 50) { + $severity = 'critical'; + } elseif ($ratio < 80) { + $severity = 'warning'; + } else { + continue; // 80% 이상이면 알림 불필요 + } + + StatAlert::create([ + 'tenant_id' => $tenantId, + 'domain' => $target->domain, + 'alert_type' => 'below_target', + 'severity' => $severity, + 'title' => "{$target->description} 목표 미달 (".number_format($ratio, 0).'%)', + 'message' => "KPI '{$target->metric_code}' 현재값: {$currentValue}, 목표값: {$target->target_value} ({$target->unit})", + 'metric_code' => $target->metric_code, + 'current_value' => $currentValue, + 'threshold_value' => $target->target_value, + 'created_at' => now(), + ]); + $alertsCreated++; + } + + return $alertsCreated; + } + + private function getCurrentValue(int $tenantId, string $domain, string $metricCode, int $year, int $month): ?float + { + return match ($domain) { + 'sales' => $this->getSalesMetric($tenantId, $metricCode, $year, $month), + 'finance' => $this->getFinanceMetric($tenantId, $metricCode, $year, $month), + default => null, + }; + } + + private function getSalesMetric(int $tenantId, string $metricCode, int $year, int $month): ?float + { + $monthly = StatSalesMonthly::where('tenant_id', $tenantId) + ->where('stat_year', $year) + ->where('stat_month', $month) + ->first(); + + if (! $monthly) { + // 월간 미집계 시 일간 합산 + $daily = StatSalesDaily::where('tenant_id', $tenantId) + ->whereYear('stat_date', $year) + ->whereMonth('stat_date', $month) + ->selectRaw('SUM(order_amount) as order_total, SUM(sales_amount) as sales_total') + ->first(); + + return match ($metricCode) { + 'monthly_order_amount' => (float) ($daily->order_total ?? 0), + 'monthly_sales_amount' => (float) ($daily->sales_total ?? 0), + default => null, + }; + } + + return match ($metricCode) { + 'monthly_order_amount' => (float) $monthly->order_amount, + 'monthly_sales_amount' => (float) $monthly->sales_amount, + 'monthly_order_count' => (float) $monthly->order_count, + default => null, + }; + } + + private function getFinanceMetric(int $tenantId, string $metricCode, int $year, int $month): ?float + { + $daily = StatFinanceDaily::where('tenant_id', $tenantId) + ->whereYear('stat_date', $year) + ->whereMonth('stat_date', $month) + ->selectRaw('SUM(deposit_amount) as deposit_total, SUM(withdrawal_amount) as withdrawal_total') + ->first(); + + return match ($metricCode) { + 'monthly_deposit_amount' => (float) ($daily->deposit_total ?? 0), + 'monthly_withdrawal_amount' => (float) ($daily->withdrawal_total ?? 0), + default => null, + }; + } +} diff --git a/app/Services/Stats/QuoteStatService.php b/app/Services/Stats/QuoteStatService.php new file mode 100644 index 0000000..5a09be9 --- /dev/null +++ b/app/Services/Stats/QuoteStatService.php @@ -0,0 +1,93 @@ +format('Y-m-d'); + + // 견적 (quotes) + $quoteStats = DB::connection('mysql') + ->table('quotes') + ->where('tenant_id', $tenantId) + ->where('registration_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw(" + COUNT(*) as created_count, + COALESCE(SUM(total_amount), 0) as total_amount, + SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved_count, + SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected_count, + SUM(CASE WHEN status = 'converted' THEN 1 ELSE 0 END) as conversion_count + ") + ->first(); + + $createdCount = $quoteStats->created_count ?? 0; + $conversionCount = $quoteStats->conversion_count ?? 0; + $conversionRate = $createdCount > 0 ? ($conversionCount / $createdCount) * 100 : 0; + + // 입찰 (biddings) + $biddingStats = DB::connection('mysql') + ->table('biddings') + ->where('tenant_id', $tenantId) + ->where('bidding_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw(" + COUNT(*) as cnt, + SUM(CASE WHEN status = 'won' THEN 1 ELSE 0 END) as won_count, + COALESCE(SUM(bidding_amount), 0) as total_amount + ") + ->first(); + + // 상담 (sales_prospect_consultations) + $consultationCount = DB::connection('mysql') + ->table('sales_prospect_consultations') + ->whereDate('created_at', $dateStr) + ->count(); + + // 영업 기회 (sales_prospects - tenant_id 없음, created_at 기반) + $prospectStats = DB::connection('mysql') + ->table('sales_prospects') + ->whereDate('created_at', $dateStr) + ->whereNull('deleted_at') + ->selectRaw(" + COUNT(*) as created_count, + SUM(CASE WHEN status = 'contracted' THEN 1 ELSE 0 END) as won_count, + SUM(CASE WHEN status = 'lost' THEN 1 ELSE 0 END) as lost_count + ") + ->first(); + + StatQuotePipelineDaily::updateOrCreate( + ['tenant_id' => $tenantId, 'stat_date' => $dateStr], + [ + 'quote_created_count' => $createdCount, + 'quote_amount' => $quoteStats->total_amount ?? 0, + 'quote_approved_count' => $quoteStats->approved_count ?? 0, + 'quote_rejected_count' => $quoteStats->rejected_count ?? 0, + 'quote_conversion_count' => $conversionCount, + 'quote_conversion_rate' => $conversionRate, + 'prospect_created_count' => $prospectStats->created_count ?? 0, + 'prospect_won_count' => $prospectStats->won_count ?? 0, + 'prospect_lost_count' => $prospectStats->lost_count ?? 0, + 'prospect_amount' => 0, // sales_prospects에 금액 컬럼 없음 + 'bidding_count' => $biddingStats->cnt ?? 0, + 'bidding_won_count' => $biddingStats->won_count ?? 0, + 'bidding_amount' => $biddingStats->total_amount ?? 0, + 'consultation_count' => $consultationCount, + ] + ); + + return 1; + } + + public function aggregateMonthly(int $tenantId, int $year, int $month): int + { + // 견적 도메인은 일간 테이블만 운영 (월간은 Phase 4에서 필요시 추가) + return 0; + } +} diff --git a/app/Services/Stats/StatAggregatorService.php b/app/Services/Stats/StatAggregatorService.php index 03597fd..6dcc191 100644 --- a/app/Services/Stats/StatAggregatorService.php +++ b/app/Services/Stats/StatAggregatorService.php @@ -18,6 +18,9 @@ private function getDailyDomainServices(): array 'sales' => SalesStatService::class, 'finance' => FinanceStatService::class, 'production' => ProductionStatService::class, + 'inventory' => InventoryStatService::class, + 'quote' => QuoteStatService::class, + 'hr' => HrStatService::class, ]; } @@ -30,6 +33,9 @@ private function getMonthlyDomainServices(): array 'sales' => SalesStatService::class, 'finance' => FinanceStatService::class, 'production' => ProductionStatService::class, + 'inventory' => InventoryStatService::class, + 'quote' => QuoteStatService::class, + 'hr' => HrStatService::class, ]; } diff --git a/database/migrations/stats/2026_01_29_193546_create_dim_client_table.php b/database/migrations/stats/2026_01_29_193546_create_dim_client_table.php new file mode 100644 index 0000000..7c02b5d --- /dev/null +++ b/database/migrations/stats/2026_01_29_193546_create_dim_client_table.php @@ -0,0 +1,35 @@ +connection)->create('dim_client', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('client_id')->comment('원본 clients.id'); + $table->string('client_name', 200); + $table->unsignedBigInteger('client_group_id')->nullable(); + $table->string('client_group_name', 200)->nullable(); + $table->string('client_type', 50)->nullable()->comment('고객/공급업체/양쪽'); + $table->string('region', 100)->nullable(); + $table->date('valid_from'); + $table->date('valid_to')->nullable()->comment('NULL = 현재 유효'); + $table->boolean('is_current')->default(true); + + $table->index(['tenant_id', 'client_id'], 'idx_tenant_client'); + $table->index('is_current', 'idx_current'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('dim_client'); + } +}; \ No newline at end of file diff --git a/database/migrations/stats/2026_01_29_193546_create_dim_product_table.php b/database/migrations/stats/2026_01_29_193546_create_dim_product_table.php new file mode 100644 index 0000000..60b29aa --- /dev/null +++ b/database/migrations/stats/2026_01_29_193546_create_dim_product_table.php @@ -0,0 +1,35 @@ +connection)->create('dim_product', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('item_id')->comment('원본 items.id'); + $table->string('item_code', 100); + $table->string('item_name', 300); + $table->string('item_type', 50)->nullable()->comment('item_type from items'); + $table->unsignedBigInteger('category_id')->nullable(); + $table->string('category_name', 200)->nullable(); + $table->date('valid_from'); + $table->date('valid_to')->nullable()->comment('NULL = 현재 유효'); + $table->boolean('is_current')->default(true); + + $table->index(['tenant_id', 'item_id'], 'idx_tenant_item'); + $table->index('is_current', 'idx_current'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('dim_product'); + } +}; \ No newline at end of file diff --git a/database/migrations/stats/2026_01_29_193547_create_stat_inventory_daily_table.php b/database/migrations/stats/2026_01_29_193547_create_stat_inventory_daily_table.php new file mode 100644 index 0000000..fe87481 --- /dev/null +++ b/database/migrations/stats/2026_01_29_193547_create_stat_inventory_daily_table.php @@ -0,0 +1,56 @@ +connection)->create('stat_inventory_daily', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->date('stat_date'); + + // 재고 현황 + $table->unsignedInteger('total_sku_count')->default(0)->comment('총 SKU 수'); + $table->decimal('total_stock_qty', 18, 2)->default(0)->comment('총 재고 수량'); + $table->decimal('total_stock_value', 18, 2)->default(0)->comment('총 재고 금액'); + + // 입출고 + $table->unsignedInteger('receipt_count')->default(0)->comment('입고 건수'); + $table->decimal('receipt_qty', 18, 2)->default(0); + $table->decimal('receipt_amount', 18, 2)->default(0); + $table->unsignedInteger('issue_count')->default(0)->comment('출고 건수'); + $table->decimal('issue_qty', 18, 2)->default(0); + $table->decimal('issue_amount', 18, 2)->default(0); + + // 안전재고 + $table->unsignedInteger('below_safety_count')->default(0)->comment('안전재고 미달 품목 수'); + $table->unsignedInteger('zero_stock_count')->default(0)->comment('재고 0 품목 수'); + $table->unsignedInteger('excess_stock_count')->default(0)->comment('과잉 재고 품목 수'); + + // 품질검사 + $table->unsignedInteger('inspection_count')->default(0); + $table->unsignedInteger('inspection_pass_count')->default(0); + $table->unsignedInteger('inspection_fail_count')->default(0); + $table->decimal('inspection_pass_rate', 5, 2)->default(0)->comment('합격률 (%)'); + + // 재고회전 + $table->decimal('turnover_rate', 8, 2)->default(0)->comment('재고회전율'); + + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_date'], 'uk_tenant_date'); + $table->index('stat_date'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_inventory_daily'); + } +}; \ No newline at end of file diff --git a/database/migrations/stats/2026_01_29_193547_create_stat_quote_pipeline_daily_table.php b/database/migrations/stats/2026_01_29_193547_create_stat_quote_pipeline_daily_table.php new file mode 100644 index 0000000..721af7d --- /dev/null +++ b/database/migrations/stats/2026_01_29_193547_create_stat_quote_pipeline_daily_table.php @@ -0,0 +1,51 @@ +connection)->create('stat_quote_pipeline_daily', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->date('stat_date'); + + // 견적 + $table->unsignedInteger('quote_created_count')->default(0); + $table->decimal('quote_amount', 18, 2)->default(0); + $table->unsignedInteger('quote_approved_count')->default(0); + $table->unsignedInteger('quote_rejected_count')->default(0); + $table->unsignedInteger('quote_conversion_count')->default(0)->comment('수주 전환 건수'); + $table->decimal('quote_conversion_rate', 5, 2)->default(0)->comment('전환율 (%)'); + + // 영업 기회 (sales_prospects - tenant_id 없어 manager_id로 연결) + $table->unsignedInteger('prospect_created_count')->default(0); + $table->unsignedInteger('prospect_won_count')->default(0); + $table->unsignedInteger('prospect_lost_count')->default(0); + $table->decimal('prospect_amount', 18, 2)->default(0)->comment('파이프라인 금액'); + + // 입찰 + $table->unsignedInteger('bidding_count')->default(0); + $table->unsignedInteger('bidding_won_count')->default(0); + $table->decimal('bidding_amount', 18, 2)->default(0); + + // 상담 + $table->unsignedInteger('consultation_count')->default(0); + + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_date'], 'uk_tenant_date'); + $table->index('stat_date'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_quote_pipeline_daily'); + } +}; \ No newline at end of file diff --git a/database/migrations/stats/2026_01_29_193548_create_stat_hr_attendance_daily_table.php b/database/migrations/stats/2026_01_29_193548_create_stat_hr_attendance_daily_table.php new file mode 100644 index 0000000..e942c75 --- /dev/null +++ b/database/migrations/stats/2026_01_29_193548_create_stat_hr_attendance_daily_table.php @@ -0,0 +1,49 @@ +connection)->create('stat_hr_attendance_daily', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->date('stat_date'); + + // 근태 + $table->unsignedInteger('total_employees')->default(0)->comment('전체 직원 수'); + $table->unsignedInteger('attendance_count')->default(0)->comment('출근 인원'); + $table->unsignedInteger('late_count')->default(0)->comment('지각'); + $table->unsignedInteger('absent_count')->default(0)->comment('결근'); + $table->decimal('attendance_rate', 5, 2)->default(0)->comment('출근율 (%)'); + + // 휴가 + $table->unsignedInteger('leave_count')->default(0)->comment('휴가 사용'); + $table->unsignedInteger('leave_annual_count')->default(0)->comment('연차'); + $table->unsignedInteger('leave_sick_count')->default(0)->comment('병가'); + $table->unsignedInteger('leave_other_count')->default(0)->comment('기타'); + + // 초과근무 + $table->decimal('overtime_hours', 10, 2)->default(0); + $table->unsignedInteger('overtime_employee_count')->default(0); + + // 인건비 + $table->decimal('total_labor_cost', 18, 2)->default(0); + + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_date'], 'uk_tenant_date'); + $table->index('stat_date'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_hr_attendance_daily'); + } +}; \ No newline at end of file diff --git a/database/migrations/stats/2026_01_29_193548_create_stat_kpi_targets_table.php b/database/migrations/stats/2026_01_29_193548_create_stat_kpi_targets_table.php new file mode 100644 index 0000000..a05695c --- /dev/null +++ b/database/migrations/stats/2026_01_29_193548_create_stat_kpi_targets_table.php @@ -0,0 +1,37 @@ +connection)->create('stat_kpi_targets', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->smallInteger('stat_year'); + $table->tinyInteger('stat_month')->nullable()->comment('NULL = 연간 목표'); + + $table->string('domain', 50)->comment('sales, production, finance 등'); + $table->string('metric_code', 100)->comment('monthly_sales_amount 등'); + $table->decimal('target_value', 18, 2); + $table->string('unit', 20)->default('KRW')->comment('KRW, %, count, hours'); + $table->string('description', 300)->nullable(); + + $table->unsignedBigInteger('created_by')->nullable(); + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_year', 'stat_month', 'metric_code'], 'uk_tenant_metric'); + $table->index('domain', 'idx_domain'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_kpi_targets'); + } +}; \ No newline at end of file diff --git a/database/migrations/stats/2026_01_29_193549_create_stat_alerts_table.php b/database/migrations/stats/2026_01_29_193549_create_stat_alerts_table.php new file mode 100644 index 0000000..38972d3 --- /dev/null +++ b/database/migrations/stats/2026_01_29_193549_create_stat_alerts_table.php @@ -0,0 +1,40 @@ +connection)->create('stat_alerts', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('domain', 50); + $table->string('alert_type', 100)->comment('below_target, anomaly, threshold'); + $table->enum('severity', ['info', 'warning', 'critical'])->default('info'); + $table->string('title', 300); + $table->text('message'); + $table->string('metric_code', 100)->nullable(); + $table->decimal('current_value', 18, 2)->nullable(); + $table->decimal('threshold_value', 18, 2)->nullable(); + $table->boolean('is_read')->default(false); + $table->boolean('is_resolved')->default(false); + $table->timestamp('resolved_at')->nullable(); + $table->unsignedBigInteger('resolved_by')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index(['tenant_id', 'is_read'], 'idx_tenant_unread'); + $table->index('severity', 'idx_severity'); + $table->index('domain', 'idx_domain'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_alerts'); + } +}; \ No newline at end of file diff --git a/routes/console.php b/routes/console.php index 0e9025a..190c612 100644 --- a/routes/console.php +++ b/routes/console.php @@ -119,3 +119,14 @@ ->onFailure(function () { \Illuminate\Support\Facades\Log::error('❌ stat:aggregate-monthly 스케줄러 실행 실패', ['time' => now()]); }); + +// 매일 오전 09:00에 KPI 목표 대비 알림 체크 +Schedule::command('stat:check-kpi-alerts') + ->dailyAt('09:00') + ->appendOutputTo(storage_path('logs/scheduler.log')) + ->onSuccess(function () { + \Illuminate\Support\Facades\Log::info('✅ stat:check-kpi-alerts 스케줄러 실행 성공', ['time' => now()]); + }) + ->onFailure(function () { + \Illuminate\Support\Facades\Log::error('❌ stat:check-kpi-alerts 스케줄러 실행 실패', ['time' => now()]); + }); From 4d8dac1091d63048812549556c1a26e039680096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 21:56:53 +0900 Subject: [PATCH 50/57] =?UTF-8?q?feat:=20sam=5Fstat=20P2=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20+=20=ED=86=B5=EA=B3=84=20API=20+=20?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=A0=84=ED=99=98=20(Ph?= =?UTF-8?q?ase=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 4.1: stat_project_monthly + ProjectStatService (건설/프로젝트 월간) - 4.2: stat_system_daily + SystemStatService (API/감사/FCM/파일/결재) - 4.3: stat_events, stat_snapshots + StatEventService + StatEventObserver - 4.4: StatController (summary/daily/monthly/alerts) + StatQueryService + FormRequest 3개 + routes/stats.php - 4.5: DashboardService sam_stat 우선 조회 + 원본 DB 폴백 패턴 Co-Authored-By: Claude Opus 4.5 --- .../Controllers/Api/V1/StatController.php | 64 +++++++ .../Requests/V1/Stat/StatAlertRequest.php | 21 ++ .../Requests/V1/Stat/StatDailyRequest.php | 22 +++ .../Requests/V1/Stat/StatMonthlyRequest.php | 22 +++ app/Models/Stats/Daily/StatSystemDaily.php | 16 ++ .../Stats/Monthly/StatProjectMonthly.php | 20 ++ app/Models/Stats/StatEvent.php | 15 ++ app/Models/Stats/StatSnapshot.php | 16 ++ app/Observers/StatEventObserver.php | 96 ++++++++++ app/Providers/AppServiceProvider.php | 10 + app/Services/DashboardService.php | 44 ++++- app/Services/Stats/ProjectStatService.php | 92 +++++++++ app/Services/Stats/StatAggregatorService.php | 2 + app/Services/Stats/StatEventService.php | 74 ++++++++ app/Services/Stats/StatQueryService.php | 179 ++++++++++++++++++ app/Services/Stats/SystemStatService.php | 135 +++++++++++++ ...0001_create_stat_project_monthly_table.php | 50 +++++ ..._200002_create_stat_system_daily_table.php | 56 ++++++ ..._01_29_200003_create_stat_events_table.php | 33 ++++ ..._29_200004_create_stat_snapshots_table.php | 31 +++ routes/api.php | 1 + routes/api/v1/stats.php | 19 ++ 22 files changed, 1011 insertions(+), 7 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/StatController.php create mode 100644 app/Http/Requests/V1/Stat/StatAlertRequest.php create mode 100644 app/Http/Requests/V1/Stat/StatDailyRequest.php create mode 100644 app/Http/Requests/V1/Stat/StatMonthlyRequest.php create mode 100644 app/Models/Stats/Daily/StatSystemDaily.php create mode 100644 app/Models/Stats/Monthly/StatProjectMonthly.php create mode 100644 app/Models/Stats/StatEvent.php create mode 100644 app/Models/Stats/StatSnapshot.php create mode 100644 app/Observers/StatEventObserver.php create mode 100644 app/Services/Stats/ProjectStatService.php create mode 100644 app/Services/Stats/StatEventService.php create mode 100644 app/Services/Stats/StatQueryService.php create mode 100644 app/Services/Stats/SystemStatService.php create mode 100644 database/migrations/stats/2026_01_29_200001_create_stat_project_monthly_table.php create mode 100644 database/migrations/stats/2026_01_29_200002_create_stat_system_daily_table.php create mode 100644 database/migrations/stats/2026_01_29_200003_create_stat_events_table.php create mode 100644 database/migrations/stats/2026_01_29_200004_create_stat_snapshots_table.php create mode 100644 routes/api/v1/stats.php diff --git a/app/Http/Controllers/Api/V1/StatController.php b/app/Http/Controllers/Api/V1/StatController.php new file mode 100644 index 0000000..78ff96d --- /dev/null +++ b/app/Http/Controllers/Api/V1/StatController.php @@ -0,0 +1,64 @@ +statQueryService->getDashboardSummary(); + + return ApiResponse::handle(['data' => $data], __('message.fetched')); + } + + /** + * 일간 통계 조회 + */ + public function daily(StatDailyRequest $request): JsonResponse + { + $data = $this->statQueryService->getDailyStat( + $request->validated('domain'), + $request->validated() + ); + + return ApiResponse::handle(['data' => $data], __('message.fetched')); + } + + /** + * 월간 통계 조회 + */ + public function monthly(StatMonthlyRequest $request): JsonResponse + { + $data = $this->statQueryService->getMonthlyStat( + $request->validated('domain'), + $request->validated() + ); + + return ApiResponse::handle(['data' => $data], __('message.fetched')); + } + + /** + * 알림 목록 조회 + */ + public function alerts(StatAlertRequest $request): JsonResponse + { + $data = $this->statQueryService->getAlerts($request->validated()); + + return ApiResponse::handle(['data' => $data], __('message.fetched')); + } +} diff --git a/app/Http/Requests/V1/Stat/StatAlertRequest.php b/app/Http/Requests/V1/Stat/StatAlertRequest.php new file mode 100644 index 0000000..fdc51c3 --- /dev/null +++ b/app/Http/Requests/V1/Stat/StatAlertRequest.php @@ -0,0 +1,21 @@ + 'nullable|integer|min:1|max:100', + 'unread_only' => 'nullable|boolean', + ]; + } +} diff --git a/app/Http/Requests/V1/Stat/StatDailyRequest.php b/app/Http/Requests/V1/Stat/StatDailyRequest.php new file mode 100644 index 0000000..42b24b0 --- /dev/null +++ b/app/Http/Requests/V1/Stat/StatDailyRequest.php @@ -0,0 +1,22 @@ + 'required|string|in:sales,finance,production,inventory,quote,hr,system', + 'start_date' => 'required|date|date_format:Y-m-d', + 'end_date' => 'required|date|date_format:Y-m-d|after_or_equal:start_date', + ]; + } +} diff --git a/app/Http/Requests/V1/Stat/StatMonthlyRequest.php b/app/Http/Requests/V1/Stat/StatMonthlyRequest.php new file mode 100644 index 0000000..04b2eb7 --- /dev/null +++ b/app/Http/Requests/V1/Stat/StatMonthlyRequest.php @@ -0,0 +1,22 @@ + 'required|string|in:sales,finance,production,project', + 'year' => 'required|integer|min:2020|max:2099', + 'month' => 'nullable|integer|min:1|max:12', + ]; + } +} diff --git a/app/Models/Stats/Daily/StatSystemDaily.php b/app/Models/Stats/Daily/StatSystemDaily.php new file mode 100644 index 0000000..2b60a27 --- /dev/null +++ b/app/Models/Stats/Daily/StatSystemDaily.php @@ -0,0 +1,16 @@ + 'date', + 'file_upload_size_mb' => 'decimal:2', + 'approval_avg_hours' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/Monthly/StatProjectMonthly.php b/app/Models/Stats/Monthly/StatProjectMonthly.php new file mode 100644 index 0000000..b4e5c1e --- /dev/null +++ b/app/Models/Stats/Monthly/StatProjectMonthly.php @@ -0,0 +1,20 @@ + 'decimal:2', + 'expected_expense_total' => 'decimal:2', + 'actual_expense_total' => 'decimal:2', + 'labor_cost_total' => 'decimal:2', + 'material_cost_total' => 'decimal:2', + 'gross_profit' => 'decimal:2', + 'gross_profit_rate' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/StatEvent.php b/app/Models/Stats/StatEvent.php new file mode 100644 index 0000000..a260284 --- /dev/null +++ b/app/Models/Stats/StatEvent.php @@ -0,0 +1,15 @@ + 'array', + 'occurred_at' => 'datetime', + ]; +} diff --git a/app/Models/Stats/StatSnapshot.php b/app/Models/Stats/StatSnapshot.php new file mode 100644 index 0000000..dd3cf96 --- /dev/null +++ b/app/Models/Stats/StatSnapshot.php @@ -0,0 +1,16 @@ + 'date', + 'data' => 'array', + 'created_at' => 'datetime', + ]; +} diff --git a/app/Observers/StatEventObserver.php b/app/Observers/StatEventObserver.php new file mode 100644 index 0000000..5da33eb --- /dev/null +++ b/app/Observers/StatEventObserver.php @@ -0,0 +1,96 @@ + 'sales', + 'App\Models\Tenants\Sale' => 'sales', + 'App\Models\Tenants\Deposit' => 'finance', + 'App\Models\Tenants\Withdrawal' => 'finance', + 'App\Models\Tenants\Purchase' => 'finance', + 'App\Models\Production\WorkOrder' => 'production', + 'App\Models\Tenants\Approval' => 'system', + ]; + + public function created(Model $model): void + { + $this->recordEvent($model, 'created'); + } + + public function updated(Model $model): void + { + $this->recordEvent($model, 'updated'); + } + + public function deleted(Model $model): void + { + $this->recordEvent($model, 'deleted'); + } + + private function recordEvent(Model $model, string $action): void + { + $tenantId = $model->getAttribute('tenant_id'); + if (! $tenantId) { + return; + } + + $className = get_class($model); + $domain = self::$domainMap[$className] ?? 'other'; + $entityType = class_basename($model); + $eventType = strtolower($entityType).'_'.$action; + + $payload = match ($action) { + 'created' => $this->getCreatedPayload($model), + 'updated' => $this->getUpdatedPayload($model), + 'deleted' => ['id' => $model->getKey()], + default => null, + }; + + app(StatEventService::class)->recordEvent( + $tenantId, + $domain, + $eventType, + $entityType, + $model->getKey(), + $payload + ); + } + + private function getCreatedPayload(Model $model): array + { + $payload = ['id' => $model->getKey()]; + + if ($model->getAttribute('total_amount') !== null) { + $payload['amount'] = $model->getAttribute('total_amount'); + } + if ($model->getAttribute('amount') !== null) { + $payload['amount'] = $model->getAttribute('amount'); + } + if ($model->getAttribute('status') !== null) { + $payload['status'] = $model->getAttribute('status'); + } + + return $payload; + } + + private function getUpdatedPayload(Model $model): array + { + $changed = $model->getChanges(); + $payload = ['id' => $model->getKey()]; + + if (isset($changed['status'])) { + $payload['old_status'] = $model->getOriginal('status'); + $payload['new_status'] = $changed['status']; + } + if (isset($changed['total_amount']) || isset($changed['amount'])) { + $payload['amount'] = $changed['total_amount'] ?? $changed['amount']; + } + + return $payload; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d083f5e..ac3963a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -14,6 +14,7 @@ use App\Models\Tenants\Deposit; use App\Models\Tenants\ExpectedExpense; use App\Models\Tenants\Purchase; +use App\Models\Tenants\Sale; use App\Models\Tenants\Stock; use App\Models\Tenants\Tenant; use App\Models\Tenants\Withdrawal; @@ -21,6 +22,7 @@ use App\Observers\ExpenseSync\PurchaseExpenseSyncObserver; use App\Observers\ExpenseSync\WithdrawalExpenseSyncObserver; use App\Observers\MenuObserver; +use App\Observers\StatEventObserver; use App\Observers\TenantObserver; use App\Observers\TodayIssue\ApprovalIssueObserver; use App\Observers\TodayIssue\ApprovalStepIssueObserver; @@ -96,5 +98,13 @@ public function boot(): void Purchase::observe(PurchaseExpenseSyncObserver::class); Withdrawal::observe(WithdrawalExpenseSyncObserver::class); Bill::observe(BillExpenseSyncObserver::class); + + // 통계 이벤트 기록 (stat_events 테이블) + Order::observe(StatEventObserver::class); + Sale::observe(StatEventObserver::class); + Deposit::observe(StatEventObserver::class); + Withdrawal::observe(StatEventObserver::class); + Purchase::observe(StatEventObserver::class); + Approval::observe(StatEventObserver::class); } } diff --git a/app/Services/DashboardService.php b/app/Services/DashboardService.php index 5423370..22ed143 100644 --- a/app/Services/DashboardService.php +++ b/app/Services/DashboardService.php @@ -2,6 +2,8 @@ namespace App\Services; +use App\Models\Stats\Monthly\StatFinanceMonthly; +use App\Models\Stats\Monthly\StatSalesMonthly; use App\Models\Tenants\Approval; use App\Models\Tenants\ApprovalStep; use App\Models\Tenants\Attendance; @@ -114,23 +116,36 @@ private function getTodaySummary(int $tenantId, Carbon $today): array } /** - * 재무 요약 데이터 + * 재무 요약 데이터 (sam_stat 우선, 폴백: 원본 DB) */ private function getFinanceSummary(int $tenantId, Carbon $startOfMonth, Carbon $endOfMonth): array { - // 월간 입금 합계 + // sam_stat 월간 데이터 시도 + $monthly = StatFinanceMonthly::where('tenant_id', $tenantId) + ->where('stat_year', $startOfMonth->year) + ->where('stat_month', $startOfMonth->month) + ->first(); + + if ($monthly) { + return [ + 'monthly_deposit' => (float) $monthly->deposit_amount, + 'monthly_withdrawal' => (float) $monthly->withdrawal_amount, + 'balance' => (float) ($monthly->deposit_amount - $monthly->withdrawal_amount), + 'source' => 'sam_stat', + ]; + } + + // 폴백: 원본 DB 실시간 집계 $monthlyDeposit = Deposit::query() ->where('tenant_id', $tenantId) ->whereBetween('deposit_date', [$startOfMonth, $endOfMonth]) ->sum('amount'); - // 월간 출금 합계 $monthlyWithdrawal = Withdrawal::query() ->where('tenant_id', $tenantId) ->whereBetween('withdrawal_date', [$startOfMonth, $endOfMonth]) ->sum('amount'); - // 현재 잔액 (전체 입금 - 전체 출금) $totalDeposits = Deposit::query() ->where('tenant_id', $tenantId) ->sum('amount'); @@ -145,21 +160,35 @@ private function getFinanceSummary(int $tenantId, Carbon $startOfMonth, Carbon $ 'monthly_deposit' => (float) $monthlyDeposit, 'monthly_withdrawal' => (float) $monthlyWithdrawal, 'balance' => (float) $balance, + 'source' => 'samdb', ]; } /** - * 매출/매입 요약 데이터 + * 매출/매입 요약 데이터 (sam_stat 우선, 폴백: 원본 DB) */ private function getSalesSummary(int $tenantId, Carbon $startOfMonth, Carbon $endOfMonth): array { - // 월간 매출 합계 + // sam_stat 월간 데이터 시도 + $monthly = StatSalesMonthly::where('tenant_id', $tenantId) + ->where('stat_year', $startOfMonth->year) + ->where('stat_month', $startOfMonth->month) + ->first(); + + if ($monthly) { + return [ + 'monthly_sales' => (float) $monthly->sales_amount, + 'monthly_purchases' => 0, + 'source' => 'sam_stat', + ]; + } + + // 폴백: 원본 DB 실시간 집계 $monthlySales = Sale::query() ->where('tenant_id', $tenantId) ->whereBetween('sale_date', [$startOfMonth, $endOfMonth]) ->sum('total_amount'); - // 월간 매입 합계 $monthlyPurchases = Purchase::query() ->where('tenant_id', $tenantId) ->whereBetween('purchase_date', [$startOfMonth, $endOfMonth]) @@ -168,6 +197,7 @@ private function getSalesSummary(int $tenantId, Carbon $startOfMonth, Carbon $en return [ 'monthly_sales' => (float) $monthlySales, 'monthly_purchases' => (float) $monthlyPurchases, + 'source' => 'samdb', ]; } diff --git a/app/Services/Stats/ProjectStatService.php b/app/Services/Stats/ProjectStatService.php new file mode 100644 index 0000000..6929c7b --- /dev/null +++ b/app/Services/Stats/ProjectStatService.php @@ -0,0 +1,92 @@ +startOfDay(); + $endDate = $startDate->copy()->endOfMonth(); + + // 현장 현황 + $activeSites = DB::connection('mysql') + ->table('sites') + ->where('tenant_id', $tenantId) + ->where('status', 'active') + ->whereNull('deleted_at') + ->count(); + + $completedSites = DB::connection('mysql') + ->table('sites') + ->where('tenant_id', $tenantId) + ->where('status', 'completed') + ->whereNull('deleted_at') + ->count(); + + // 계약 현황 (해당 월 신규 계약) + $contractStats = DB::connection('mysql') + ->table('contracts') + ->where('tenant_id', $tenantId) + ->whereBetween('created_at', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->selectRaw(' + COUNT(*) as new_count, + COALESCE(SUM(contract_amount), 0) as total_amount + ') + ->first(); + + // 지출예상 (해당 월) + $expenseStats = DB::connection('mysql') + ->table('expected_expenses') + ->where('tenant_id', $tenantId) + ->whereYear('expected_payment_date', $year) + ->whereMonth('expected_payment_date', $month) + ->whereNull('deleted_at') + ->selectRaw(' + COALESCE(SUM(amount), 0) as expected_total, + COALESCE(SUM(CASE WHEN payment_status = \'paid\' THEN amount ELSE 0 END), 0) as actual_total + ') + ->first(); + + // 수익률 계산 + $contractTotal = (float) ($contractStats->total_amount ?? 0); + $actualExpense = (float) ($expenseStats->actual_total ?? 0); + $grossProfit = $contractTotal - $actualExpense; + $grossProfitRate = $contractTotal > 0 ? ($grossProfit / $contractTotal) * 100 : 0; + + StatProjectMonthly::updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'stat_year' => $year, + 'stat_month' => $month, + ], + [ + 'active_site_count' => $activeSites, + 'completed_site_count' => $completedSites, + 'new_contract_count' => $contractStats->new_count ?? 0, + 'contract_total_amount' => $contractTotal, + 'expected_expense_total' => $expenseStats->expected_total ?? 0, + 'actual_expense_total' => $actualExpense, + 'labor_cost_total' => 0, + 'material_cost_total' => 0, + 'gross_profit' => $grossProfit, + 'gross_profit_rate' => $grossProfitRate, + 'handover_report_count' => 0, + 'structure_review_count' => 0, + ] + ); + + return 1; + } +} diff --git a/app/Services/Stats/StatAggregatorService.php b/app/Services/Stats/StatAggregatorService.php index 6dcc191..7ca5515 100644 --- a/app/Services/Stats/StatAggregatorService.php +++ b/app/Services/Stats/StatAggregatorService.php @@ -21,6 +21,7 @@ private function getDailyDomainServices(): array 'inventory' => InventoryStatService::class, 'quote' => QuoteStatService::class, 'hr' => HrStatService::class, + 'system' => SystemStatService::class, ]; } @@ -36,6 +37,7 @@ private function getMonthlyDomainServices(): array 'inventory' => InventoryStatService::class, 'quote' => QuoteStatService::class, 'hr' => HrStatService::class, + 'project' => ProjectStatService::class, ]; } diff --git a/app/Services/Stats/StatEventService.php b/app/Services/Stats/StatEventService.php new file mode 100644 index 0000000..83bda74 --- /dev/null +++ b/app/Services/Stats/StatEventService.php @@ -0,0 +1,74 @@ + $tenantId, + 'domain' => $domain, + 'event_type' => $eventType, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'payload' => $payload, + 'occurred_at' => now(), + ]); + } catch (\Throwable $e) { + Log::warning('stat_event 기록 실패', [ + 'tenant_id' => $tenantId, + 'domain' => $domain, + 'event_type' => $eventType, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * 스냅샷 저장 + */ + public function saveSnapshot( + int $tenantId, + string $domain, + string $snapshotDate, + array $data, + string $snapshotType = 'daily' + ): void { + try { + StatSnapshot::updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'snapshot_date' => $snapshotDate, + 'domain' => $domain, + 'snapshot_type' => $snapshotType, + ], + [ + 'data' => $data, + 'created_at' => now(), + ] + ); + } catch (\Throwable $e) { + Log::warning('stat_snapshot 저장 실패', [ + 'tenant_id' => $tenantId, + 'domain' => $domain, + 'snapshot_date' => $snapshotDate, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Services/Stats/StatQueryService.php b/app/Services/Stats/StatQueryService.php new file mode 100644 index 0000000..8b8a538 --- /dev/null +++ b/app/Services/Stats/StatQueryService.php @@ -0,0 +1,179 @@ +tenantId(); + $startDate = $params['start_date']; + $endDate = $params['end_date']; + + $model = $this->getDailyModel($domain); + if (! $model) { + return []; + } + + return $model::where('tenant_id', $tenantId) + ->whereBetween('stat_date', [$startDate, $endDate]) + ->orderBy('stat_date') + ->get() + ->toArray(); + } + + /** + * 도메인별 월간 통계 조회 + */ + public function getMonthlyStat(string $domain, array $params): array + { + $tenantId = $this->tenantId(); + $year = (int) $params['year']; + $month = isset($params['month']) ? (int) $params['month'] : null; + + $model = $this->getMonthlyModel($domain); + if (! $model) { + return []; + } + + $query = $model::where('tenant_id', $tenantId) + ->where('stat_year', $year); + + if ($month) { + $query->where('stat_month', $month); + } + + return $query->orderBy('stat_month') + ->get() + ->toArray(); + } + + /** + * 대시보드 요약 통계 (sam_stat 기반) + */ + public function getDashboardSummary(): array + { + $tenantId = $this->tenantId(); + $today = Carbon::today()->format('Y-m-d'); + $year = Carbon::now()->year; + $month = Carbon::now()->month; + + return [ + 'sales_today' => $this->getTodaySales($tenantId, $today), + 'finance_today' => $this->getTodayFinance($tenantId, $today), + 'production_today' => $this->getTodayProduction($tenantId, $today), + 'sales_monthly' => $this->getMonthlySalesOverview($tenantId, $year, $month), + 'alerts' => $this->getActiveAlerts($tenantId), + ]; + } + + /** + * 알림 목록 조회 + */ + public function getAlerts(array $params): array + { + $tenantId = $this->tenantId(); + $limit = $params['limit'] ?? 20; + $unreadOnly = $params['unread_only'] ?? false; + + $query = StatAlert::where('tenant_id', $tenantId) + ->orderByDesc('created_at'); + + if ($unreadOnly) { + $query->where('is_read', false); + } + + return $query->limit($limit)->get()->toArray(); + } + + private function getTodaySales(int $tenantId, string $today): ?array + { + $stat = StatSalesDaily::where('tenant_id', $tenantId) + ->where('stat_date', $today) + ->first(); + + return $stat?->toArray(); + } + + private function getTodayFinance(int $tenantId, string $today): ?array + { + $stat = StatFinanceDaily::where('tenant_id', $tenantId) + ->where('stat_date', $today) + ->first(); + + return $stat?->toArray(); + } + + private function getTodayProduction(int $tenantId, string $today): ?array + { + $stat = StatProductionDaily::where('tenant_id', $tenantId) + ->where('stat_date', $today) + ->first(); + + return $stat?->toArray(); + } + + private function getMonthlySalesOverview(int $tenantId, int $year, int $month): ?array + { + $stat = StatSalesMonthly::where('tenant_id', $tenantId) + ->where('stat_year', $year) + ->where('stat_month', $month) + ->first(); + + return $stat?->toArray(); + } + + private function getActiveAlerts(int $tenantId): array + { + return StatAlert::where('tenant_id', $tenantId) + ->where('is_read', false) + ->where('is_resolved', false) + ->orderByDesc('created_at') + ->limit(10) + ->get() + ->toArray(); + } + + private function getDailyModel(string $domain): ?string + { + return match ($domain) { + 'sales' => StatSalesDaily::class, + 'finance' => StatFinanceDaily::class, + 'production' => StatProductionDaily::class, + 'inventory' => StatInventoryDaily::class, + 'quote' => StatQuotePipelineDaily::class, + 'hr' => StatHrAttendanceDaily::class, + 'system' => StatSystemDaily::class, + default => null, + }; + } + + private function getMonthlyModel(string $domain): ?string + { + return match ($domain) { + 'sales' => StatSalesMonthly::class, + 'finance' => StatFinanceMonthly::class, + 'production' => StatProductionMonthly::class, + 'project' => StatProjectMonthly::class, + default => null, + }; + } +} diff --git a/app/Services/Stats/SystemStatService.php b/app/Services/Stats/SystemStatService.php new file mode 100644 index 0000000..c4310ad --- /dev/null +++ b/app/Services/Stats/SystemStatService.php @@ -0,0 +1,135 @@ +format('Y-m-d'); + + // API 사용량 + $apiStats = DB::connection('mysql') + ->table('api_request_logs') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->selectRaw(' + COUNT(*) as request_count, + SUM(CASE WHEN response_status >= 400 THEN 1 ELSE 0 END) as error_count, + COALESCE(AVG(duration_ms), 0) as avg_response_ms + ') + ->first(); + + // 사용자 활동 (고유 사용자 수, 로그인 수) + $activeUsers = DB::connection('mysql') + ->table('api_request_logs') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->whereNotNull('user_id') + ->distinct('user_id') + ->count('user_id'); + + // personal_access_tokens에 tenant_id 없으므로 user_tenants 조인으로 로그인 수 집계 + $loginCount = DB::connection('mysql') + ->table('personal_access_tokens as pat') + ->join('user_tenants as ut', function ($join) use ($tenantId) { + $join->on('pat.tokenable_id', '=', 'ut.user_id') + ->where('pat.tokenable_type', '=', 'App\\Models\\Members\\User') + ->where('ut.tenant_id', '=', $tenantId); + }) + ->whereDate('pat.created_at', $dateStr) + ->count(); + + // 감사 로그 + $auditStats = DB::connection('mysql') + ->table('audit_logs') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->selectRaw(" + SUM(CASE WHEN action = 'created' THEN 1 ELSE 0 END) as create_count, + SUM(CASE WHEN action = 'updated' THEN 1 ELSE 0 END) as update_count, + SUM(CASE WHEN action = 'deleted' THEN 1 ELSE 0 END) as delete_count + ") + ->first(); + + // FCM 알림 + $fcmStats = DB::connection('mysql') + ->table('fcm_send_logs') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->selectRaw(' + COALESCE(SUM(success_count), 0) as sent_count, + COALESCE(SUM(failure_count), 0) as failed_count + ') + ->first(); + + // 파일 업로드 + $fileStats = DB::connection('mysql') + ->table('files') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->selectRaw(' + COUNT(*) as upload_count, + COALESCE(SUM(file_size), 0) / 1048576 as upload_size_mb + ') + ->first(); + + // 결재 + $approvalSubmitted = DB::connection('mysql') + ->table('approvals') + ->where('tenant_id', $tenantId) + ->whereDate('drafted_at', $dateStr) + ->whereNull('deleted_at') + ->count(); + + $approvalCompleted = DB::connection('mysql') + ->table('approvals') + ->where('tenant_id', $tenantId) + ->whereDate('completed_at', $dateStr) + ->whereNull('deleted_at') + ->count(); + + $approvalAvgHours = DB::connection('mysql') + ->table('approvals') + ->where('tenant_id', $tenantId) + ->whereDate('completed_at', $dateStr) + ->whereNotNull('drafted_at') + ->whereNotNull('completed_at') + ->whereNull('deleted_at') + ->selectRaw('AVG(TIMESTAMPDIFF(HOUR, drafted_at, completed_at)) as avg_hours') + ->value('avg_hours'); + + StatSystemDaily::updateOrCreate( + ['tenant_id' => $tenantId, 'stat_date' => $dateStr], + [ + 'api_request_count' => $apiStats->request_count ?? 0, + 'api_error_count' => $apiStats->error_count ?? 0, + 'api_avg_response_ms' => (int) ($apiStats->avg_response_ms ?? 0), + 'active_user_count' => $activeUsers, + 'login_count' => $loginCount, + 'audit_create_count' => $auditStats->create_count ?? 0, + 'audit_update_count' => $auditStats->update_count ?? 0, + 'audit_delete_count' => $auditStats->delete_count ?? 0, + 'fcm_sent_count' => $fcmStats->sent_count ?? 0, + 'fcm_failed_count' => $fcmStats->failed_count ?? 0, + 'file_upload_count' => $fileStats->upload_count ?? 0, + 'file_upload_size_mb' => $fileStats->upload_size_mb ?? 0, + 'approval_submitted_count' => $approvalSubmitted, + 'approval_completed_count' => $approvalCompleted, + 'approval_avg_hours' => (float) ($approvalAvgHours ?? 0), + ] + ); + + return 1; + } + + public function aggregateMonthly(int $tenantId, int $year, int $month): int + { + // 시스템 도메인은 일간 테이블만 운영 + return 0; + } +} diff --git a/database/migrations/stats/2026_01_29_200001_create_stat_project_monthly_table.php b/database/migrations/stats/2026_01_29_200001_create_stat_project_monthly_table.php new file mode 100644 index 0000000..076a771 --- /dev/null +++ b/database/migrations/stats/2026_01_29_200001_create_stat_project_monthly_table.php @@ -0,0 +1,50 @@ +create('stat_project_monthly', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->smallInteger('stat_year'); + $table->tinyInteger('stat_month'); + + // 프로젝트 현황 + $table->unsignedInteger('active_site_count')->default(0); + $table->unsignedInteger('completed_site_count')->default(0); + $table->unsignedInteger('new_contract_count')->default(0); + $table->decimal('contract_total_amount', 18, 2)->default(0); + + // 원가 + $table->decimal('expected_expense_total', 18, 2)->default(0); + $table->decimal('actual_expense_total', 18, 2)->default(0); + $table->decimal('labor_cost_total', 18, 2)->default(0); + $table->decimal('material_cost_total', 18, 2)->default(0); + + // 수익률 + $table->decimal('gross_profit', 18, 2)->default(0); + $table->decimal('gross_profit_rate', 5, 2)->default(0); + + // 이슈 + $table->unsignedInteger('handover_report_count')->default(0); + $table->unsignedInteger('structure_review_count')->default(0); + + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_year', 'stat_month'], 'uk_tenant_year_month'); + $table->index(['stat_year', 'stat_month'], 'idx_year_month'); + }); + } + + public function down(): void + { + Schema::connection('sam_stat')->dropIfExists('stat_project_monthly'); + } +}; diff --git a/database/migrations/stats/2026_01_29_200002_create_stat_system_daily_table.php b/database/migrations/stats/2026_01_29_200002_create_stat_system_daily_table.php new file mode 100644 index 0000000..f39b143 --- /dev/null +++ b/database/migrations/stats/2026_01_29_200002_create_stat_system_daily_table.php @@ -0,0 +1,56 @@ +create('stat_system_daily', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->date('stat_date'); + + // API 사용량 + $table->unsignedInteger('api_request_count')->default(0); + $table->unsignedInteger('api_error_count')->default(0); + $table->unsignedInteger('api_avg_response_ms')->default(0); + + // 사용자 활동 + $table->unsignedInteger('active_user_count')->default(0); + $table->unsignedInteger('login_count')->default(0); + + // 감사 + $table->unsignedInteger('audit_create_count')->default(0); + $table->unsignedInteger('audit_update_count')->default(0); + $table->unsignedInteger('audit_delete_count')->default(0); + + // 알림 + $table->unsignedInteger('fcm_sent_count')->default(0); + $table->unsignedInteger('fcm_failed_count')->default(0); + + // 파일 + $table->unsignedInteger('file_upload_count')->default(0); + $table->decimal('file_upload_size_mb', 10, 2)->default(0); + + // 결재 + $table->unsignedInteger('approval_submitted_count')->default(0); + $table->unsignedInteger('approval_completed_count')->default(0); + $table->decimal('approval_avg_hours', 8, 2)->default(0); + + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_date'], 'uk_tenant_date'); + $table->index('stat_date', 'idx_date'); + }); + } + + public function down(): void + { + Schema::connection('sam_stat')->dropIfExists('stat_system_daily'); + } +}; diff --git a/database/migrations/stats/2026_01_29_200003_create_stat_events_table.php b/database/migrations/stats/2026_01_29_200003_create_stat_events_table.php new file mode 100644 index 0000000..b52bb90 --- /dev/null +++ b/database/migrations/stats/2026_01_29_200003_create_stat_events_table.php @@ -0,0 +1,33 @@ +create('stat_events', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('domain', 50); + $table->string('event_type', 100); + $table->string('entity_type', 100); + $table->unsignedBigInteger('entity_id'); + $table->json('payload')->nullable(); + $table->timestamp('occurred_at'); + + $table->index(['tenant_id', 'domain'], 'idx_tenant_domain'); + $table->index('occurred_at', 'idx_occurred'); + $table->index(['entity_type', 'entity_id'], 'idx_entity'); + }); + } + + public function down(): void + { + Schema::connection('sam_stat')->dropIfExists('stat_events'); + } +}; diff --git a/database/migrations/stats/2026_01_29_200004_create_stat_snapshots_table.php b/database/migrations/stats/2026_01_29_200004_create_stat_snapshots_table.php new file mode 100644 index 0000000..2fa7f11 --- /dev/null +++ b/database/migrations/stats/2026_01_29_200004_create_stat_snapshots_table.php @@ -0,0 +1,31 @@ +create('stat_snapshots', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->date('snapshot_date'); + $table->string('domain', 50); + $table->string('snapshot_type', 50)->default('daily'); + $table->json('data'); + $table->timestamp('created_at')->nullable(); + + $table->unique(['tenant_id', 'snapshot_date', 'domain', 'snapshot_type'], 'uk_tenant_date_domain'); + $table->index('snapshot_date', 'idx_date'); + }); + } + + public function down(): void + { + Schema::connection('sam_stat')->dropIfExists('stat_snapshots'); + } +}; diff --git a/routes/api.php b/routes/api.php index 44cd1b1..daf53ea 100644 --- a/routes/api.php +++ b/routes/api.php @@ -37,6 +37,7 @@ require __DIR__.'/api/v1/boards.php'; require __DIR__.'/api/v1/documents.php'; require __DIR__.'/api/v1/common.php'; + require __DIR__.'/api/v1/stats.php'; // 공유 링크 다운로드 (인증 불필요 - auth.apikey 그룹 밖) Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download'); diff --git a/routes/api/v1/stats.php b/routes/api/v1/stats.php new file mode 100644 index 0000000..eef2469 --- /dev/null +++ b/routes/api/v1/stats.php @@ -0,0 +1,19 @@ +group(function () { + Route::get('/summary', [StatController::class, 'summary'])->name('v1.stats.summary'); + Route::get('/daily', [StatController::class, 'daily'])->name('v1.stats.daily'); + Route::get('/monthly', [StatController::class, 'monthly'])->name('v1.stats.monthly'); + Route::get('/alerts', [StatController::class, 'alerts'])->name('v1.stats.alerts'); +}); From 3793e956620b8106420e71511be1ca6184720175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 22:00:15 +0900 Subject: [PATCH 51/57] =?UTF-8?q?fix:=20=EA=B2=AC=EC=A0=81=20=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20chandj=20=EC=9B=90=EB=B3=B8=20=EC=86=8C=EC=8A=A4=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EB=B0=8F=205130=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EC=9D=BC=EC=B9=98=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MigrateBDModelsPrices: chandj 원본 테이블(price_motor, price_angle 등)에서 직접 마이그레이션 - EstimatePriceService: 모터 LIKE 매칭, 제어기 카테고리 분리, 앵글 bracket/main 분리, 샤프트 포맷 정규화 - KyungdongFormulaHandler: - 검사비 항목 추가 (기본 50,000원) - 뒷박스 항목 추가 (제어기 섹션) - 부자재 앵글3T 항목 추가 (calculatePartItems) - 면적 소수점 2자리 반올림 후 곱셈 (5130 동일) - model_name에 product_model fallback 추가 (KSS02 단가 정확 조회) Co-Authored-By: Claude Opus 4.5 --- .../Commands/MigrateBDModelsPrices.php | 567 +++++++++++++----- app/Services/Quote/EstimatePriceService.php | 77 ++- .../Handlers/KyungdongFormulaHandler.php | 158 ++++- 3 files changed, 619 insertions(+), 183 deletions(-) diff --git a/app/Console/Commands/MigrateBDModelsPrices.php b/app/Console/Commands/MigrateBDModelsPrices.php index b1a41fb..60f021f 100644 --- a/app/Console/Commands/MigrateBDModelsPrices.php +++ b/app/Console/Commands/MigrateBDModelsPrices.php @@ -6,39 +6,67 @@ use Illuminate\Support\Facades\DB; /** - * BDmodels + kd_price_tables → items + item_details + prices 마이그레이션 + * chandj 원본 가격 테이블 → items + item_details + prices 마이그레이션 * - * 레거시 chandj.BDmodels 데이터와 kd_price_tables 데이터를 + * 레거시 chandj DB의 BDmodels, price_motor, price_raw_materials, + * price_shaft, price_pipe, price_angle, price_smokeban 데이터를 * items + item_details + prices 통합 구조로 마이그레이션 */ class MigrateBDModelsPrices extends Command { - protected $signature = 'kd:migrate-prices {--dry-run : 실제 DB 변경 없이 미리보기}'; + protected $signature = 'kd:migrate-prices + {--dry-run : 실제 DB 변경 없이 미리보기} + {--fresh : 기존 EST-* 항목 삭제 후 재생성}'; - protected $description = '경동 견적 단가를 items+item_details+prices로 마이그레이션'; + protected $description = '경동 견적 단가를 chandj 원본에서 items+item_details+prices로 마이그레이션'; private const TENANT_ID = 287; private int $created = 0; + private int $updated = 0; + private int $skipped = 0; + private int $deleted = 0; + public function handle(): int { $dryRun = $this->option('dry-run'); + $fresh = $this->option('fresh'); - $this->info('=== 경동 견적 단가 마이그레이션 ==='); + $this->info('=== 경동 견적 단가 마이그레이션 (chandj 원본) ==='); $this->info($dryRun ? '[DRY RUN] 실제 변경 없음' : '[LIVE] DB에 반영합니다'); $this->newLine(); DB::beginTransaction(); try { - // 1. 레거시 BDmodels (chandj DB) + // --fresh: 기존 EST-* 항목 삭제 + if ($fresh) { + $this->cleanExistingEstItems($dryRun); + } + + // 1. BDmodels (절곡품: 케이스, 가이드레일, 하단마감재, 마구리, 연기차단재, L바, 보강평철) $this->migrateBDModels($dryRun); - // 2. kd_price_tables (motor, shaft, pipe, angle, raw_material) - $this->migrateKdPriceTables($dryRun); + // 2. price_motor (모터 + 제어기) + $this->migrateMotors($dryRun); + + // 3. price_raw_materials (원자재: 실리카, 화이바, 와이어 등) + $this->migrateRawMaterials($dryRun); + + // 4. price_shaft (감기샤프트) + $this->migrateShafts($dryRun); + + // 5. price_pipe (각파이프) + $this->migratePipes($dryRun); + + // 6. price_angle (앵글) + $this->migrateAngles($dryRun); + + // 7. price_smokeban (연기차단재 - BDmodels에 없는 경우 보완) + $this->migrateSmokeBan($dryRun); if ($dryRun) { DB::rollBack(); @@ -49,25 +77,49 @@ public function handle(): int } $this->newLine(); - $this->info("생성: {$this->created}건, 스킵: {$this->skipped}건"); + $this->info("생성: {$this->created}건, 업데이트: {$this->updated}건, 스킵: {$this->skipped}건, 삭제: {$this->deleted}건"); return Command::SUCCESS; } catch (\Exception $e) { DB::rollBack(); $this->error("오류: {$e->getMessage()}"); + $this->error($e->getTraceAsString()); return Command::FAILURE; } } /** - * 레거시 chandj.BDmodels → items + item_details + prices + * 기존 EST-* 항목 삭제 (--fresh 옵션) + */ + private function cleanExistingEstItems(bool $dryRun): void + { + $this->info('--- 기존 EST-* 항목 삭제 ---'); + + $items = DB::table('items') + ->where('tenant_id', self::TENANT_ID) + ->where('code', 'LIKE', 'EST-%') + ->whereNull('deleted_at') + ->get(['id', 'code']); + + foreach ($items as $item) { + $this->line(" [삭제] {$item->code}"); + if (! $dryRun) { + DB::table('prices')->where('item_id', $item->id)->delete(); + DB::table('item_details')->where('item_id', $item->id)->delete(); + DB::table('items')->where('id', $item->id)->delete(); + } + $this->deleted++; + } + } + + /** + * chandj.BDmodels → items + item_details + prices */ private function migrateBDModels(bool $dryRun): void { - $this->info('--- BDmodels (레거시) ---'); + $this->info('--- BDmodels (절곡품) ---'); - // chandj DB에서 BDmodels 조회 (chandj connection 사용) $rows = DB::connection('chandj')->select(" SELECT model_name, seconditem, finishing_type, spec, unitprice, description FROM BDmodels @@ -91,7 +143,6 @@ private function migrateBDModels(bool $dryRun): void continue; } - // 코드 생성 $codeParts = ['BD', $secondItem]; if ($modelName) { $codeParts[] = $modelName; @@ -104,7 +155,6 @@ private function migrateBDModels(bool $dryRun): void } $code = implode('-', $codeParts); - // 이름 생성 $nameParts = [$secondItem]; if ($modelName) { $nameParts[] = $modelName; @@ -117,7 +167,7 @@ private function migrateBDModels(bool $dryRun): void } $name = implode(' ', $nameParts); - $this->createEstimateItem( + $this->upsertEstimateItem( code: $code, name: $name, productCategory: 'bdmodels', @@ -130,162 +180,357 @@ private function migrateBDModels(bool $dryRun): void 'description' => $row->description ?: null, ]), salesPrice: $unitPrice, - note: 'BDmodels 마이그레이션', + note: 'chandj.BDmodels', dryRun: $dryRun ); } } /** - * kd_price_tables → items + item_details + prices + * chandj.price_motor → 모터 + 제어기 + * + * col1: 전압 (220, 380, 제어기, 방화, 방범) + * col2: 용량/종류 (150K(S), 300K, 매립형, 노출형 등) + * col13: 판매가 */ - private function migrateKdPriceTables(bool $dryRun): void + private function migrateMotors(bool $dryRun): void { - $this->info('--- kd_price_tables ---'); + $this->info('--- price_motor (모터/제어기) ---'); - $rows = DB::table('kd_price_tables') - ->where('tenant_id', self::TENANT_ID) - ->where('is_active', true) - ->where('table_type', '!=', 'bdmodels') // BDmodels는 위에서 처리 - ->orderBy('table_type') - ->orderBy('item_code') - ->get(); + $row = DB::connection('chandj')->selectOne( + "SELECT itemList FROM price_motor WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1" + ); + if (! $row) { + return; + } - foreach ($rows as $row) { - $tableType = $row->table_type; - $unitPrice = (float) $row->unit_price; + $items = json_decode($row->itemList, true); - if ($unitPrice <= 0) { + foreach ($items as $item) { + $category = trim($item['col1'] ?? ''); // 220, 380, 제어기, 방화, 방범 + $name = trim($item['col2'] ?? ''); // 150K(S), 매립형 등 + $price = (int) str_replace(',', '', $item['col13'] ?? '0'); + + if (empty($name) || $price <= 0) { $this->skipped++; continue; } - switch ($tableType) { - case 'motor': - $this->migrateMotor($row, $dryRun); - break; - case 'shaft': - $this->migrateShaft($row, $dryRun); - break; - case 'pipe': - $this->migratePipe($row, $dryRun); - break; - case 'angle': - $this->migrateAngle($row, $dryRun); - break; - case 'raw_material': - $this->migrateRawMaterial($row, $dryRun); - break; + // 카테고리 분류 + if (in_array($category, ['220', '380'])) { + $productCategory = 'motor'; + $code = "EST-MOTOR-{$category}V-{$name}"; + $displayName = "모터 {$name} ({$category}V)"; + $partType = $name; + } elseif ($category === '제어기') { + $productCategory = 'controller'; + $code = "EST-CTRL-{$name}"; + $displayName = "제어기 {$name}"; + $partType = $name; + } else { + // 방화, 방범 등 + $productCategory = 'controller'; + $code = "EST-CTRL-{$category}-{$name}"; + $displayName = "{$category} {$name}"; + $partType = "{$category} {$name}"; + } + + $this->upsertEstimateItem( + code: $code, + name: $displayName, + productCategory: $productCategory, + partType: $partType, + specification: null, + attributes: ['voltage' => $category, 'source' => 'price_motor'], + salesPrice: (float) $price, + note: 'chandj.price_motor', + dryRun: $dryRun + ); + } + } + + /** + * chandj.price_raw_materials → 원자재 + * + * col1: 카테고리 (슬랫, 스크린) + * col2: 품명 (방화, 실리카, 화이바, 와이어 등) + * col13: 판매단가 + */ + private function migrateRawMaterials(bool $dryRun): void + { + $this->info('--- price_raw_materials (원자재) ---'); + + $row = DB::connection('chandj')->selectOne( + "SELECT itemList FROM price_raw_materials WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY registedate DESC LIMIT 1" + ); + if (! $row) { + return; + } + + $items = json_decode($row->itemList, true); + + foreach ($items as $item) { + $category = trim($item['col1'] ?? ''); + $name = trim($item['col2'] ?? ''); + $price = (int) str_replace(',', '', $item['col13'] ?? '0'); + + if (empty($name) || $price <= 0) { + $this->skipped++; + + continue; + } + + $code = "EST-RAW-{$category}-{$name}"; + $displayName = "{$category} {$name}"; + + $this->upsertEstimateItem( + code: $code, + name: $displayName, + productCategory: 'raw_material', + partType: $name, + specification: $category, + attributes: ['category' => $category, 'source' => 'price_raw_materials'], + salesPrice: (float) $price, + note: 'chandj.price_raw_materials', + dryRun: $dryRun + ); + } + } + + /** + * chandj.price_shaft → 감기샤프트 + * + * col4: 인치 (3, 4, 5, 6, 8, 10, 12) + * col10: 길이 (m) + * col19: 판매가 + */ + private function migrateShafts(bool $dryRun): void + { + $this->info('--- price_shaft (감기샤프트) ---'); + + $row = DB::connection('chandj')->selectOne( + "SELECT itemList FROM price_shaft WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1" + ); + if (! $row) { + return; + } + + $items = json_decode($row->itemList, true); + + foreach ($items as $item) { + $inch = trim($item['col4'] ?? ''); + $lengthM = trim($item['col10'] ?? ''); + $price = (int) str_replace(',', '', $item['col19'] ?? '0'); + + if (empty($inch) || empty($lengthM) || $price <= 0) { + $this->skipped++; + + continue; + } + + $code = "EST-SHAFT-{$inch}-{$lengthM}"; + $name = "감기샤프트 {$inch}인치 {$lengthM}m"; + + $this->upsertEstimateItem( + code: $code, + name: $name, + productCategory: 'shaft', + partType: $inch, + specification: $lengthM, + attributes: ['source' => 'price_shaft'], + salesPrice: (float) $price, + note: 'chandj.price_shaft', + dryRun: $dryRun + ); + } + } + + /** + * chandj.price_pipe → 각파이프 + * + * col4: 두께 (1.4, 2) + * col2: 길이 (3,000 / 6,000) + * col8: 판매가 + */ + private function migratePipes(bool $dryRun): void + { + $this->info('--- price_pipe (각파이프) ---'); + + $row = DB::connection('chandj')->selectOne( + "SELECT itemList FROM price_pipe WHERE (is_deleted IS NULL OR is_deleted = 0 OR is_deleted = '') ORDER BY NUM LIMIT 1" + ); + if (! $row) { + return; + } + + $items = json_decode($row->itemList, true); + + foreach ($items as $item) { + $thickness = trim($item['col4'] ?? ''); + $length = (int) str_replace(',', '', $item['col2'] ?? '0'); + $price = (int) str_replace(',', '', $item['col8'] ?? '0'); + + if (empty($thickness) || $length <= 0 || $price <= 0) { + $this->skipped++; + + continue; + } + + $code = "EST-PIPE-{$thickness}-{$length}"; + $name = "각파이프 {$thickness}T {$length}mm"; + + $this->upsertEstimateItem( + code: $code, + name: $name, + productCategory: 'pipe', + partType: $thickness, + specification: (string) $length, + attributes: ['spec' => $item['col3'] ?? '', 'source' => 'price_pipe'], + salesPrice: (float) $price, + note: 'chandj.price_pipe', + dryRun: $dryRun + ); + } + } + + /** + * chandj.price_angle → 앵글 (bracket + main 분리) + * + * bracket angle (모터 받침용): col2가 텍스트 (스크린용, 철제300K 등) + * - col2: 검색옵션, col3: 브라켓크기, col4: 앵글타입, col19: 판매가 + * + * main angle (부자재용): col2가 숫자 (4 등) + * - col4: 종류 (앵글3T, 앵글4T), col10: 길이 (2.5, 10), col19: 판매가 + */ + private function migrateAngles(bool $dryRun): void + { + $this->info('--- price_angle (앵글) ---'); + + $row = DB::connection('chandj')->selectOne( + "SELECT itemList FROM price_angle WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1" + ); + if (! $row) { + return; + } + + $items = json_decode($row->itemList, true); + + foreach ($items as $item) { + $col2 = trim($item['col2'] ?? ''); + $col3 = trim($item['col3'] ?? ''); + $col4 = trim($item['col4'] ?? ''); + $col10 = trim($item['col10'] ?? ''); + $price = (int) str_replace(',', '', $item['col19'] ?? '0'); + + if ($price <= 0) { + $this->skipped++; + + continue; + } + + // col2가 숫자이면 main angle, 텍스트이면 bracket angle + if (is_numeric($col2)) { + // Main angle (부자재용): col4=앵글3T, col10=2.5 + if (empty($col4) || empty($col10)) { + $this->skipped++; + + continue; + } + + $code = "EST-ANGLE-MAIN-{$col4}-{$col10}"; + $name = "앵글 {$col4} {$col10}m"; + + $this->upsertEstimateItem( + code: $code, + name: $name, + productCategory: 'angle_main', + partType: $col4, + specification: $col10, + attributes: ['source' => 'price_angle'], + salesPrice: (float) $price, + note: 'chandj.price_angle (main)', + dryRun: $dryRun + ); + } else { + // Bracket angle (모터 받침용): col2=스크린용, col3=380*180 + if (empty($col2)) { + $this->skipped++; + + continue; + } + + $code = "EST-ANGLE-BRACKET-{$col2}"; + $name = "모터받침 앵글 {$col2}"; + + $this->upsertEstimateItem( + code: $code, + name: $name, + productCategory: 'angle_bracket', + partType: $col2, + specification: $col3 ?: null, + attributes: [ + 'angle_type' => $col4, + 'source' => 'price_angle', + ], + salesPrice: (float) $price, + note: 'chandj.price_angle (bracket)', + dryRun: $dryRun + ); } } } - private function migrateMotor(object $row, bool $dryRun): void + /** + * chandj.price_smokeban → 연기차단재 + * + * col2: 용도 (레일용, 케이스용) + * col11: 판매가 + */ + private function migrateSmokeBan(bool $dryRun): void { - $category = $row->category; // 150K, 300K, 매립형, 노출형 등 - $code = "EST-MOTOR-{$category}"; - $name = "모터/제어기 {$category}"; + $this->info('--- price_smokeban (연기차단재) ---'); - $this->createEstimateItem( - code: $code, - name: $name, - productCategory: 'motor', - partType: $category, - specification: $row->spec2 ?? null, - attributes: ['price_unit' => $row->unit ?? 'EA'], - salesPrice: (float) $row->unit_price, - note: 'kd_price_tables motor 마이그레이션', - dryRun: $dryRun + $row = DB::connection('chandj')->selectOne( + "SELECT itemList FROM price_smokeban WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1" ); - } + if (! $row) { + return; + } - private function migrateShaft(object $row, bool $dryRun): void - { - $size = $row->spec1; // 인치 - $length = $row->spec2; // 길이 - $code = "EST-SHAFT-{$size}-{$length}"; - $name = "감기샤프트 {$size}인치 {$length}m"; + $items = json_decode($row->itemList, true); - $this->createEstimateItem( - code: $code, - name: $name, - productCategory: 'shaft', - partType: $size, - specification: $length, - attributes: ['price_unit' => $row->unit ?? 'EA'], - salesPrice: (float) $row->unit_price, - note: 'kd_price_tables shaft 마이그레이션', - dryRun: $dryRun - ); - } + foreach ($items as $item) { + $usage = trim($item['col2'] ?? ''); + $price = (int) str_replace(',', '', $item['col11'] ?? '0'); - private function migratePipe(object $row, bool $dryRun): void - { - $thickness = $row->spec1; - $length = $row->spec2; - $code = "EST-PIPE-{$thickness}-{$length}"; - $name = "각파이프 {$thickness}T {$length}mm"; + if (empty($usage) || $price <= 0) { + $this->skipped++; - $this->createEstimateItem( - code: $code, - name: $name, - productCategory: 'pipe', - partType: $thickness, - specification: $length, - attributes: ['price_unit' => $row->unit ?? 'EA'], - salesPrice: (float) $row->unit_price, - note: 'kd_price_tables pipe 마이그레이션', - dryRun: $dryRun - ); - } + continue; + } - private function migrateAngle(object $row, bool $dryRun): void - { - $category = $row->category; // 스크린용, 철재용 - $bracketSize = $row->spec1; // 530*320, 600*350, 690*390 - $angleType = $row->spec2; // 앵글3T, 앵글4T - $code = "EST-ANGLE-{$category}-{$bracketSize}-{$angleType}"; - $name = "앵글 {$category} {$bracketSize} {$angleType}"; + $code = "EST-SMOKE-{$usage}"; + $name = "연기차단재 {$usage}"; - $this->createEstimateItem( - code: $code, - name: $name, - productCategory: 'angle', - partType: $category, - specification: $bracketSize, - attributes: [ - 'angle_type' => $angleType, - 'price_unit' => $row->unit ?? 'EA', - ], - salesPrice: (float) $row->unit_price, - note: 'kd_price_tables angle 마이그레이션', - dryRun: $dryRun - ); - } - - private function migrateRawMaterial(object $row, bool $dryRun): void - { - $name = $row->item_name; - $code = 'EST-RAW-'.preg_replace('/[^A-Za-z0-9가-힣]/', '', $name); - - $this->createEstimateItem( - code: $code, - name: $name, - productCategory: 'raw_material', - partType: $name, - specification: $row->spec1 ?? null, - attributes: ['price_unit' => $row->unit ?? 'EA'], - salesPrice: (float) $row->unit_price, - note: 'kd_price_tables raw_material 마이그레이션', - dryRun: $dryRun - ); + $this->upsertEstimateItem( + code: $code, + name: $name, + productCategory: 'smokeban', + partType: $usage, + specification: null, + attributes: ['source' => 'price_smokeban'], + salesPrice: (float) $price, + note: 'chandj.price_smokeban', + dryRun: $dryRun + ); + } } /** - * 견적 품목 생성 (items + item_details + prices) + * 견적 품목 생성 또는 가격 업데이트 */ - private function createEstimateItem( + private function upsertEstimateItem( string $code, string $name, string $productCategory, @@ -296,7 +541,6 @@ private function createEstimateItem( string $note, bool $dryRun ): void { - // 중복 체크 (code 기준) $existing = DB::table('items') ->where('tenant_id', self::TENANT_ID) ->where('code', $code) @@ -304,13 +548,49 @@ private function createEstimateItem( ->first(); if ($existing) { - $this->line(" [스킵] {$code} - 이미 존재"); - $this->skipped++; + // 가격 업데이트 + $currentPrice = DB::table('prices') + ->where('item_id', $existing->id) + ->where('status', 'active') + ->orderByDesc('id') + ->value('sales_price'); + + if ((float) $currentPrice === $salesPrice) { + $this->skipped++; + + return; + } + + $this->line(" [업데이트] {$code} 가격: " . number_format($currentPrice ?? 0) . " → " . number_format($salesPrice)); + + if (! $dryRun) { + // 기존 가격 비활성화 + DB::table('prices') + ->where('item_id', $existing->id) + ->where('status', 'active') + ->update(['status' => 'inactive', 'updated_at' => now()]); + + // 새 가격 추가 + DB::table('prices')->insert([ + 'tenant_id' => self::TENANT_ID, + 'item_type_code' => 'PT', + 'item_id' => $existing->id, + 'sales_price' => $salesPrice, + 'effective_from' => now()->toDateString(), + 'status' => 'active', + 'note' => $note, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + $this->updated++; return; } - $this->line(" [생성] {$code} ({$name}) = {$salesPrice}"); + // 신규 생성 + $this->line(" [생성] {$code} ({$name}) = " . number_format($salesPrice)); if ($dryRun) { $this->created++; @@ -320,7 +600,6 @@ private function createEstimateItem( $now = now(); - // 1. items $itemId = DB::table('items')->insertGetId([ 'tenant_id' => self::TENANT_ID, 'item_type' => 'PT', @@ -333,7 +612,6 @@ private function createEstimateItem( 'updated_at' => $now, ]); - // 2. item_details DB::table('item_details')->insert([ 'item_id' => $itemId, 'product_category' => $productCategory, @@ -345,7 +623,6 @@ private function createEstimateItem( 'updated_at' => $now, ]); - // 3. prices DB::table('prices')->insert([ 'tenant_id' => self::TENANT_ID, 'item_type_code' => 'PT', diff --git a/app/Services/Quote/EstimatePriceService.php b/app/Services/Quote/EstimatePriceService.php index f66f336..88ec0c7 100644 --- a/app/Services/Quote/EstimatePriceService.php +++ b/app/Services/Quote/EstimatePriceService.php @@ -163,10 +163,41 @@ public function getRailSmokeBlockPrice(): float /** * 모터 단가 + * + * chandj col2는 '150K(S)', '300K(S)', '300K' 등 다양한 형식 + * handler는 '150K', '300K' 등 단순 용량으로 호출 + * LIKE 매칭 + 380V 기본 전압 필터 적용 */ - public function getMotorPrice(string $motorCapacity): float + public function getMotorPrice(string $motorCapacity, string $voltage = '380'): float { - return $this->getEstimatePartPrice('motor', $motorCapacity); + $cacheKey = "motor:{$motorCapacity}:{$voltage}"; + + if (isset($this->cache[$cacheKey])) { + return $this->cache[$cacheKey]; + } + + $today = now()->toDateString(); + + $price = (float) (DB::table('items') + ->join('item_details', 'item_details.item_id', '=', 'items.id') + ->join('prices', 'prices.item_id', '=', 'items.id') + ->where('items.tenant_id', $this->tenantId) + ->where('items.is_active', true) + ->whereNull('items.deleted_at') + ->where('item_details.product_category', 'motor') + ->where('item_details.part_type', 'LIKE', "{$motorCapacity}%") + ->where('items.attributes->voltage', $voltage) + ->where('prices.effective_from', '<=', $today) + ->where(function ($q) use ($today) { + $q->whereNull('prices.effective_to') + ->orWhere('prices.effective_to', '>=', $today); + }) + ->whereNull('prices.deleted_at') + ->value('prices.sales_price') ?? 0); + + $this->cache[$cacheKey] = $price; + + return $price; } /** @@ -174,7 +205,7 @@ public function getMotorPrice(string $motorCapacity): float */ public function getControllerPrice(string $controllerType): float { - return $this->getEstimatePartPrice('motor', $controllerType); + return $this->getEstimatePartPrice('controller', $controllerType); } // ========================================================================= @@ -183,10 +214,14 @@ public function getControllerPrice(string $controllerType): float /** * 샤프트 단가 + * + * chandj col10은 '0.3', '3', '6' 등 혼재 포맷 + * 정수면 '6', 소수면 '0.3' 그대로 저장됨 */ public function getShaftPrice(string $size, float $length): float { - $lengthStr = number_format($length, 1, '.', ''); + // chandj 원본 포맷에 맞게 변환: 정수면 정수형, 소수면 소수형 + $lengthStr = ($length == (int) $length) ? (string) (int) $length : (string) $length; $cacheKey = "shaft:{$size}:{$lengthStr}"; if (isset($this->cache[$cacheKey])) { @@ -208,11 +243,14 @@ public function getPipePrice(string $thickness, int $length): float } /** - * 앵글 단가 + * 모터 받침용 앵글 단가 (bracket angle) + * + * 5130: calculateAngle(qty, itemList, '스크린용') → col2 검색 + * chandj col2 값: '스크린용', '철제300K', '철제400K', '철제800K' */ - public function getAnglePrice(string $type, string $bracketSize, string $angleType): float + public function getAnglePrice(string $searchOption): float { - $cacheKey = "angle:{$type}:{$bracketSize}:{$angleType}"; + $cacheKey = "angle_bracket:{$searchOption}"; if (isset($this->cache[$cacheKey])) { return $this->cache[$cacheKey]; @@ -226,10 +264,8 @@ public function getAnglePrice(string $type, string $bracketSize, string $angleTy ->where('items.tenant_id', $this->tenantId) ->where('items.is_active', true) ->whereNull('items.deleted_at') - ->where('item_details.product_category', 'angle') - ->where('item_details.part_type', $type) - ->where('item_details.specification', $bracketSize) - ->where('items.attributes->angle_type', $angleType) + ->where('item_details.product_category', 'angle_bracket') + ->where('item_details.part_type', $searchOption) ->where('prices.effective_from', '<=', $today) ->where(function ($q) use ($today) { $q->whereNull('prices.effective_to') @@ -243,6 +279,25 @@ public function getAnglePrice(string $type, string $bracketSize, string $angleTy return $price; } + /** + * 부자재용 앵글 단가 (main angle) + * + * 5130: calculateMainAngle(1, itemList, '앵글3T', '2.5') → col4+col10 검색 + */ + public function getMainAnglePrice(string $angleType, string $size): float + { + $cacheKey = "angle_main:{$angleType}:{$size}"; + + if (isset($this->cache[$cacheKey])) { + return $this->cache[$cacheKey]; + } + + $price = $this->getEstimatePartPriceBySpec('angle_main', $angleType, $size); + $this->cache[$cacheKey] = $price; + + return $price; + } + /** * 원자재 단가 */ diff --git a/app/Services/Quote/Handlers/KyungdongFormulaHandler.php b/app/Services/Quote/Handlers/KyungdongFormulaHandler.php index e00f4e5..32aa24d 100644 --- a/app/Services/Quote/Handlers/KyungdongFormulaHandler.php +++ b/app/Services/Quote/Handlers/KyungdongFormulaHandler.php @@ -239,10 +239,13 @@ public function calculateScreenPrice(float $width, float $height): array // 원자재 단가 조회 (실리카/스크린) $unitPrice = $this->getRawMaterialPrice('실리카'); + // 5130 동일: round(area, 2) 후 단가 곱셈 + $roundedArea = round($area, 2); + return [ 'unit_price' => $unitPrice, - 'area' => round($area, 2), - 'total_price' => round($unitPrice * $area), + 'area' => $roundedArea, + 'total_price' => round($unitPrice * $roundedArea), ]; } @@ -291,11 +294,24 @@ public function getPipePrice(string $thickness, int $length): float } /** - * 앵글 단가 조회 + * 모터 받침용 앵글 단가 조회 + * + * @param string $searchOption 검색옵션 (스크린용, 철제300K 등) */ - public function getAnglePrice(string $type, string $bracketSize, string $angleType): float + public function getAnglePrice(string $searchOption): float { - return $this->priceService->getAnglePrice($type, $bracketSize, $angleType); + return $this->priceService->getAnglePrice($searchOption); + } + + /** + * 부자재용 앵글 단가 조회 + * + * @param string $angleType 앵글타입 (앵글3T, 앵글4T) + * @param string $size 길이 (2.5, 10) + */ + public function getMainAnglePrice(string $angleType, string $size): float + { + return $this->priceService->getMainAnglePrice($angleType, $size); } // ========================================================================= @@ -319,7 +335,7 @@ public function calculateSteelItems(array $params): array $width = (float) ($params['W0'] ?? 0); $height = (float) ($params['H0'] ?? 0); $quantity = (int) ($params['QTY'] ?? 1); - $modelName = $params['model_name'] ?? 'KSS01'; + $modelName = $params['model_name'] ?? $params['product_model'] ?? 'KSS01'; $finishingType = $params['finishing_type'] ?? 'SUS'; // 절곡품 관련 파라미터 @@ -365,13 +381,15 @@ public function calculateSteelItems(array $params): array } // 3. 케이스 마구리 (단가 × 수량) - $caseCapPrice = $this->priceService->getCaseCapPrice($caseSpec); + // 마구리 규격 = 케이스 규격 각 치수 + 5mm (레거시 updateCol45 공식) + $caseCapSpec = $this->convertToCaseCapSpec($caseSpec); + $caseCapPrice = $this->priceService->getCaseCapPrice($caseCapSpec); if ($caseCapPrice > 0) { $capQty = 2 * $quantity; // 좌우 2개 $items[] = [ 'category' => 'steel', 'item_name' => '케이스 마구리', - 'specification' => $caseSpec, + 'specification' => $caseCapSpec, 'unit' => 'EA', 'quantity' => $capQty, 'unit_price' => $caseCapPrice, @@ -616,19 +634,49 @@ public function calculatePartItems(array $params): array ]; } - // 3. 앵글 - $angleType = $productType === 'steel' ? '철재용' : '스크린용'; - $angleSpec = $bracketSize === '690*390' ? '앵글4T' : '앵글3T'; - $anglePrice = $this->getAnglePrice($angleType, $bracketSize, $angleSpec); + // 3. 모터 받침용 앵글 (bracket angle) + // 5130: calculateAngle(qty, itemList, '스크린용') → col2 검색, qty × $su × 4 + $motorCapacity = $params['MOTOR_CAPACITY'] ?? '300K'; + if ($productType === 'screen') { + $angleSearchOption = '스크린용'; + } else { + // 철재: bracketSize로 매핑 (530*320→철제300K, 600*350→철제400K, 690*390→철제800K) + $angleSearchOption = match ($bracketSize) { + '530*320' => '철제300K', + '600*350' => '철제400K', + '690*390' => '철제800K', + default => '철제300K', + }; + } + $anglePrice = $this->getAnglePrice($angleSearchOption); if ($anglePrice > 0) { + $angleQty = 4 * $quantity; // 5130: $su * 4 $items[] = [ 'category' => 'parts', - 'item_name' => "앵글 {$angleSpec}", - 'specification' => "{$angleType} {$bracketSize}", + 'item_name' => '모터 받침용 앵글', + 'specification' => $angleSearchOption, 'unit' => 'EA', - 'quantity' => 2 * $quantity, // 좌우 2개 + 'quantity' => $angleQty, 'unit_price' => $anglePrice, - 'total_price' => $anglePrice * 2 * $quantity, + 'total_price' => $anglePrice * $angleQty, + ]; + } + + // 4. 부자재 앵글 (main angle) + // 5130: calculateMainAngle(1, $itemList, '앵글3T', '2.5') × col71 + $mainAngleType = $bracketSize === '690*390' ? '앵글4T' : '앵글3T'; + $mainAngleSize = '2.5'; + $mainAngleQty = (int) ($params['main_angle_qty'] ?? 2); // col71, default 2 (좌우) + $mainAnglePrice = $this->getMainAnglePrice($mainAngleType, $mainAngleSize); + if ($mainAnglePrice > 0 && $mainAngleQty > 0) { + $items[] = [ + 'category' => 'parts', + 'item_name' => "앵글 {$mainAngleType}", + 'specification' => "{$mainAngleSize}m", + 'unit' => 'EA', + 'quantity' => $mainAngleQty * $quantity, + 'unit_price' => $mainAnglePrice, + 'total_price' => $mainAnglePrice * $mainAngleQty * $quantity, ]; } @@ -670,6 +718,21 @@ public function calculateDynamicItems(array $inputs): array $inputs['MOTOR_CAPACITY'] = $motorCapacity; $inputs['BRACKET_SIZE'] = $bracketSize; + // 0. 검사비 (5130: inspectionFee × col14, 기본 50,000원) + $inspectionFee = (int) ($inputs['inspection_fee'] ?? 50000); + if ($inspectionFee > 0) { + $items[] = [ + 'category' => 'inspection', + 'item_code' => 'KD-INSPECTION', + 'item_name' => '검사비', + 'specification' => '', + 'unit' => 'EA', + 'quantity' => $quantity, + 'unit_price' => $inspectionFee, + 'total_price' => $inspectionFee * $quantity, + ]; + } + // 1. 주자재 (스크린) $screenResult = $this->calculateScreenPrice($width, $height); $items[] = [ @@ -696,19 +759,40 @@ public function calculateDynamicItems(array $inputs): array 'total_price' => $motorPrice * $quantity, ]; - // 3. 제어기 + // 3. 제어기 (5130: 매립형×col15 + 노출형×col16 + 뒷박스×col17) $controllerType = $inputs['controller_type'] ?? '매립형'; + $controllerQty = (int) ($inputs['controller_qty'] ?? 1); $controllerPrice = $this->getControllerPrice($controllerType); - $items[] = [ - 'category' => 'controller', - 'item_code' => 'KD-CTRL-'.strtoupper($controllerType), - 'item_name' => "제어기 {$controllerType}", - 'specification' => $controllerType, - 'unit' => 'EA', - 'quantity' => $quantity, - 'unit_price' => $controllerPrice, - 'total_price' => $controllerPrice * $quantity, - ]; + if ($controllerPrice > 0 && $controllerQty > 0) { + $items[] = [ + 'category' => 'controller', + 'item_code' => 'KD-CTRL-'.strtoupper($controllerType), + 'item_name' => "제어기 {$controllerType}", + 'specification' => $controllerType, + 'unit' => 'EA', + 'quantity' => $controllerQty * $quantity, + 'unit_price' => $controllerPrice, + 'total_price' => $controllerPrice * $controllerQty * $quantity, + ]; + } + + // 뒷박스 (5130: col17 수량) + $backboxQty = (int) ($inputs['backbox_qty'] ?? 1); + if ($backboxQty > 0) { + $backboxPrice = $this->getControllerPrice('뒷박스'); + if ($backboxPrice > 0) { + $items[] = [ + 'category' => 'controller', + 'item_code' => 'KD-CTRL-BACKBOX', + 'item_name' => '뒷박스', + 'specification' => '', + 'unit' => 'EA', + 'quantity' => $backboxQty * $quantity, + 'unit_price' => $backboxPrice, + 'total_price' => $backboxPrice * $backboxQty * $quantity, + ]; + } + } // 4. 절곡품 $steelItems = $this->calculateSteelItems($inputs); @@ -720,4 +804,24 @@ public function calculateDynamicItems(array $inputs): array return $items; } + + /** + * 케이스 규격 → 마구리 규격 변환 + * + * 레거시 updateCol45/Slat_updateCol46 공식: + * 마구리 규격 = (케이스 가로 + 5) × (케이스 세로 + 5) + * 예: 500*380 → 505*385 + */ + private function convertToCaseCapSpec(string $caseSpec): string + { + if (str_contains($caseSpec, '*')) { + $parts = explode('*', $caseSpec); + $width = (int) trim($parts[0]) + 5; + $height = (int) trim($parts[1]) + 5; + + return "{$width}*{$height}"; + } + + return $caseSpec; + } } From ca51867cc29cda53f560a2f1ef7340e667eee24f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 22:17:11 +0900 Subject: [PATCH 52/57] =?UTF-8?q?feat:=20sam=5Fstat=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=EC=95=88=EC=A0=95=ED=99=94=20(Phase=20?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StatBackfillCommand: 과거 데이터 일괄 백필 (일간+월간, 프로그레스바, 에러 리포트) - StatVerifyCommand: 원본 DB vs sam_stat 정합성 교차 검증 (--fix 자동 재집계) - 파티셔닝 준비: 7개 일간 테이블 RANGE COLUMNS(stat_date) 마이그레이션 - Redis 캐싱: StatQueryService Cache::remember TTL 5분 + invalidateCache() - StatMonitorService: 집계 실패/누락/불일치 시 stat_alerts 알림 기록 - StatAggregatorService: 모니터링 알림 + 캐시 무효화 연동 Co-Authored-By: Claude Opus 4.5 --- app/Console/Commands/StatBackfillCommand.php | 174 +++++++++++++++ app/Console/Commands/StatVerifyCommand.php | 211 ++++++++++++++++++ app/Services/Stats/StatAggregatorService.php | 16 ++ app/Services/Stats/StatMonitorService.php | 120 ++++++++++ app/Services/Stats/StatQueryService.php | 107 ++++++--- ...0001_prepare_partitioning_daily_tables.php | 138 ++++++++++++ 6 files changed, 738 insertions(+), 28 deletions(-) create mode 100644 app/Console/Commands/StatBackfillCommand.php create mode 100644 app/Console/Commands/StatVerifyCommand.php create mode 100644 app/Services/Stats/StatMonitorService.php create mode 100644 database/migrations/stats/2026_01_29_300001_prepare_partitioning_daily_tables.php diff --git a/app/Console/Commands/StatBackfillCommand.php b/app/Console/Commands/StatBackfillCommand.php new file mode 100644 index 0000000..397401f --- /dev/null +++ b/app/Console/Commands/StatBackfillCommand.php @@ -0,0 +1,174 @@ +option('from'); + if (! $from) { + $this->error('--from 옵션은 필수입니다. 예: --from=2024-01-01'); + + return self::FAILURE; + } + + $startDate = Carbon::parse($from); + $endDate = $this->option('to') + ? Carbon::parse($this->option('to')) + : Carbon::yesterday(); + + $domain = $this->option('domain'); + $tenantId = $this->option('tenant') ? (int) $this->option('tenant') : null; + + $totalDays = $startDate->diffInDays($endDate) + 1; + + $this->info("📊 백필 시작: {$startDate->format('Y-m-d')} ~ {$endDate->format('Y-m-d')} ({$totalDays}일)"); + if ($domain) { + $this->info(" 도메인 필터: {$domain}"); + } + if ($tenantId) { + $this->info(" 테넌트 필터: {$tenantId}"); + } + + $totalErrors = []; + $totalDomainsProcessed = 0; + $startTime = microtime(true); + + // 1. 차원 테이블 동기화 (최초 1회) + if (! $this->option('skip-dimensions')) { + $this->info(''); + $this->info('🔄 차원 테이블 동기화...'); + try { + $tenants = $this->getTargetTenants($tenantId); + foreach ($tenants as $tenant) { + $clients = $dimensionSync->syncClients($tenant->id); + $products = $dimensionSync->syncProducts($tenant->id); + $this->line(" tenant={$tenant->id}: 고객 {$clients}건, 제품 {$products}건"); + } + } catch (\Throwable $e) { + $this->warn(" 차원 동기화 실패: {$e->getMessage()}"); + } + } + + // 2. 일간 집계 + $this->info(''); + $this->info('📅 일간 집계 시작...'); + $bar = $this->output->createProgressBar($totalDays); + $bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %message%'); + $bar->setMessage(''); + + $period = CarbonPeriod::create($startDate, $endDate); + + foreach ($period as $date) { + $bar->setMessage($date->format('Y-m-d')); + + try { + $result = $aggregator->aggregateDaily($date, $domain, $tenantId); + $totalDomainsProcessed += $result['domains_processed']; + + if (! empty($result['errors'])) { + $totalErrors = array_merge($totalErrors, $result['errors']); + } + } catch (\Throwable $e) { + $totalErrors[] = "daily {$date->format('Y-m-d')}: {$e->getMessage()}"; + } + + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + + // 3. 월간 집계 + if (! $this->option('skip-monthly')) { + $this->info(''); + $this->info('📆 월간 집계 시작...'); + + $months = $this->getMonthRange($startDate, $endDate); + $monthBar = $this->output->createProgressBar(count($months)); + + foreach ($months as [$year, $month]) { + $monthBar->setMessage("{$year}-".str_pad($month, 2, '0', STR_PAD_LEFT)); + + try { + $result = $aggregator->aggregateMonthly($year, $month, $domain, $tenantId); + $totalDomainsProcessed += $result['domains_processed']; + + if (! empty($result['errors'])) { + $totalErrors = array_merge($totalErrors, $result['errors']); + } + } catch (\Throwable $e) { + $totalErrors[] = "monthly {$year}-{$month}: {$e->getMessage()}"; + } + + $monthBar->advance(); + } + + $monthBar->finish(); + $this->newLine(); + } + + $durationSec = round(microtime(true) - $startTime, 1); + + $this->info(''); + $this->info('✅ 백필 완료:'); + $this->info(" - 기간: {$startDate->format('Y-m-d')} ~ {$endDate->format('Y-m-d')} ({$totalDays}일)"); + $this->info(" - 처리 도메인-테넌트: {$totalDomainsProcessed}건"); + $this->info(" - 소요 시간: {$durationSec}초"); + + if (! empty($totalErrors)) { + $this->warn(' - 에러: '.count($totalErrors).'건'); + foreach (array_slice($totalErrors, 0, 20) as $error) { + $this->error(" - {$error}"); + } + if (count($totalErrors) > 20) { + $this->warn(' ... 외 '.(count($totalErrors) - 20).'건'); + } + + return self::FAILURE; + } + + return self::SUCCESS; + } + + private function getTargetTenants(?int $tenantId): \Illuminate\Database\Eloquent\Collection + { + $query = \App\Models\Tenants\Tenant::where('tenant_st_code', '!=', 'none'); + if ($tenantId) { + $query->where('id', $tenantId); + } + + return $query->get(); + } + + private function getMonthRange(Carbon $start, Carbon $end): array + { + $months = []; + $current = $start->copy()->startOfMonth(); + $endMonth = $end->copy()->startOfMonth(); + + while ($current->lte($endMonth)) { + $months[] = [$current->year, $current->month]; + $current->addMonth(); + } + + return $months; + } +} diff --git a/app/Console/Commands/StatVerifyCommand.php b/app/Console/Commands/StatVerifyCommand.php new file mode 100644 index 0000000..6efb93e --- /dev/null +++ b/app/Console/Commands/StatVerifyCommand.php @@ -0,0 +1,211 @@ +option('date') + ? Carbon::parse($this->option('date')) + : Carbon::yesterday(); + $tenantId = $this->option('tenant') ? (int) $this->option('tenant') : null; + $domain = $this->option('domain'); + $dateStr = $date->format('Y-m-d'); + + $this->info("🔍 정합성 검증: {$dateStr}"); + + $tenants = $this->getTargetTenants($tenantId); + + $domains = $domain + ? [$domain] + : ['sales', 'finance', 'system']; + + foreach ($tenants as $tenant) { + $this->info(''); + $this->info("── tenant={$tenant->id} ──"); + + foreach ($domains as $d) { + match ($d) { + 'sales' => $this->verifySales($tenant->id, $dateStr), + 'finance' => $this->verifyFinance($tenant->id, $dateStr), + 'system' => $this->verifySystem($tenant->id, $dateStr), + default => $this->warn(" 미지원 도메인: {$d}"), + }; + } + } + + $this->printSummary(); + + if ($this->failedChecks > 0 && $this->option('fix')) { + $this->info(''); + $this->info('🔧 불일치 항목 재집계...'); + $this->reAggregate($date, $tenantId, $domains); + } + + return $this->failedChecks > 0 ? self::FAILURE : self::SUCCESS; + } + + private function verifySales(int $tenantId, string $dateStr): void + { + $this->line(' [sales]'); + + $originOrderCount = DB::connection('mysql') + ->table('orders') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->whereNull('deleted_at') + ->count(); + + $originSalesAmount = (float) DB::connection('mysql') + ->table('sales') + ->where('tenant_id', $tenantId) + ->where('sale_date', $dateStr) + ->whereNull('deleted_at') + ->sum('supply_amount'); + + $stat = StatSalesDaily::where('tenant_id', $tenantId) + ->where('stat_date', $dateStr) + ->first(); + + $this->check('수주건수', $originOrderCount, $stat?->order_count ?? 0, $tenantId, 'sales'); + $this->check('매출금액', $originSalesAmount, (float) ($stat?->sales_amount ?? 0), $tenantId, 'sales'); + } + + private function verifyFinance(int $tenantId, string $dateStr): void + { + $this->line(' [finance]'); + + $originDepositAmount = (float) DB::connection('mysql') + ->table('deposits') + ->where('tenant_id', $tenantId) + ->where('deposit_date', $dateStr) + ->whereNull('deleted_at') + ->sum('amount'); + + $originWithdrawalAmount = (float) DB::connection('mysql') + ->table('withdrawals') + ->where('tenant_id', $tenantId) + ->where('withdrawal_date', $dateStr) + ->whereNull('deleted_at') + ->sum('amount'); + + $stat = StatFinanceDaily::where('tenant_id', $tenantId) + ->where('stat_date', $dateStr) + ->first(); + + $this->check('입금액', $originDepositAmount, (float) ($stat?->deposit_amount ?? 0), $tenantId, 'finance'); + $this->check('출금액', $originWithdrawalAmount, (float) ($stat?->withdrawal_amount ?? 0), $tenantId, 'finance'); + } + + private function verifySystem(int $tenantId, string $dateStr): void + { + $this->line(' [system]'); + + $originApiCount = DB::connection('mysql') + ->table('api_request_logs') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->count(); + + $originAuditCount = DB::connection('mysql') + ->table('audit_logs') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->count(); + + $stat = StatSystemDaily::where('tenant_id', $tenantId) + ->where('stat_date', $dateStr) + ->first(); + + $this->check('API요청수', $originApiCount, $stat?->api_request_count ?? 0, $tenantId, 'system'); + + $statAuditTotal = ($stat?->audit_create_count ?? 0) + + ($stat?->audit_update_count ?? 0) + + ($stat?->audit_delete_count ?? 0); + $this->check('감사로그수', $originAuditCount, $statAuditTotal, $tenantId, 'system'); + } + + private function check(string $label, float|int $expected, float|int $actual, int $tenantId, string $domain): void + { + $this->totalChecks++; + + $tolerance = is_float($expected) ? 0.01 : 0; + $match = abs($expected - $actual) <= $tolerance; + + if ($match) { + $this->passedChecks++; + $this->line(" ✅ {$label}: {$actual}"); + } else { + $this->failedChecks++; + $this->error(" ❌ {$label}: 원본={$expected} / 통계={$actual} (차이=".($actual - $expected).')'); + $this->mismatches[] = compact('tenantId', 'domain', 'label', 'expected', 'actual'); + } + } + + private function printSummary(): void + { + $this->info(''); + $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + $this->info("📊 검증 결과: {$this->totalChecks}건 검사, ✅ {$this->passedChecks}건 일치, ❌ {$this->failedChecks}건 불일치"); + + if ($this->failedChecks > 0) { + $this->warn(''); + $this->warn('불일치 목록:'); + foreach ($this->mismatches as $m) { + $this->warn(" - tenant={$m['tenantId']} [{$m['domain']}] {$m['label']}: 원본={$m['expected']} / 통계={$m['actual']}"); + } + } + } + + private function reAggregate(Carbon $date, ?int $tenantId, array $domains): void + { + $aggregator = app(\App\Services\Stats\StatAggregatorService::class); + + foreach ($domains as $d) { + $result = $aggregator->aggregateDaily($date, $d, $tenantId); + $this->line(" {$d}: 재집계 완료 ({$result['domains_processed']}건)"); + + if (! empty($result['errors'])) { + foreach ($result['errors'] as $error) { + $this->error(" {$error}"); + } + } + } + + $this->info('✅ 재집계 완료. stat:verify를 다시 실행하여 확인하세요.'); + } + + private function getTargetTenants(?int $tenantId): \Illuminate\Database\Eloquent\Collection + { + $query = \App\Models\Tenants\Tenant::where('tenant_st_code', '!=', 'none'); + if ($tenantId) { + $query->where('id', $tenantId); + } + + return $query->get(); + } +} diff --git a/app/Services/Stats/StatAggregatorService.php b/app/Services/Stats/StatAggregatorService.php index 7ca5515..57826b0 100644 --- a/app/Services/Stats/StatAggregatorService.php +++ b/app/Services/Stats/StatAggregatorService.php @@ -71,11 +71,19 @@ public function aggregateDaily(Carbon $date, ?string $domain = null, ?int $tenan $jobLog->markCompleted($recordCount); $domainsProcessed++; + + // 캐시 무효화 + StatQueryService::invalidateCache($tenant->id, $domainKey); } catch (\Throwable $e) { $errorMsg = "[{$domainKey}] tenant={$tenant->id}: {$e->getMessage()}"; $errors[] = $errorMsg; $jobLog->markFailed($e->getMessage()); + // 모니터링 알림 기록 + app(StatMonitorService::class)->recordAggregationFailure( + $tenant->id, $domainKey, "{$domainKey}_daily", $e->getMessage() + ); + Log::error('stat:aggregate-daily 실패', [ 'domain' => $domainKey, 'tenant_id' => $tenant->id, @@ -129,11 +137,19 @@ public function aggregateMonthly(int $year, int $month, ?string $domain = null, $jobLog->markCompleted($recordCount); $domainsProcessed++; + + // 캐시 무효화 + StatQueryService::invalidateCache($tenant->id, $domainKey); } catch (\Throwable $e) { $errorMsg = "[{$domainKey}] tenant={$tenant->id}: {$e->getMessage()}"; $errors[] = $errorMsg; $jobLog->markFailed($e->getMessage()); + // 모니터링 알림 기록 + app(StatMonitorService::class)->recordAggregationFailure( + $tenant->id, $domainKey, "{$domainKey}_monthly", $e->getMessage() + ); + Log::error('stat:aggregate-monthly 실패', [ 'domain' => $domainKey, 'tenant_id' => $tenant->id, diff --git a/app/Services/Stats/StatMonitorService.php b/app/Services/Stats/StatMonitorService.php new file mode 100644 index 0000000..292df47 --- /dev/null +++ b/app/Services/Stats/StatMonitorService.php @@ -0,0 +1,120 @@ + $tenantId, + 'alert_type' => 'aggregation_failure', + 'domain' => $domain, + 'severity' => 'critical', + 'title' => "[{$jobType}] 집계 실패", + 'message' => mb_substr($errorMessage, 0, 500), + 'current_value' => 0, + 'threshold_value' => 0, + 'is_read' => false, + 'is_resolved' => false, + 'created_at' => now(), + ]); + } catch (\Throwable $e) { + Log::error('stat_alert 기록 실패', [ + 'tenant_id' => $tenantId, + 'domain' => $domain, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * 데이터 누락 알림 (특정 날짜에 통계 데이터 없음) + */ + public function recordMissingData(int $tenantId, string $domain, string $date): void + { + try { + // 동일 알림 중복 방지 + $exists = StatAlert::where('tenant_id', $tenantId) + ->where('alert_type', 'missing_data') + ->where('domain', $domain) + ->where('title', 'LIKE', "%{$date}%") + ->where('is_resolved', false) + ->exists(); + + if ($exists) { + return; + } + + StatAlert::create([ + 'tenant_id' => $tenantId, + 'alert_type' => 'missing_data', + 'domain' => $domain, + 'severity' => 'warning', + 'title' => "[{$domain}] {$date} 데이터 누락", + 'message' => "{$date} 날짜의 {$domain} 통계 데이터가 없습니다. stat:backfill 실행을 권장합니다.", + 'current_value' => 0, + 'threshold_value' => 1, + 'is_read' => false, + 'is_resolved' => false, + 'created_at' => now(), + ]); + } catch (\Throwable $e) { + Log::error('stat_alert 기록 실패 (missing_data)', [ + 'tenant_id' => $tenantId, + 'domain' => $domain, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * 정합성 불일치 알림 + */ + public function recordMismatch(int $tenantId, string $domain, string $label, float|int $expected, float|int $actual): void + { + try { + StatAlert::create([ + 'tenant_id' => $tenantId, + 'alert_type' => 'data_mismatch', + 'domain' => $domain, + 'severity' => 'critical', + 'title' => "[{$domain}] {$label} 정합성 불일치", + 'message' => "원본={$expected}, 통계={$actual}, 차이=".($actual - $expected), + 'current_value' => $actual, + 'threshold_value' => $expected, + 'is_read' => false, + 'is_resolved' => false, + 'created_at' => now(), + ]); + } catch (\Throwable $e) { + Log::error('stat_alert 기록 실패 (mismatch)', [ + 'tenant_id' => $tenantId, + 'domain' => $domain, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * 알림 해결 처리 + */ + public function resolveAlerts(int $tenantId, string $domain, string $alertType): int + { + return StatAlert::where('tenant_id', $tenantId) + ->where('domain', $domain) + ->where('alert_type', $alertType) + ->where('is_resolved', false) + ->update([ + 'is_resolved' => true, + 'resolved_at' => now(), + ]); + } +} diff --git a/app/Services/Stats/StatQueryService.php b/app/Services/Stats/StatQueryService.php index 8b8a538..2fb4894 100644 --- a/app/Services/Stats/StatQueryService.php +++ b/app/Services/Stats/StatQueryService.php @@ -16,9 +16,12 @@ use App\Models\Stats\StatAlert; use App\Services\Service; use Carbon\Carbon; +use Illuminate\Support\Facades\Cache; class StatQueryService extends Service { + private const CACHE_TTL = 300; // 5분 + /** * 도메인별 일간 통계 조회 */ @@ -28,16 +31,20 @@ public function getDailyStat(string $domain, array $params): array $startDate = $params['start_date']; $endDate = $params['end_date']; - $model = $this->getDailyModel($domain); - if (! $model) { - return []; - } + $cacheKey = "stat:daily:{$tenantId}:{$domain}:{$startDate}:{$endDate}"; - return $model::where('tenant_id', $tenantId) - ->whereBetween('stat_date', [$startDate, $endDate]) - ->orderBy('stat_date') - ->get() - ->toArray(); + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($domain, $tenantId, $startDate, $endDate) { + $model = $this->getDailyModel($domain); + if (! $model) { + return []; + } + + return $model::where('tenant_id', $tenantId) + ->whereBetween('stat_date', [$startDate, $endDate]) + ->orderBy('stat_date') + ->get() + ->toArray(); + }); } /** @@ -49,21 +56,25 @@ public function getMonthlyStat(string $domain, array $params): array $year = (int) $params['year']; $month = isset($params['month']) ? (int) $params['month'] : null; - $model = $this->getMonthlyModel($domain); - if (! $model) { - return []; - } + $cacheKey = "stat:monthly:{$tenantId}:{$domain}:{$year}:".($month ?? 'all'); - $query = $model::where('tenant_id', $tenantId) - ->where('stat_year', $year); + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($domain, $tenantId, $year, $month) { + $model = $this->getMonthlyModel($domain); + if (! $model) { + return []; + } - if ($month) { - $query->where('stat_month', $month); - } + $query = $model::where('tenant_id', $tenantId) + ->where('stat_year', $year); - return $query->orderBy('stat_month') - ->get() - ->toArray(); + if ($month) { + $query->where('stat_month', $month); + } + + return $query->orderBy('stat_month') + ->get() + ->toArray(); + }); } /** @@ -76,13 +87,17 @@ public function getDashboardSummary(): array $year = Carbon::now()->year; $month = Carbon::now()->month; - return [ - 'sales_today' => $this->getTodaySales($tenantId, $today), - 'finance_today' => $this->getTodayFinance($tenantId, $today), - 'production_today' => $this->getTodayProduction($tenantId, $today), - 'sales_monthly' => $this->getMonthlySalesOverview($tenantId, $year, $month), - 'alerts' => $this->getActiveAlerts($tenantId), - ]; + $cacheKey = "stat:dashboard:{$tenantId}:{$today}"; + + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tenantId, $today, $year, $month) { + return [ + 'sales_today' => $this->getTodaySales($tenantId, $today), + 'finance_today' => $this->getTodayFinance($tenantId, $today), + 'production_today' => $this->getTodayProduction($tenantId, $today), + 'sales_monthly' => $this->getMonthlySalesOverview($tenantId, $year, $month), + 'alerts' => $this->getActiveAlerts($tenantId), + ]; + }); } /** @@ -104,6 +119,42 @@ public function getAlerts(array $params): array return $query->limit($limit)->get()->toArray(); } + /** + * 집계 완료 시 관련 캐시 무효화 + */ + public static function invalidateCache(int $tenantId, ?string $domain = null): void + { + $patterns = [ + "stat:dashboard:{$tenantId}:*", + ]; + + if ($domain) { + $patterns[] = "stat:daily:{$tenantId}:{$domain}:*"; + $patterns[] = "stat:monthly:{$tenantId}:{$domain}:*"; + } else { + $patterns[] = "stat:daily:{$tenantId}:*"; + $patterns[] = "stat:monthly:{$tenantId}:*"; + } + + // Redis 태그 기반 또는 키 패턴 삭제 + $redis = Cache::getStore(); + if (method_exists($redis, 'getRedis')) { + $connection = $redis->getRedis(); + $prefix = config('cache.prefix', '').':'; + + foreach ($patterns as $pattern) { + $keys = $connection->keys($prefix.$pattern); + if (! empty($keys)) { + $connection->del($keys); + } + } + } else { + // Redis가 아닌 경우 개별 키 삭제 (대시보드만) + $today = Carbon::today()->format('Y-m-d'); + Cache::forget("stat:dashboard:{$tenantId}:{$today}"); + } + } + private function getTodaySales(int $tenantId, string $today): ?array { $stat = StatSalesDaily::where('tenant_id', $tenantId) diff --git a/database/migrations/stats/2026_01_29_300001_prepare_partitioning_daily_tables.php b/database/migrations/stats/2026_01_29_300001_prepare_partitioning_daily_tables.php new file mode 100644 index 0000000..c39e25d --- /dev/null +++ b/database/migrations/stats/2026_01_29_300001_prepare_partitioning_daily_tables.php @@ -0,0 +1,138 @@ + [ + 'unique_columns' => 'tenant_id, stat_date', + 'unique_name' => 'uk_tenant_date', + ], + 'stat_finance_daily' => [ + 'unique_columns' => 'tenant_id, stat_date', + 'unique_name' => 'uk_tenant_date', + ], + 'stat_production_daily' => [ + 'unique_columns' => 'tenant_id, stat_date', + 'unique_name' => 'uk_tenant_date', + ], + 'stat_inventory_daily' => [ + 'unique_columns' => 'tenant_id, stat_date', + 'unique_name' => 'uk_tenant_date', + ], + 'stat_quote_pipeline_daily' => [ + 'unique_columns' => 'tenant_id, stat_date', + 'unique_name' => 'uk_tenant_date', + ], + 'stat_hr_attendance_daily' => [ + 'unique_columns' => 'tenant_id, stat_date', + 'unique_name' => 'uk_tenant_date', + ], + 'stat_system_daily' => [ + 'unique_columns' => 'tenant_id, stat_date', + 'unique_name' => 'uk_tenant_date', + ], + ]; + } + + /** + * 연도별 파티션 정의 생성 (2024~2028 + MAXVALUE) + */ + private function getPartitionDefinitions(): string + { + $partitions = []; + for ($year = 2024; $year <= 2028; $year++) { + $partitions[] = "PARTITION p{$year} VALUES LESS THAN ('{$year}-01-01')"; + } + $partitions[] = 'PARTITION p_future VALUES LESS THAN MAXVALUE'; + + return implode(",\n ", $partitions); + } + + public function up(): void + { + $tables = $this->getTargetTables(); + $partitionDefs = $this->getPartitionDefinitions(); + + foreach ($tables as $table => $config) { + // 테이블 존재 여부 확인 + $exists = DB::connection('sam_stat') + ->select("SHOW TABLES LIKE '{$table}'"); + + if (empty($exists)) { + continue; + } + + // 이미 파티셔닝되어 있는지 확인 + $partitionInfo = DB::connection('sam_stat') + ->select('SELECT PARTITION_NAME FROM INFORMATION_SCHEMA.PARTITIONS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND PARTITION_NAME IS NOT NULL', + [$table]); + + if (! empty($partitionInfo)) { + continue; // 이미 파티셔닝됨 + } + + // AUTO_INCREMENT PK를 일반 PK로 변경 (파티션 키 포함) + // MySQL 파티셔닝 제약: UNIQUE KEY에 파티션 컬럼 포함 필수 + DB::connection('sam_stat')->statement(" + ALTER TABLE `{$table}` + DROP PRIMARY KEY, + ADD PRIMARY KEY (`id`, `stat_date`), + DROP INDEX `{$config['unique_name']}`, + ADD UNIQUE KEY `{$config['unique_name']}` ({$config['unique_columns']}) + "); + + // RANGE 파티셔닝 적용 + DB::connection('sam_stat')->statement(" + ALTER TABLE `{$table}` + PARTITION BY RANGE COLUMNS(`stat_date`) ( + {$partitionDefs} + ) + "); + } + } + + public function down(): void + { + $tables = $this->getTargetTables(); + + foreach ($tables as $table => $config) { + $exists = DB::connection('sam_stat') + ->select("SHOW TABLES LIKE '{$table}'"); + + if (empty($exists)) { + continue; + } + + // 파티션 제거 + DB::connection('sam_stat')->statement(" + ALTER TABLE `{$table}` REMOVE PARTITIONING + "); + + // PK 원복 + DB::connection('sam_stat')->statement(" + ALTER TABLE `{$table}` + DROP PRIMARY KEY, + ADD PRIMARY KEY (`id`) + "); + } + } +}; From e300062f327636eb1f519f003e004f726753a4ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 22:28:27 +0900 Subject: [PATCH 53/57] =?UTF-8?q?fix:=20=EA=B2=AC=EC=A0=81=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EB=A7=88=EA=B5=AC=EB=A6=AC=20=EC=88=98=EB=9F=89?= =?UTF-8?q?=C2=B7=EA=B0=81=ED=8C=8C=EC=9D=B4=ED=94=84=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=205130=20=EC=9D=BC=EC=B9=98=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 케이스 마구리: 수량 2 고정 → quantity 기반 (5130: maguriPrices × $su) - 각파이프: 하드코딩 1개 → pipe_3000_qty/pipe_6000_qty 2종 분리 (5130: col68+col69) - 기본값 fallback: 파이프 수량 미입력 시 W0 기준 자동 결정 유지 Co-Authored-By: Claude Opus 4.5 --- .../Handlers/KyungdongFormulaHandler.php | 55 ++++++++++++++----- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/app/Services/Quote/Handlers/KyungdongFormulaHandler.php b/app/Services/Quote/Handlers/KyungdongFormulaHandler.php index 32aa24d..0cf544a 100644 --- a/app/Services/Quote/Handlers/KyungdongFormulaHandler.php +++ b/app/Services/Quote/Handlers/KyungdongFormulaHandler.php @@ -385,7 +385,7 @@ public function calculateSteelItems(array $params): array $caseCapSpec = $this->convertToCaseCapSpec($caseSpec); $caseCapPrice = $this->priceService->getCaseCapPrice($caseCapSpec); if ($caseCapPrice > 0) { - $capQty = 2 * $quantity; // 좌우 2개 + $capQty = $quantity; // 5130: maguriPrices × $su (수량) $items[] = [ 'category' => 'steel', 'item_name' => '케이스 마구리', @@ -618,20 +618,47 @@ public function calculatePartItems(array $params): array ]; } - // 2. 각파이프 + // 2. 각파이프 (5130: col68=3000mm 수량, col69=6000mm 수량) $pipeThickness = '1.4'; - $pipeLength = $width > 3000 ? 6000 : 3000; - $pipePrice = $this->getPipePrice($pipeThickness, $pipeLength); - if ($pipePrice > 0) { - $items[] = [ - 'category' => 'parts', - 'item_name' => '각파이프', - 'specification' => "{$pipeThickness}T {$pipeLength}mm", - 'unit' => 'EA', - 'quantity' => $quantity, - 'unit_price' => $pipePrice, - 'total_price' => $pipePrice * $quantity, - ]; + $pipe3000Qty = (int) ($params['pipe_3000_qty'] ?? 0); + $pipe6000Qty = (int) ($params['pipe_6000_qty'] ?? 0); + + // 기본값: 둘 다 0이면 레거시 로직 (W0 기준 자동 결정) + if ($pipe3000Qty === 0 && $pipe6000Qty === 0) { + if ($width > 3000) { + $pipe6000Qty = 1; + } else { + $pipe3000Qty = 1; + } + } + + if ($pipe3000Qty > 0) { + $pipe3000Price = $this->getPipePrice($pipeThickness, 3000); + if ($pipe3000Price > 0) { + $items[] = [ + 'category' => 'parts', + 'item_name' => '각파이프', + 'specification' => "{$pipeThickness}T 3000mm", + 'unit' => 'EA', + 'quantity' => $pipe3000Qty, + 'unit_price' => $pipe3000Price, + 'total_price' => $pipe3000Price * $pipe3000Qty, + ]; + } + } + if ($pipe6000Qty > 0) { + $pipe6000Price = $this->getPipePrice($pipeThickness, 6000); + if ($pipe6000Price > 0) { + $items[] = [ + 'category' => 'parts', + 'item_name' => '각파이프', + 'specification' => "{$pipeThickness}T 6000mm", + 'unit' => 'EA', + 'quantity' => $pipe6000Qty, + 'unit_price' => $pipe6000Price, + 'total_price' => $pipe6000Price * $pipe6000Qty, + ]; + } } // 3. 모터 받침용 앵글 (bracket angle) From ca2dd44567444d56b45b65acb4fdbfb075a04478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 22:55:40 +0900 Subject: [PATCH 54/57] =?UTF-8?q?fix:=20=ED=99=98=EB=B4=89=C2=B7=EA=B0=81?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=205130=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EA=B3=B5=EC=8B=9D=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 환봉: W0 기준 자동계산 (≤3000→1, ≤6000→2, ≤9000→3, ≤12000→4 × 수량) - 각파이프: col67(케이스길이+3000×연결수) 기준 3000mm/6000mm 수량 자동계산 - 기존 하드코딩(각파이프 1개, 환봉 0개) 제거 Co-Authored-By: Claude Opus 4.5 --- .../Handlers/KyungdongFormulaHandler.php | 51 ++++++++++++++++--- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/app/Services/Quote/Handlers/KyungdongFormulaHandler.php b/app/Services/Quote/Handlers/KyungdongFormulaHandler.php index 0cf544a..1d6fe4d 100644 --- a/app/Services/Quote/Handlers/KyungdongFormulaHandler.php +++ b/app/Services/Quote/Handlers/KyungdongFormulaHandler.php @@ -348,7 +348,22 @@ public function calculateSteelItems(array $params): array $lbarLength = (float) ($params['lbar_length'] ?? ($width + 220)) / 1000; // m 단위 (레거시: W0+220) $flatBarLength = (float) ($params['flatbar_length'] ?? ($width + 220)) / 1000; // m 단위 (레거시: W0+220) $weightPlateQty = (int) ($params['weight_plate_qty'] ?? 0); // 무게평철 수량 - $roundBarQty = (int) ($params['round_bar_qty'] ?? 0); // 환봉 수량 + // 환봉 수량: 5130 자동계산 (col10=폭 기준) + // ≤3000→1, ≤6000→2, ≤9000→3, ≤12000→4 (× 수량) + $roundBarQty = (int) ($params['round_bar_qty'] ?? -1); + if ($roundBarQty < 0) { + if ($width <= 3000) { + $roundBarQty = 1 * $quantity; + } elseif ($width <= 6000) { + $roundBarQty = 2 * $quantity; + } elseif ($width <= 9000) { + $roundBarQty = 3 * $quantity; + } elseif ($width <= 12000) { + $roundBarQty = 4 * $quantity; + } else { + $roundBarQty = 0; + } + } // 1. 케이스 (단가/1000 × 길이mm × 수량) $casePrice = $this->priceService->getCasePrice($caseSpec); @@ -618,17 +633,39 @@ public function calculatePartItems(array $params): array ]; } - // 2. 각파이프 (5130: col68=3000mm 수량, col69=6000mm 수량) + // 2. 각파이프 (5130: col67 = col37 + 3000 × col66, col68/col69 자동계산) $pipeThickness = '1.4'; + $caseLength = (float) ($params['case_length'] ?? ($width + 220)); // col37 (mm) + $connectionCount = (int) ($params['connection_count'] ?? 0); // col66 (연결 수) + $pipeBaseLength = $caseLength + 3000 * $connectionCount; // col67 + + // 5130 자동계산 공식: col67 기준 $pipe3000Qty = (int) ($params['pipe_3000_qty'] ?? 0); $pipe6000Qty = (int) ($params['pipe_6000_qty'] ?? 0); - // 기본값: 둘 다 0이면 레거시 로직 (W0 기준 자동 결정) if ($pipe3000Qty === 0 && $pipe6000Qty === 0) { - if ($width > 3000) { - $pipe6000Qty = 1; - } else { - $pipe3000Qty = 1; + // col68: 3000mm 파이프 수량 + if ($pipeBaseLength <= 9000) { + $pipe3000Qty = 3 * $quantity; + } elseif ($pipeBaseLength <= 12000) { + $pipe3000Qty = 4 * $quantity; + } elseif ($pipeBaseLength <= 15000) { + $pipe3000Qty = 5 * $quantity; + } elseif ($pipeBaseLength <= 18000) { + $pipe3000Qty = 6 * $quantity; + } + + // col69: 6000mm 파이프 수량 (18000 초과 시) + if ($pipeBaseLength > 18000 && $pipeBaseLength <= 24000) { + $pipe6000Qty = 4 * $quantity; + } elseif ($pipeBaseLength > 24000 && $pipeBaseLength <= 30000) { + $pipe6000Qty = 5 * $quantity; + } elseif ($pipeBaseLength > 30000 && $pipeBaseLength <= 36000) { + $pipe6000Qty = 6 * $quantity; + } elseif ($pipeBaseLength > 36000 && $pipeBaseLength <= 42000) { + $pipe6000Qty = 7 * $quantity; + } elseif ($pipeBaseLength > 42000 && $pipeBaseLength <= 48000) { + $pipe6000Qty = 8 * $quantity; } } From 0fbd0808753c1fc16d1a2b7b3e21b89a74ed1983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 30 Jan 2026 09:23:51 +0900 Subject: [PATCH 55/57] =?UTF-8?q?docs:=20sam=5Fstat=20Swagger=20API=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80=20(Phase=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StatApi.php: Stats 태그, 4개 엔드포인트 Swagger 정의 - GET /stats/summary - 대시보드 통계 요약 - GET /stats/daily - 도메인별 일간 통계 - GET /stats/monthly - 도메인별 월간 통계 - GET /stats/alerts - 통계 알림 목록 - 스키마: StatSalesDaily, StatFinanceDaily, StatDashboardSummary, StatAlert Co-Authored-By: Claude Opus 4.5 --- app/Swagger/v1/StatApi.php | 273 +++++++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 app/Swagger/v1/StatApi.php diff --git a/app/Swagger/v1/StatApi.php b/app/Swagger/v1/StatApi.php new file mode 100644 index 0000000..8b8fe65 --- /dev/null +++ b/app/Swagger/v1/StatApi.php @@ -0,0 +1,273 @@ + Date: Fri, 30 Jan 2026 11:22:28 +0900 Subject: [PATCH 56/57] =?UTF-8?q?fix(API):=20=EC=9E=85=EA=B3=A0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=8B=9C=20completed=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UpdateReceivingRequest: status 허용값에 completed 추가, receiving_qty/receiving_date/lot_no 필드 추가 - ReceivingService::update(): status가 completed로 변경 시 LOT번호 자동생성, 입고수량/입고일 설정, 재고 연동(StockService) 처리 Co-Authored-By: Claude Opus 4.5 --- .../V1/Receiving/UpdateReceivingRequest.php | 5 +++- app/Services/ReceivingService.php | 23 ++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php b/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php index 1d73d79..4f32a9b 100644 --- a/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php +++ b/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php @@ -23,8 +23,11 @@ public function rules(): array 'order_qty' => ['sometimes', 'numeric', 'min:0'], 'order_unit' => ['nullable', 'string', 'max:20'], 'due_date' => ['nullable', 'date'], - 'status' => ['sometimes', 'string', 'in:order_completed,shipping,inspection_pending,receiving_pending'], + 'status' => ['sometimes', 'string', 'in:order_completed,shipping,inspection_pending,receiving_pending,completed'], 'remark' => ['nullable', 'string', 'max:1000'], + 'receiving_qty' => ['nullable', 'numeric', 'min:0'], + 'receiving_date' => ['nullable', 'date'], + 'lot_no' => ['nullable', 'string', 'max:50'], ]; } diff --git a/app/Services/ReceivingService.php b/app/Services/ReceivingService.php index 35ffa59..c00b1c0 100644 --- a/app/Services/ReceivingService.php +++ b/app/Services/ReceivingService.php @@ -186,16 +186,33 @@ public function update(int $id, array $data): Receiving if (array_key_exists('due_date', $data)) { $receiving->due_date = $data['due_date']; } - if (isset($data['status'])) { - $receiving->status = $data['status']; - } if (array_key_exists('remark', $data)) { $receiving->remark = $data['remark']; } + // 입고완료(completed) 상태로 변경 시 입고처리 로직 실행 + $isCompletingReceiving = isset($data['status']) + && $data['status'] === 'completed' + && $receiving->status !== 'completed'; + + if ($isCompletingReceiving) { + // 입고수량 설정 (없으면 발주수량 사용) + $receiving->receiving_qty = $data['receiving_qty'] ?? $receiving->order_qty; + $receiving->receiving_date = $data['receiving_date'] ?? now()->toDateString(); + $receiving->lot_no = $data['lot_no'] ?? $this->generateLotNo(); + $receiving->status = 'completed'; + } elseif (isset($data['status'])) { + $receiving->status = $data['status']; + } + $receiving->updated_by = $userId; $receiving->save(); + // 입고완료 시 재고 연동 + if ($isCompletingReceiving && $receiving->item_id) { + app(StockService::class)->increaseFromReceiving($receiving); + } + return $receiving->fresh(); }); } From 63afa4fc9b85866cb7105fe3a4811b41f83fdc51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 30 Jan 2026 11:23:35 +0900 Subject: [PATCH 57/57] =?UTF-8?q?feat(API):=20=EA=B2=BD=EB=8F=99=20?= =?UTF-8?q?=EA=B2=AC=EC=A0=81=20=EA=B3=84=EC=82=B0=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20stock=5Ftransactions=20=EA=B4=80=EA=B3=84=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FormulaEvaluatorService: 완제품 미등록 상태에서도 경동 전용 계산 진행, product_model/finishing_type/installation_type 변수 추가 - LOGICAL_RELATIONSHIPS.md: stock_transactions 모델 관계 반영 Co-Authored-By: Claude Opus 4.5 --- LOGICAL_RELATIONSHIPS.md | 10 ++++- .../Quote/FormulaEvaluatorService.php | 41 ++++++++++++------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 12cf164..973dd58 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-01-29 00:51:16 +> **자동 생성**: 2026-01-29 19:41:35 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -907,6 +907,7 @@ ### stocks - **item()**: belongsTo → `items` - **creator()**: belongsTo → `users` - **lots()**: hasMany → `stock_lots` +- **transactions()**: hasMany → `stock_transactions` ### stock_lots **모델**: `App\Models\Tenants\StockLot` @@ -915,6 +916,13 @@ ### stock_lots - **receiving()**: belongsTo → `receivings` - **creator()**: belongsTo → `users` +### stock_transactions +**모델**: `App\Models\Tenants\StockTransaction` + +- **stock()**: belongsTo → `stocks` +- **stockLot()**: belongsTo → `stock_lots` +- **creator()**: belongsTo → `users` + ### subscriptions **모델**: `App\Models\Tenants\Subscription` diff --git a/app/Services/Quote/FormulaEvaluatorService.php b/app/Services/Quote/FormulaEvaluatorService.php index d3942d8..e679490 100644 --- a/app/Services/Quote/FormulaEvaluatorService.php +++ b/app/Services/Quote/FormulaEvaluatorService.php @@ -1596,31 +1596,34 @@ private function calculateKyungdongBom( ['var' => 'QTY', 'desc' => '수량', 'value' => $QTY, 'unit' => 'EA'], ['var' => 'bracket_inch', 'desc' => '브라켓 인치', 'value' => $bracketInch, 'unit' => '인치'], ['var' => 'product_type', 'desc' => '제품 타입', 'value' => $productType, 'unit' => ''], + ['var' => 'product_model', 'desc' => '모델코드', 'value' => $inputVariables['product_model'] ?? 'KSS01', 'unit' => ''], + ['var' => 'finishing_type', 'desc' => '마감타입', 'value' => $inputVariables['finishing_type'] ?? 'SUS', 'unit' => ''], + ['var' => 'installation_type', 'desc' => '설치타입', 'value' => $inputVariables['installation_type'] ?? '벽면형', 'unit' => ''], ], ]); - // Step 2: 완제품 조회 + // Step 2: 완제품 조회 (경동 전용 계산은 완제품 없이도 동작) $finishedGoods = $this->getItemDetails($finishedGoodsCode, $tenantId); - if (! $finishedGoods) { + if ($finishedGoods) { + $this->addDebugStep(2, '완제품선택', [ + 'code' => $finishedGoods['code'], + 'name' => $finishedGoods['name'], + 'item_category' => $finishedGoods['item_category'] ?? 'N/A', + ]); + } else { + // 경동 전용: 완제품 미등록 상태에서도 견적 계산 진행 + $finishedGoods = [ + 'code' => $finishedGoodsCode, + 'name' => $finishedGoodsCode, + 'item_category' => 'estimate', + ]; $this->addDebugStep(2, '완제품선택', [ 'code' => $finishedGoodsCode, - 'error' => '완제품을 찾을 수 없습니다.', + 'note' => '경동 전용 계산 - 완제품 미등록 상태로 진행', ]); - - return [ - 'success' => false, - 'error' => __('error.finished_goods_not_found', ['code' => $finishedGoodsCode]), - 'debug_steps' => $this->debugSteps, - ]; } - $this->addDebugStep(2, '완제품선택', [ - 'code' => $finishedGoods['code'], - 'name' => $finishedGoods['name'], - 'item_category' => $finishedGoods['item_category'] ?? 'N/A', - ]); - // KyungdongFormulaHandler 인스턴스 생성 $handler = new KyungdongFormulaHandler; @@ -1646,6 +1649,11 @@ private function calculateKyungdongBom( // 브라켓 크기 결정 $bracketSize = $handler->calculateBracketSize($weight, $bracketInch); + // 핸들러가 필요한 키 보장 (inputVariables에서 전달되지 않으면 기본값) + $productModel = $inputVariables['product_model'] ?? 'KSS01'; + $finishingType = $inputVariables['finishing_type'] ?? 'SUS'; + $installationType = $inputVariables['installation_type'] ?? '벽면형'; + $calculatedVariables = array_merge($inputVariables, [ 'W0' => $W0, 'H0' => $H0, @@ -1658,6 +1666,9 @@ private function calculateKyungdongBom( 'BRACKET_SIZE' => $bracketSize, 'bracket_inch' => $bracketInch, 'product_type' => $productType, + 'product_model' => $productModel, + 'finishing_type' => $finishingType, + 'installation_type' => $installationType, ]); $this->addDebugStep(3, '변수계산', [