From e819635ea61265c90659b3405a658e4ffdd1cbfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 25 Feb 2026 19:39:52 +0900 Subject: [PATCH 001/166] =?UTF-8?q?feat:=20[equipment]=20=EC=84=A4?= =?UTF-8?q?=EB=B9=84=EA=B4=80=EB=A6=AC=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=206?= =?UTF-8?q?=EA=B0=9C=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - equipments (설비 마스터) - equipment_inspection_templates (점검항목 템플릿) - equipment_inspections (월간 점검 헤더) - equipment_inspection_details (일자별 점검 결과) - equipment_repairs (수리이력) - equipment_process (설비-공정 피봇) --- ...6_02_25_100000_create_equipments_table.php | 51 +++++++++++++++++++ ...e_equipment_inspection_templates_table.php | 39 ++++++++++++++ ...200_create_equipment_inspections_table.php | 38 ++++++++++++++ ...ate_equipment_inspection_details_table.php | 38 ++++++++++++++ ..._100400_create_equipment_repairs_table.php | 42 +++++++++++++++ ..._100500_create_equipment_process_table.php | 36 +++++++++++++ 6 files changed, 244 insertions(+) create mode 100644 database/migrations/2026_02_25_100000_create_equipments_table.php create mode 100644 database/migrations/2026_02_25_100100_create_equipment_inspection_templates_table.php create mode 100644 database/migrations/2026_02_25_100200_create_equipment_inspections_table.php create mode 100644 database/migrations/2026_02_25_100300_create_equipment_inspection_details_table.php create mode 100644 database/migrations/2026_02_25_100400_create_equipment_repairs_table.php create mode 100644 database/migrations/2026_02_25_100500_create_equipment_process_table.php diff --git a/database/migrations/2026_02_25_100000_create_equipments_table.php b/database/migrations/2026_02_25_100000_create_equipments_table.php new file mode 100644 index 0000000..512bb9b --- /dev/null +++ b/database/migrations/2026_02_25_100000_create_equipments_table.php @@ -0,0 +1,51 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->string('equipment_code', 20)->comment('설비코드 (KD-M-001 형식)'); + $table->string('name', 100)->comment('설비명'); + $table->string('equipment_type', 50)->nullable()->comment('설비유형 (포밍기/미싱기/샤링기/V컷팅기/절곡기/프레스/드릴)'); + $table->string('specification', 255)->nullable()->comment('규격'); + $table->string('manufacturer', 100)->nullable()->comment('제조사'); + $table->string('model_name', 100)->nullable()->comment('모델명'); + $table->string('serial_no', 100)->nullable()->comment('제조번호'); + $table->string('location', 100)->nullable()->comment('위치 (1공장-1F, 2공장-절곡 등)'); + $table->string('production_line', 50)->nullable()->comment('생산라인 (스라트/스크린/절곡)'); + $table->date('purchase_date')->nullable()->comment('구입일'); + $table->date('install_date')->nullable()->comment('설치일'); + $table->decimal('purchase_price', 15, 2)->nullable()->comment('구입가격'); + $table->integer('useful_life')->nullable()->comment('내용연수'); + $table->string('status', 20)->default('active')->comment('상태: active/idle/disposed'); + $table->date('disposed_date')->nullable()->comment('폐기일'); + $table->foreignId('manager_id')->nullable()->comment('담당자 ID (users.id)'); + $table->string('photo_path', 500)->nullable()->comment('설비사진 경로'); + $table->text('memo')->nullable()->comment('비고'); + $table->tinyInteger('is_active')->default(1)->comment('사용여부'); + $table->integer('sort_order')->default(0)->comment('정렬순서'); + $table->foreignId('created_by')->nullable()->comment('생성자 ID'); + $table->foreignId('updated_by')->nullable()->comment('수정자 ID'); + $table->foreignId('deleted_by')->nullable()->comment('삭제자 ID'); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['tenant_id', 'equipment_code'], 'uq_equipment_code'); + $table->index(['tenant_id', 'status'], 'idx_equipment_status'); + $table->index(['tenant_id', 'production_line'], 'idx_equipment_line'); + $table->index(['tenant_id', 'equipment_type'], 'idx_equipment_type'); + }); + } + + public function down(): void + { + Schema::dropIfExists('equipments'); + } +}; diff --git a/database/migrations/2026_02_25_100100_create_equipment_inspection_templates_table.php b/database/migrations/2026_02_25_100100_create_equipment_inspection_templates_table.php new file mode 100644 index 0000000..c2ba3b5 --- /dev/null +++ b/database/migrations/2026_02_25_100100_create_equipment_inspection_templates_table.php @@ -0,0 +1,39 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedBigInteger('equipment_id')->comment('설비 ID'); + $table->integer('item_no')->comment('항목번호 (1,2,3,4)'); + $table->string('check_point', 50)->comment('점검개소 (겉모양, 스위치, 롤러 등)'); + $table->string('check_item', 100)->comment('점검항목 (청결상태, 작동상태 등)'); + $table->string('check_timing', 20)->nullable()->comment('시기: operating/stopped'); + $table->string('check_frequency', 50)->nullable()->comment('주기 (1회/일)'); + $table->text('check_method')->nullable()->comment('점검방법 및 기준'); + $table->integer('sort_order')->default(0)->comment('정렬순서'); + $table->tinyInteger('is_active')->default(1)->comment('사용여부'); + $table->timestamps(); + + $table->unique(['equipment_id', 'item_no'], 'uq_equipment_item_no'); + $table->index('tenant_id', 'idx_insp_tmpl_tenant'); + + $table->foreign('equipment_id') + ->references('id') + ->on('equipments') + ->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('equipment_inspection_templates'); + } +}; diff --git a/database/migrations/2026_02_25_100200_create_equipment_inspections_table.php b/database/migrations/2026_02_25_100200_create_equipment_inspections_table.php new file mode 100644 index 0000000..f8637b8 --- /dev/null +++ b/database/migrations/2026_02_25_100200_create_equipment_inspections_table.php @@ -0,0 +1,38 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedBigInteger('equipment_id')->comment('설비 ID'); + $table->string('year_month', 7)->comment('점검년월 (2026-02)'); + $table->string('overall_judgment', 10)->nullable()->comment('종합판정: OK/NG'); + $table->foreignId('inspector_id')->nullable()->comment('점검자 ID (users.id)'); + $table->text('repair_note')->nullable()->comment('수리내역'); + $table->text('issue_note')->nullable()->comment('이상내용'); + $table->foreignId('created_by')->nullable()->comment('생성자 ID'); + $table->foreignId('updated_by')->nullable()->comment('수정자 ID'); + $table->timestamps(); + + $table->unique(['tenant_id', 'equipment_id', 'year_month'], 'uq_inspection_month'); + $table->index(['tenant_id', 'year_month'], 'idx_inspection_ym'); + + $table->foreign('equipment_id') + ->references('id') + ->on('equipments') + ->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('equipment_inspections'); + } +}; diff --git a/database/migrations/2026_02_25_100300_create_equipment_inspection_details_table.php b/database/migrations/2026_02_25_100300_create_equipment_inspection_details_table.php new file mode 100644 index 0000000..f92f40d --- /dev/null +++ b/database/migrations/2026_02_25_100300_create_equipment_inspection_details_table.php @@ -0,0 +1,38 @@ +id(); + $table->unsignedBigInteger('inspection_id')->comment('점검 헤더 ID'); + $table->unsignedBigInteger('template_item_id')->comment('점검항목 템플릿 ID'); + $table->date('check_date')->comment('점검일'); + $table->string('result', 10)->nullable()->comment('결과: good/bad/repaired'); + $table->string('note', 500)->nullable()->comment('비고'); + $table->timestamps(); + + $table->unique(['inspection_id', 'template_item_id', 'check_date'], 'uq_inspection_detail'); + + $table->foreign('inspection_id') + ->references('id') + ->on('equipment_inspections') + ->onDelete('cascade'); + + $table->foreign('template_item_id') + ->references('id') + ->on('equipment_inspection_templates') + ->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('equipment_inspection_details'); + } +}; diff --git a/database/migrations/2026_02_25_100400_create_equipment_repairs_table.php b/database/migrations/2026_02_25_100400_create_equipment_repairs_table.php new file mode 100644 index 0000000..5fac7d5 --- /dev/null +++ b/database/migrations/2026_02_25_100400_create_equipment_repairs_table.php @@ -0,0 +1,42 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedBigInteger('equipment_id')->comment('설비 ID'); + $table->date('repair_date')->comment('수리일'); + $table->string('repair_type', 20)->comment('보전구분: internal/external'); + $table->decimal('repair_hours', 5, 1)->nullable()->comment('수리시간'); + $table->text('description')->nullable()->comment('수리내용'); + $table->decimal('cost', 15, 2)->nullable()->comment('수리비용'); + $table->string('vendor', 100)->nullable()->comment('외주업체'); + $table->foreignId('repaired_by')->nullable()->comment('수리자 ID (users.id)'); + $table->text('memo')->nullable()->comment('비고'); + $table->foreignId('created_by')->nullable()->comment('생성자 ID'); + $table->foreignId('updated_by')->nullable()->comment('수정자 ID'); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['tenant_id', 'repair_date'], 'idx_repair_date'); + $table->index(['tenant_id', 'equipment_id'], 'idx_repair_equipment'); + + $table->foreign('equipment_id') + ->references('id') + ->on('equipments') + ->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('equipment_repairs'); + } +}; diff --git a/database/migrations/2026_02_25_100500_create_equipment_process_table.php b/database/migrations/2026_02_25_100500_create_equipment_process_table.php new file mode 100644 index 0000000..2f78ccb --- /dev/null +++ b/database/migrations/2026_02_25_100500_create_equipment_process_table.php @@ -0,0 +1,36 @@ +id(); + $table->unsignedBigInteger('equipment_id')->comment('설비 ID'); + $table->unsignedBigInteger('process_id')->comment('공정 ID'); + $table->tinyInteger('is_primary')->default(0)->comment('주 설비 여부'); + $table->timestamps(); + + $table->unique(['equipment_id', 'process_id'], 'uq_equipment_process'); + + $table->foreign('equipment_id') + ->references('id') + ->on('equipments') + ->onDelete('cascade'); + + $table->foreign('process_id') + ->references('id') + ->on('processes') + ->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('equipment_process'); + } +}; From a6e547f40d723f5e3c394c0b723ee4efec2ed3dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 25 Feb 2026 20:14:41 +0900 Subject: [PATCH 002/166] =?UTF-8?q?feat:=20[equipment]=20files=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=EC=97=90=20GCS=20=EC=BB=AC=EB=9F=BC=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 - gcs_object_name, gcs_uri 컬럼 추가 - 설비 사진 멀티 업로드 기능 지원 --- ..._110000_add_gcs_columns_to_files_table.php | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 database/migrations/2026_02_25_110000_add_gcs_columns_to_files_table.php diff --git a/database/migrations/2026_02_25_110000_add_gcs_columns_to_files_table.php b/database/migrations/2026_02_25_110000_add_gcs_columns_to_files_table.php new file mode 100644 index 0000000..b9bc828 --- /dev/null +++ b/database/migrations/2026_02_25_110000_add_gcs_columns_to_files_table.php @@ -0,0 +1,23 @@ +string('gcs_object_name', 500)->nullable()->after('file_type')->comment('GCS 객체 경로'); + $table->string('gcs_uri', 600)->nullable()->after('gcs_object_name')->comment('gs://bucket/object URI'); + }); + } + + public function down(): void + { + Schema::table('files', function (Blueprint $table) { + $table->dropColumn(['gcs_object_name', 'gcs_uri']); + }); + } +}; From 0345ddcce349412588f476f45bf94613337c407f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 25 Feb 2026 21:14:48 +0900 Subject: [PATCH 003/166] =?UTF-8?q?fix:=20=EC=84=B8=EC=85=98=20=EB=A7=8C?= =?UTF-8?q?=EB=A3=8C=20=EC=98=88=EC=99=B8=EB=A5=BC=20=EC=8A=AC=EB=9E=99=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=EC=97=90=EC=84=9C=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - '회원정보 정보 없음' AuthenticationException은 API Key 검증 통과 후 발생하므로 세션 만료 정상 케이스 - IP 기반 필터링(EXCEPTION_IGNORED_IPS) 대신 예외 자체를 무조건 제외하도록 단순화 Co-Authored-By: Claude Opus 4.6 --- app/Exceptions/Handler.php | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 83bdeef..03e127a 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -17,25 +17,13 @@ class Handler extends ExceptionHandler { /** - * 특정 IP에서 발생하는 예외를 슬랙/로그에서 무시할지 확인 + * 슬랙 알림에서 무시할 예외인지 확인 */ protected function shouldIgnoreException(Throwable $e): bool { - $ignoredIps = array_filter( - array_map('trim', explode(',', env('EXCEPTION_IGNORED_IPS', ''))) - ); - - if (empty($ignoredIps)) { - return false; - } - - $currentIp = request()?->ip(); - - // 무시할 IP 목록에 있고, '회원정보 정보 없음' 예외인 경우 - if (in_array($currentIp, $ignoredIps, true)) { - if ($e instanceof AuthenticationException && $e->getMessage() === '회원정보 정보 없음') { - return true; - } + // 세션 만료로 인한 인증 실패는 슬랙 알림 제외 (API Key 검증 통과 후 발생하므로 정상 케이스) + if ($e instanceof AuthenticationException && $e->getMessage() === '회원정보 정보 없음') { + return true; } return false; From 1a2350db7d794ddf49fe289e6260e3409dd1baac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 26 Feb 2026 14:29:12 +0900 Subject: [PATCH 004/166] =?UTF-8?q?feat:=20[calendar]=20=EB=8B=AC=EB=A0=A5?= =?UTF-8?q?=20=EC=9D=BC=EC=A0=95=20=EA=B4=80=EB=A6=AC=20API=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 - GET /api/v1/calendar-schedules — 연도별 일정 목록 조회 - GET /api/v1/calendar-schedules/stats — 통계 조회 - GET /api/v1/calendar-schedules/{id} — 단건 조회 - POST /api/v1/calendar-schedules — 등록 - PUT /api/v1/calendar-schedules/{id} — 수정 - DELETE /api/v1/calendar-schedules/{id} — 삭제 - POST /api/v1/calendar-schedules/bulk — 대량 등록 --- .../Api/V1/CalendarScheduleController.php | 133 +++++++++++++ app/Models/Commons/Holiday.php | 41 ++++ app/Services/CalendarScheduleService.php | 180 ++++++++++++++++++ routes/api/v1/hr.php | 12 ++ 4 files changed, 366 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/CalendarScheduleController.php create mode 100644 app/Models/Commons/Holiday.php create mode 100644 app/Services/CalendarScheduleService.php diff --git a/app/Http/Controllers/Api/V1/CalendarScheduleController.php b/app/Http/Controllers/Api/V1/CalendarScheduleController.php new file mode 100644 index 0000000..2940683 --- /dev/null +++ b/app/Http/Controllers/Api/V1/CalendarScheduleController.php @@ -0,0 +1,133 @@ +validate([ + 'year' => 'required|integer|min:2000|max:2100', + 'type' => 'nullable|string', + ]); + + return ApiResponse::handle( + fn () => $this->service->list( + (int) $request->input('year'), + $request->input('type') + ), + __('message.fetched') + ); + } + + /** + * 통계 조회 + */ + public function stats(Request $request): JsonResponse + { + $request->validate([ + 'year' => 'required|integer|min:2000|max:2100', + ]); + + return ApiResponse::handle( + fn () => $this->service->stats((int) $request->input('year')), + __('message.fetched') + ); + } + + /** + * 단건 조회 + */ + public function show(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->show($id), + __('message.fetched') + ); + } + + /** + * 등록 + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'start_date' => 'required|date', + 'end_date' => 'required|date|after_or_equal:start_date', + 'type' => 'required|string|in:public_holiday,temporary_holiday,substitute_holiday,tax_deadline,company_event', + 'is_recurring' => 'boolean', + 'memo' => 'nullable|string|max:500', + ]); + + return ApiResponse::handle( + fn () => $this->service->store($validated), + __('message.created') + ); + } + + /** + * 수정 + */ + public function update(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'start_date' => 'required|date', + 'end_date' => 'required|date|after_or_equal:start_date', + 'type' => 'required|string|in:public_holiday,temporary_holiday,substitute_holiday,tax_deadline,company_event', + 'is_recurring' => 'boolean', + 'memo' => 'nullable|string|max:500', + ]); + + return ApiResponse::handle( + fn () => $this->service->update($id, $validated), + __('message.updated') + ); + } + + /** + * 삭제 + */ + public function destroy(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->delete($id), + __('message.deleted') + ); + } + + /** + * 대량 등록 + */ + public function bulkStore(Request $request): JsonResponse + { + $validated = $request->validate([ + 'schedules' => 'required|array|min:1', + 'schedules.*.name' => 'required|string|max:100', + 'schedules.*.start_date' => 'required|date', + 'schedules.*.end_date' => 'required|date|after_or_equal:schedules.*.start_date', + 'schedules.*.type' => 'required|string|in:public_holiday,temporary_holiday,substitute_holiday,tax_deadline,company_event', + 'schedules.*.is_recurring' => 'boolean', + 'schedules.*.memo' => 'nullable|string|max:500', + ]); + + return ApiResponse::handle( + fn () => $this->service->bulkStore($validated['schedules']), + __('message.created') + ); + } +} diff --git a/app/Models/Commons/Holiday.php b/app/Models/Commons/Holiday.php new file mode 100644 index 0000000..216c8b7 --- /dev/null +++ b/app/Models/Commons/Holiday.php @@ -0,0 +1,41 @@ + 'date', + 'end_date' => 'date', + 'is_recurring' => 'boolean', + ]; + + public function scopeForTenant($query, int $tenantId) + { + return $query->where('tenant_id', $tenantId); + } + + public function scopeForYear($query, int $year) + { + return $query->whereYear('start_date', $year); + } +} diff --git a/app/Services/CalendarScheduleService.php b/app/Services/CalendarScheduleService.php new file mode 100644 index 0000000..a07a033 --- /dev/null +++ b/app/Services/CalendarScheduleService.php @@ -0,0 +1,180 @@ +tenantId()) + ->forYear($year) + ->orderBy('start_date'); + + if ($type) { + $query->where('type', $type); + } + + return $query->get()->map(function ($h) { + return [ + 'id' => $h->id, + 'name' => $h->name, + 'type' => $h->type, + 'start_date' => $h->start_date->format('Y-m-d'), + 'end_date' => $h->end_date->format('Y-m-d'), + 'days' => $h->start_date->diffInDays($h->end_date) + 1, + 'is_recurring' => $h->is_recurring, + 'memo' => $h->memo, + 'created_at' => $h->created_at?->toIso8601String(), + 'updated_at' => $h->updated_at?->toIso8601String(), + ]; + })->all(); + } + + /** + * 통계 조회 + */ + public function stats(int $year): array + { + $tenantId = $this->tenantId(); + $holidays = Holiday::forTenant($tenantId)->forYear($year)->get(); + + $totalDays = $holidays->sum(function ($h) { + return $h->start_date->diffInDays($h->end_date) + 1; + }); + + return [ + 'total_count' => $holidays->count(), + 'total_holiday_days' => $totalDays, + 'public_holiday_count' => $holidays->where('type', 'public_holiday')->count(), + ]; + } + + /** + * 단건 조회 + */ + public function show(int $id): array + { + $h = Holiday::forTenant($this->tenantId())->findOrFail($id); + + return [ + 'id' => $h->id, + 'name' => $h->name, + 'type' => $h->type, + 'start_date' => $h->start_date->format('Y-m-d'), + 'end_date' => $h->end_date->format('Y-m-d'), + 'days' => $h->start_date->diffInDays($h->end_date) + 1, + 'is_recurring' => $h->is_recurring, + 'memo' => $h->memo, + 'created_at' => $h->created_at?->toIso8601String(), + 'updated_at' => $h->updated_at?->toIso8601String(), + ]; + } + + /** + * 등록 + */ + public function store(array $data): array + { + $tenantId = $this->tenantId(); + + $exists = Holiday::forTenant($tenantId) + ->where('start_date', $data['start_date']) + ->where('end_date', $data['end_date']) + ->where('name', $data['name']) + ->exists(); + + if ($exists) { + throw new HttpException(422, __('error.duplicate')); + } + + $holiday = Holiday::create([ + 'tenant_id' => $tenantId, + 'start_date' => $data['start_date'], + 'end_date' => $data['end_date'], + 'name' => $data['name'], + 'type' => $data['type'] ?? 'public_holiday', + 'is_recurring' => $data['is_recurring'] ?? false, + 'memo' => $data['memo'] ?? null, + 'created_by' => $this->apiUserId(), + ]); + + return $this->show($holiday->id); + } + + /** + * 수정 + */ + public function update(int $id, array $data): array + { + $holiday = Holiday::forTenant($this->tenantId())->findOrFail($id); + + $holiday->update([ + 'start_date' => $data['start_date'], + 'end_date' => $data['end_date'], + 'name' => $data['name'], + 'type' => $data['type'], + 'is_recurring' => $data['is_recurring'] ?? false, + 'memo' => $data['memo'] ?? null, + 'updated_by' => $this->apiUserId(), + ]); + + return $this->show($id); + } + + /** + * 삭제 + */ + public function delete(int $id): void + { + $holiday = Holiday::forTenant($this->tenantId())->findOrFail($id); + $holiday->delete(); + } + + /** + * 대량 등록 + */ + public function bulkStore(array $schedules): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + $count = 0; + $skipped = 0; + + foreach ($schedules as $item) { + $exists = Holiday::forTenant($tenantId) + ->where('start_date', $item['start_date']) + ->where('end_date', $item['end_date']) + ->where('name', $item['name']) + ->exists(); + + if ($exists) { + $skipped++; + + continue; + } + + Holiday::create([ + 'tenant_id' => $tenantId, + 'start_date' => $item['start_date'], + 'end_date' => $item['end_date'], + 'name' => $item['name'], + 'type' => $item['type'] ?? 'public_holiday', + 'is_recurring' => $item['is_recurring'] ?? false, + 'memo' => $item['memo'] ?? null, + 'created_by' => $userId, + ]); + $count++; + } + + return [ + 'created' => $count, + 'skipped' => $skipped, + ]; + } +} diff --git a/routes/api/v1/hr.php b/routes/api/v1/hr.php index 4bcf9cf..0353a7e 100644 --- a/routes/api/v1/hr.php +++ b/routes/api/v1/hr.php @@ -16,6 +16,7 @@ use App\Http\Controllers\Api\V1\ApprovalFormController; use App\Http\Controllers\Api\V1\ApprovalLineController; use App\Http\Controllers\Api\V1\AttendanceController; +use App\Http\Controllers\Api\V1\CalendarScheduleController; use App\Http\Controllers\Api\V1\Construction\ContractController; use App\Http\Controllers\Api\V1\Construction\HandoverReportController; use App\Http\Controllers\Api\V1\Construction\StructureReviewController; @@ -213,3 +214,14 @@ Route::delete('/{id}', [StructureReviewController::class, 'destroy'])->whereNumber('id')->name('v1.construction.structure-reviews.destroy'); }); }); + +// Calendar Schedule API (달력 일정 관리) +Route::prefix('calendar-schedules')->group(function () { + Route::get('', [CalendarScheduleController::class, 'index'])->name('v1.calendar-schedules.index'); + Route::get('/stats', [CalendarScheduleController::class, 'stats'])->name('v1.calendar-schedules.stats'); + Route::post('', [CalendarScheduleController::class, 'store'])->name('v1.calendar-schedules.store'); + Route::post('/bulk', [CalendarScheduleController::class, 'bulkStore'])->name('v1.calendar-schedules.bulk'); + Route::get('/{id}', [CalendarScheduleController::class, 'show'])->whereNumber('id')->name('v1.calendar-schedules.show'); + Route::put('/{id}', [CalendarScheduleController::class, 'update'])->whereNumber('id')->name('v1.calendar-schedules.update'); + Route::delete('/{id}', [CalendarScheduleController::class, 'destroy'])->whereNumber('id')->name('v1.calendar-schedules.destroy'); +}); From fcb377a40ce6f0fba7d266cdad506176e46280a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 26 Feb 2026 20:56:41 +0900 Subject: [PATCH 005/166] =?UTF-8?q?feat:=20[attendance]=20attendance=5Freq?= =?UTF-8?q?uests=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=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 - 근태 승인 워크플로우용 신청 테이블 - tenant_id, user_id, request_type, start_date, end_date, status 등 --- ...00000_create_attendance_requests_table.php | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 database/migrations/2026_02_26_100000_create_attendance_requests_table.php diff --git a/database/migrations/2026_02_26_100000_create_attendance_requests_table.php b/database/migrations/2026_02_26_100000_create_attendance_requests_table.php new file mode 100644 index 0000000..3a969c2 --- /dev/null +++ b/database/migrations/2026_02_26_100000_create_attendance_requests_table.php @@ -0,0 +1,39 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('user_id')->comment('신청자'); + $table->enum('request_type', ['vacation', 'businessTrip', 'remote', 'fieldWork'])->comment('신청 유형'); + $table->date('start_date'); + $table->date('end_date'); + $table->text('reason')->nullable()->comment('사유'); + $table->enum('status', ['pending', 'approved', 'rejected'])->default('pending'); + $table->unsignedBigInteger('approved_by')->nullable()->comment('승인자'); + $table->timestamp('approved_at')->nullable(); + $table->text('reject_reason')->nullable()->comment('반려 사유'); + $table->json('json_details')->nullable()->comment('반차 구분 등 추가 정보'); + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + + $table->index(['tenant_id', 'status']); + $table->index(['tenant_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('attendance_requests'); + } +}; From ac7279606d8a4b11edbfc525f512858b24ec1123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 26 Feb 2026 22:29:58 +0900 Subject: [PATCH 006/166] =?UTF-8?q?chore:=20=EC=9A=B4=EC=98=81=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EC=8A=B9=EC=9D=B8=20=EB=8B=A8=EA=B3=84=20=EB=B9=84?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94=20(=EA=B0=9C=EB=B0=9C=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Production Approval stage 주석처리 - 런칭 후 다시 활성화 예정 - 배포 흐름: main push → Stage → Production (승인 없이 자동) Co-Authored-By: Claude Opus 4.6 --- Jenkinsfile | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index c0c7b43..3036f2a 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -53,18 +53,18 @@ pipeline { } } - // ── 운영 배포 승인 ── - stage('Production Approval') { - when { branch 'main' } - steps { - slackSend channel: '#product_deploy', color: '#FF9800', tokenCredentialId: 'slack-token', - message: "🔔 *api* 운영 배포 승인 대기 중\n${env.GIT_COMMIT_MSG}\nStage API: https://stage-api.sam.it.kr\n<${env.BUILD_URL}input|승인하러 가기>" - timeout(time: 24, unit: 'HOURS') { - input message: 'Stage 확인 후 운영 배포를 진행하시겠습니까?\nStage API: https://stage-api.sam.it.kr', - ok: '운영 배포 진행' - } - } - } + // ── 운영 배포 승인 (런칭 후 활성화) ── + // stage('Production Approval') { + // when { branch 'main' } + // steps { + // slackSend channel: '#product_deploy', color: '#FF9800', tokenCredentialId: 'slack-token', + // message: "🔔 *api* 운영 배포 승인 대기 중\n${env.GIT_COMMIT_MSG}\nStage API: https://stage-api.sam.it.kr\n<${env.BUILD_URL}input|승인하러 가기>" + // timeout(time: 24, unit: 'HOURS') { + // input message: 'Stage 확인 후 운영 배포를 진행하시겠습니까?\nStage API: https://stage-api.sam.it.kr', + // ok: '운영 배포 진행' + // } + // } + // } // ── main → 운영서버 Production 배포 ── stage('Deploy Production') { From 0d1d056b13519917daaf034c8151d4ff1e3aace0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 27 Feb 2026 10:06:25 +0900 Subject: [PATCH 007/166] =?UTF-8?q?feat:=20[payroll]=20payrolls=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EC=97=90=20long=5Fterm=5Fcare=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...1_add_long_term_care_to_payrolls_table.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 database/migrations/2026_02_27_100001_add_long_term_care_to_payrolls_table.php diff --git a/database/migrations/2026_02_27_100001_add_long_term_care_to_payrolls_table.php b/database/migrations/2026_02_27_100001_add_long_term_care_to_payrolls_table.php new file mode 100644 index 0000000..ad98f2d --- /dev/null +++ b/database/migrations/2026_02_27_100001_add_long_term_care_to_payrolls_table.php @@ -0,0 +1,22 @@ +decimal('long_term_care', 12, 0)->default(0)->after('health_insurance'); + }); + } + + public function down(): void + { + Schema::table('payrolls', function (Blueprint $table) { + $table->dropColumn('long_term_care'); + }); + } +}; From f53f04de65638d29793ba927a1395c15469c289f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 27 Feb 2026 10:42:15 +0900 Subject: [PATCH 008/166] =?UTF-8?q?fix:=20[cicd]=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=8B=9C=20storage/bootstrap=20=EA=B6=8C=ED=95=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mkdir 후 www-data:webservice 소유권 + 775 권한 설정 - Stage/Production 배포 모두 적용 - 원인: PHP-FPM(www-data)이 storage 쓰기 불가 → 500 에러 --- Jenkinsfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Jenkinsfile b/Jenkinsfile index 3036f2a..f76fef7 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -37,6 +37,8 @@ pipeline { ssh ${DEPLOY_USER}@211.117.60.189 ' cd /home/webservice/api-stage/releases/${RELEASE_ID} && mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs && + sudo chown -R www-data:webservice storage bootstrap/cache && + sudo chmod -R 775 storage bootstrap/cache && ln -sfn /home/webservice/api-stage/shared/.env .env && ln -sfn /home/webservice/api-stage/shared/storage/app storage/app && composer install --no-dev --optimize-autoloader --no-interaction && @@ -81,6 +83,8 @@ pipeline { ssh ${DEPLOY_USER}@211.117.60.189 ' cd /home/webservice/api/releases/${RELEASE_ID} && mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs && + sudo chown -R www-data:webservice storage bootstrap/cache && + sudo chmod -R 775 storage bootstrap/cache && ln -sfn /home/webservice/api/shared/.env .env && ln -sfn /home/webservice/api/shared/storage/app storage/app && composer install --no-dev --optimize-autoloader --no-interaction && From c94bef1dae7fcaf6a842c836ae4a7dd46d6d6d26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 27 Feb 2026 13:46:42 +0900 Subject: [PATCH 009/166] =?UTF-8?q?feat:=20[hr]=20=EC=82=AC=EC=97=85?= =?UTF-8?q?=EC=86=8C=EB=93=9D=EC=9E=90=EA=B4=80=EB=A6=AC=20worker=5Ftype?= =?UTF-8?q?=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tenant_user_profiles 테이블에 worker_type 컬럼 추가 (employee/business_income) - TenantUserProfile 모델 fillable에 worker_type 추가 --- app/Models/Tenants/TenantUserProfile.php | 1 + ...ker_type_to_tenant_user_profiles_table.php | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 database/migrations/2026_02_27_000000_add_worker_type_to_tenant_user_profiles_table.php diff --git a/app/Models/Tenants/TenantUserProfile.php b/app/Models/Tenants/TenantUserProfile.php index 4d4763b..12f20c2 100644 --- a/app/Models/Tenants/TenantUserProfile.php +++ b/app/Models/Tenants/TenantUserProfile.php @@ -44,6 +44,7 @@ class TenantUserProfile extends Model 'employee_status', 'manager_user_id', 'json_extra', + 'worker_type', 'profile_photo_path', 'display_name', ]; diff --git a/database/migrations/2026_02_27_000000_add_worker_type_to_tenant_user_profiles_table.php b/database/migrations/2026_02_27_000000_add_worker_type_to_tenant_user_profiles_table.php new file mode 100644 index 0000000..6f8a31e --- /dev/null +++ b/database/migrations/2026_02_27_000000_add_worker_type_to_tenant_user_profiles_table.php @@ -0,0 +1,25 @@ +string('worker_type', 20)->default('employee')->after('employee_status') + ->comment('근로자유형: employee(사원), business_income(사업소득자)'); + $table->index(['tenant_id', 'worker_type'], 'idx_tenant_worker_type'); + }); + } + + public function down(): void + { + Schema::table('tenant_user_profiles', function (Blueprint $table) { + $table->dropIndex('idx_tenant_worker_type'); + $table->dropColumn('worker_type'); + }); + } +}; From dd11f780b4c26f1256e86a306689546c3a7b1993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 27 Feb 2026 13:58:39 +0900 Subject: [PATCH 010/166] =?UTF-8?q?feat:=20[payroll]=20=EA=B7=BC=EB=A1=9C?= =?UTF-8?q?=EC=86=8C=EB=93=9D=EC=84=B8=20=EA=B0=84=EC=9D=B4=EC=84=B8?= =?UTF-8?q?=EC=95=A1=ED=91=9C=20DB=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8B=9C=EB=8D=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - income_tax_brackets 테이블 마이그레이션 생성 - 2024년 국세청 간이세액표 데이터 시더 (7,117건) - salary_from/salary_to(천원), family_count(1~11), tax_amount(원) --- ...00000_create_income_tax_brackets_table.php | 28 +++++++++++ database/seeders/IncomeTaxBracketSeeder.php | 47 +++++++++++++++++++ database/seeders/data/income_tax_2024.json | 1 + 3 files changed, 76 insertions(+) create mode 100644 database/migrations/2026_02_27_200000_create_income_tax_brackets_table.php create mode 100644 database/seeders/IncomeTaxBracketSeeder.php create mode 100644 database/seeders/data/income_tax_2024.json diff --git a/database/migrations/2026_02_27_200000_create_income_tax_brackets_table.php b/database/migrations/2026_02_27_200000_create_income_tax_brackets_table.php new file mode 100644 index 0000000..9ea4f84 --- /dev/null +++ b/database/migrations/2026_02_27_200000_create_income_tax_brackets_table.php @@ -0,0 +1,28 @@ +id(); + $table->unsignedSmallInteger('tax_year')->comment('적용 연도 (예: 2024)'); + $table->unsignedInteger('salary_from')->comment('구간 하한 (천원 단위)'); + $table->unsignedInteger('salary_to')->comment('구간 상한 (천원 단위)'); + $table->unsignedTinyInteger('family_count')->comment('공제대상가족수 (1~11)'); + $table->unsignedInteger('tax_amount')->comment('세액 (원)'); + $table->timestamps(); + + $table->index(['tax_year', 'salary_from', 'salary_to', 'family_count'], 'itb_lookup_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('income_tax_brackets'); + } +}; diff --git a/database/seeders/IncomeTaxBracketSeeder.php b/database/seeders/IncomeTaxBracketSeeder.php new file mode 100644 index 0000000..6e1b42b --- /dev/null +++ b/database/seeders/IncomeTaxBracketSeeder.php @@ -0,0 +1,47 @@ +where('tax_year', $taxYear)->delete(); + + // JSON 데이터 로드 (format: [[salary_from, salary_to, [tax1..tax11]], ...]) + $jsonPath = database_path('seeders/data/income_tax_2024.json'); + $data = json_decode(file_get_contents($jsonPath), true); + + $records = []; + $now = now(); + + foreach ($data as $row) { + [$salaryFrom, $salaryTo, $taxes] = $row; + + for ($fc = 1; $fc <= 11; $fc++) { + $records[] = [ + 'tax_year' => $taxYear, + 'salary_from' => $salaryFrom, + 'salary_to' => $salaryTo, + 'family_count' => $fc, + 'tax_amount' => $taxes[$fc - 1], + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + } + + // 500건씩 청크 삽입 + foreach (array_chunk($records, 500) as $chunk) { + DB::table('income_tax_brackets')->insert($chunk); + } + + $this->command->info("Income tax brackets seeded: {$taxYear}년 ".count($records).'건'); + } +} diff --git a/database/seeders/data/income_tax_2024.json b/database/seeders/data/income_tax_2024.json new file mode 100644 index 0000000..f9ba489 --- /dev/null +++ b/database/seeders/data/income_tax_2024.json @@ -0,0 +1 @@ +[[770,775,[0,0,0,0,0,0,0,0,0,0,0]],[775,780,[0,0,0,0,0,0,0,0,0,0,0]],[780,785,[0,0,0,0,0,0,0,0,0,0,0]],[785,790,[0,0,0,0,0,0,0,0,0,0,0]],[790,795,[0,0,0,0,0,0,0,0,0,0,0]],[795,800,[0,0,0,0,0,0,0,0,0,0,0]],[800,805,[0,0,0,0,0,0,0,0,0,0,0]],[805,810,[0,0,0,0,0,0,0,0,0,0,0]],[810,815,[0,0,0,0,0,0,0,0,0,0,0]],[815,820,[0,0,0,0,0,0,0,0,0,0,0]],[820,825,[0,0,0,0,0,0,0,0,0,0,0]],[825,830,[0,0,0,0,0,0,0,0,0,0,0]],[830,835,[0,0,0,0,0,0,0,0,0,0,0]],[835,840,[0,0,0,0,0,0,0,0,0,0,0]],[840,845,[0,0,0,0,0,0,0,0,0,0,0]],[845,850,[0,0,0,0,0,0,0,0,0,0,0]],[850,855,[0,0,0,0,0,0,0,0,0,0,0]],[855,860,[0,0,0,0,0,0,0,0,0,0,0]],[860,865,[0,0,0,0,0,0,0,0,0,0,0]],[865,870,[0,0,0,0,0,0,0,0,0,0,0]],[870,875,[0,0,0,0,0,0,0,0,0,0,0]],[875,880,[0,0,0,0,0,0,0,0,0,0,0]],[880,885,[0,0,0,0,0,0,0,0,0,0,0]],[885,890,[0,0,0,0,0,0,0,0,0,0,0]],[890,895,[0,0,0,0,0,0,0,0,0,0,0]],[895,900,[0,0,0,0,0,0,0,0,0,0,0]],[900,905,[0,0,0,0,0,0,0,0,0,0,0]],[905,910,[0,0,0,0,0,0,0,0,0,0,0]],[910,915,[0,0,0,0,0,0,0,0,0,0,0]],[915,920,[0,0,0,0,0,0,0,0,0,0,0]],[920,925,[0,0,0,0,0,0,0,0,0,0,0]],[925,930,[0,0,0,0,0,0,0,0,0,0,0]],[930,935,[0,0,0,0,0,0,0,0,0,0,0]],[935,940,[0,0,0,0,0,0,0,0,0,0,0]],[940,945,[0,0,0,0,0,0,0,0,0,0,0]],[945,950,[0,0,0,0,0,0,0,0,0,0,0]],[950,955,[0,0,0,0,0,0,0,0,0,0,0]],[955,960,[0,0,0,0,0,0,0,0,0,0,0]],[960,965,[0,0,0,0,0,0,0,0,0,0,0]],[965,970,[0,0,0,0,0,0,0,0,0,0,0]],[970,975,[0,0,0,0,0,0,0,0,0,0,0]],[975,980,[0,0,0,0,0,0,0,0,0,0,0]],[980,985,[0,0,0,0,0,0,0,0,0,0,0]],[985,990,[0,0,0,0,0,0,0,0,0,0,0]],[990,995,[0,0,0,0,0,0,0,0,0,0,0]],[995,1000,[0,0,0,0,0,0,0,0,0,0,0]],[1000,1005,[0,0,0,0,0,0,0,0,0,0,0]],[1005,1010,[0,0,0,0,0,0,0,0,0,0,0]],[1010,1015,[0,0,0,0,0,0,0,0,0,0,0]],[1015,1020,[0,0,0,0,0,0,0,0,0,0,0]],[1020,1025,[0,0,0,0,0,0,0,0,0,0,0]],[1025,1030,[0,0,0,0,0,0,0,0,0,0,0]],[1030,1035,[0,0,0,0,0,0,0,0,0,0,0]],[1035,1040,[0,0,0,0,0,0,0,0,0,0,0]],[1040,1045,[0,0,0,0,0,0,0,0,0,0,0]],[1045,1050,[0,0,0,0,0,0,0,0,0,0,0]],[1050,1055,[0,0,0,0,0,0,0,0,0,0,0]],[1055,1060,[0,0,0,0,0,0,0,0,0,0,0]],[1060,1065,[1040,0,0,0,0,0,0,0,0,0,0]],[1065,1070,[1110,0,0,0,0,0,0,0,0,0,0]],[1070,1075,[1180,0,0,0,0,0,0,0,0,0,0]],[1075,1080,[1250,0,0,0,0,0,0,0,0,0,0]],[1080,1085,[1320,0,0,0,0,0,0,0,0,0,0]],[1085,1090,[1390,0,0,0,0,0,0,0,0,0,0]],[1090,1095,[1460,0,0,0,0,0,0,0,0,0,0]],[1095,1100,[1530,0,0,0,0,0,0,0,0,0,0]],[1100,1105,[1600,0,0,0,0,0,0,0,0,0,0]],[1105,1110,[1670,0,0,0,0,0,0,0,0,0,0]],[1110,1115,[1740,0,0,0,0,0,0,0,0,0,0]],[1115,1120,[1810,0,0,0,0,0,0,0,0,0,0]],[1120,1125,[1880,0,0,0,0,0,0,0,0,0,0]],[1125,1130,[1950,0,0,0,0,0,0,0,0,0,0]],[1130,1135,[2020,0,0,0,0,0,0,0,0,0,0]],[1135,1140,[2090,0,0,0,0,0,0,0,0,0,0]],[1140,1145,[2160,0,0,0,0,0,0,0,0,0,0]],[1145,1150,[2230,0,0,0,0,0,0,0,0,0,0]],[1150,1155,[2300,0,0,0,0,0,0,0,0,0,0]],[1155,1160,[2370,0,0,0,0,0,0,0,0,0,0]],[1160,1165,[2440,0,0,0,0,0,0,0,0,0,0]],[1165,1170,[2500,0,0,0,0,0,0,0,0,0,0]],[1170,1175,[2570,0,0,0,0,0,0,0,0,0,0]],[1175,1180,[2640,0,0,0,0,0,0,0,0,0,0]],[1180,1185,[2710,0,0,0,0,0,0,0,0,0,0]],[1185,1190,[2780,0,0,0,0,0,0,0,0,0,0]],[1190,1195,[2850,0,0,0,0,0,0,0,0,0,0]],[1195,1200,[2920,0,0,0,0,0,0,0,0,0,0]],[1200,1205,[2990,0,0,0,0,0,0,0,0,0,0]],[1205,1210,[3060,0,0,0,0,0,0,0,0,0,0]],[1210,1215,[3130,0,0,0,0,0,0,0,0,0,0]],[1215,1220,[3200,0,0,0,0,0,0,0,0,0,0]],[1220,1225,[3270,0,0,0,0,0,0,0,0,0,0]],[1225,1230,[3340,0,0,0,0,0,0,0,0,0,0]],[1230,1235,[3410,0,0,0,0,0,0,0,0,0,0]],[1235,1240,[3480,0,0,0,0,0,0,0,0,0,0]],[1240,1245,[3550,0,0,0,0,0,0,0,0,0,0]],[1245,1250,[3620,0,0,0,0,0,0,0,0,0,0]],[1250,1255,[3700,0,0,0,0,0,0,0,0,0,0]],[1255,1260,[3810,0,0,0,0,0,0,0,0,0,0]],[1260,1265,[3910,0,0,0,0,0,0,0,0,0,0]],[1265,1270,[4010,0,0,0,0,0,0,0,0,0,0]],[1270,1275,[4120,0,0,0,0,0,0,0,0,0,0]],[1275,1280,[4220,0,0,0,0,0,0,0,0,0,0]],[1280,1285,[4320,0,0,0,0,0,0,0,0,0,0]],[1285,1290,[4430,0,0,0,0,0,0,0,0,0,0]],[1290,1295,[4530,0,0,0,0,0,0,0,0,0,0]],[1295,1300,[4630,0,0,0,0,0,0,0,0,0,0]],[1300,1305,[4740,0,0,0,0,0,0,0,0,0,0]],[1305,1310,[4840,0,0,0,0,0,0,0,0,0,0]],[1310,1315,[4940,0,0,0,0,0,0,0,0,0,0]],[1315,1320,[5050,0,0,0,0,0,0,0,0,0,0]],[1320,1325,[5150,0,0,0,0,0,0,0,0,0,0]],[1325,1330,[5250,0,0,0,0,0,0,0,0,0,0]],[1330,1335,[5360,0,0,0,0,0,0,0,0,0,0]],[1335,1340,[5460,0,0,0,0,0,0,0,0,0,0]],[1340,1345,[5560,1060,0,0,0,0,0,0,0,0,0]],[1345,1350,[5670,1170,0,0,0,0,0,0,0,0,0]],[1350,1355,[5770,1270,0,0,0,0,0,0,0,0,0]],[1355,1360,[5870,1370,0,0,0,0,0,0,0,0,0]],[1360,1365,[5980,1480,0,0,0,0,0,0,0,0,0]],[1365,1370,[6080,1580,0,0,0,0,0,0,0,0,0]],[1370,1375,[6180,1680,0,0,0,0,0,0,0,0,0]],[1375,1380,[6290,1790,0,0,0,0,0,0,0,0,0]],[1380,1385,[6390,1890,0,0,0,0,0,0,0,0,0]],[1385,1390,[6490,1990,0,0,0,0,0,0,0,0,0]],[1390,1395,[6600,2100,0,0,0,0,0,0,0,0,0]],[1395,1400,[6700,2200,0,0,0,0,0,0,0,0,0]],[1400,1405,[6800,2300,0,0,0,0,0,0,0,0,0]],[1405,1410,[6910,2410,0,0,0,0,0,0,0,0,0]],[1410,1415,[7010,2510,0,0,0,0,0,0,0,0,0]],[1415,1420,[7110,2610,0,0,0,0,0,0,0,0,0]],[1420,1425,[7210,2710,0,0,0,0,0,0,0,0,0]],[1425,1430,[7320,2820,0,0,0,0,0,0,0,0,0]],[1430,1435,[7420,2920,0,0,0,0,0,0,0,0,0]],[1435,1440,[7520,3020,0,0,0,0,0,0,0,0,0]],[1440,1445,[7630,3130,0,0,0,0,0,0,0,0,0]],[1445,1450,[7730,3230,0,0,0,0,0,0,0,0,0]],[1450,1455,[7830,3330,0,0,0,0,0,0,0,0,0]],[1455,1460,[7940,3440,0,0,0,0,0,0,0,0,0]],[1460,1465,[8040,3540,0,0,0,0,0,0,0,0,0]],[1465,1470,[8140,3640,0,0,0,0,0,0,0,0,0]],[1470,1475,[8250,3750,0,0,0,0,0,0,0,0,0]],[1475,1480,[8350,3850,0,0,0,0,0,0,0,0,0]],[1480,1485,[8450,3950,0,0,0,0,0,0,0,0,0]],[1485,1490,[8560,4060,0,0,0,0,0,0,0,0,0]],[1490,1495,[8660,4160,0,0,0,0,0,0,0,0,0]],[1495,1500,[8760,4260,0,0,0,0,0,0,0,0,0]],[1500,1510,[8920,4420,0,0,0,0,0,0,0,0,0]],[1510,1520,[9120,4620,0,0,0,0,0,0,0,0,0]],[1520,1530,[9330,4830,0,0,0,0,0,0,0,0,0]],[1530,1540,[9540,5040,0,0,0,0,0,0,0,0,0]],[1540,1550,[9740,5240,0,0,0,0,0,0,0,0,0]],[1550,1560,[9950,5450,0,0,0,0,0,0,0,0,0]],[1560,1570,[10160,5660,0,0,0,0,0,0,0,0,0]],[1570,1580,[10360,5860,0,0,0,0,0,0,0,0,0]],[1580,1590,[10570,6070,0,0,0,0,0,0,0,0,0]],[1590,1600,[10780,6280,0,0,0,0,0,0,0,0,0]],[1600,1610,[10980,6480,0,0,0,0,0,0,0,0,0]],[1610,1620,[11190,6690,0,0,0,0,0,0,0,0,0]],[1620,1630,[11400,6900,0,0,0,0,0,0,0,0,0]],[1630,1640,[11600,7100,0,0,0,0,0,0,0,0,0]],[1640,1650,[11810,7310,0,0,0,0,0,0,0,0,0]],[1650,1660,[12020,7520,0,0,0,0,0,0,0,0,0]],[1660,1670,[12220,7720,0,0,0,0,0,0,0,0,0]],[1670,1680,[12430,7930,0,0,0,0,0,0,0,0,0]],[1680,1690,[12640,8140,0,0,0,0,0,0,0,0,0]],[1690,1700,[12840,8340,0,0,0,0,0,0,0,0,0]],[1700,1710,[13050,8550,0,0,0,0,0,0,0,0,0]],[1710,1720,[13260,8760,0,0,0,0,0,0,0,0,0]],[1720,1730,[13460,8960,1040,0,0,0,0,0,0,0,0]],[1730,1740,[13670,9170,1240,0,0,0,0,0,0,0,0]],[1740,1750,[13880,9380,1440,0,0,0,0,0,0,0,0]],[1750,1760,[14080,9580,1640,0,0,0,0,0,0,0,0]],[1760,1770,[14290,9790,1830,0,0,0,0,0,0,0,0]],[1770,1780,[14500,10000,2030,0,0,0,0,0,0,0,0]],[1780,1790,[14700,10200,2230,0,0,0,0,0,0,0,0]],[1790,1800,[14910,10410,2430,0,0,0,0,0,0,0,0]],[1800,1810,[15110,10610,2630,0,0,0,0,0,0,0,0]],[1810,1820,[15320,10820,2830,0,0,0,0,0,0,0,0]],[1820,1830,[15530,11030,3020,0,0,0,0,0,0,0,0]],[1830,1840,[15730,11230,3220,0,0,0,0,0,0,0,0]],[1840,1850,[15940,11440,3420,0,0,0,0,0,0,0,0]],[1850,1860,[16150,11650,3620,0,0,0,0,0,0,0,0]],[1860,1870,[16350,11850,3820,0,0,0,0,0,0,0,0]],[1870,1880,[16560,12060,4020,0,0,0,0,0,0,0,0]],[1880,1890,[16770,12270,4220,0,0,0,0,0,0,0,0]],[1890,1900,[16970,12470,4410,1040,0,0,0,0,0,0,0]],[1900,1910,[17180,12680,4610,1240,0,0,0,0,0,0,0]],[1910,1920,[17390,12890,4810,1440,0,0,0,0,0,0,0]],[1920,1930,[17590,13090,5010,1630,0,0,0,0,0,0,0]],[1930,1940,[17800,13300,5210,1830,0,0,0,0,0,0,0]],[1940,1950,[18010,13510,5410,2030,0,0,0,0,0,0,0]],[1950,1960,[18210,13710,5600,2230,0,0,0,0,0,0,0]],[1960,1970,[18420,13920,5800,2430,0,0,0,0,0,0,0]],[1970,1980,[18630,14130,6000,2630,0,0,0,0,0,0,0]],[1980,1990,[18880,14330,6200,2820,0,0,0,0,0,0,0]],[1990,2000,[19200,14540,6400,3020,0,0,0,0,0,0,0]],[2000,2010,[19520,14750,6600,3220,0,0,0,0,0,0,0]],[2010,2020,[19850,14950,6800,3420,0,0,0,0,0,0,0]],[2020,2030,[20170,15160,6990,3620,0,0,0,0,0,0,0]],[2030,2040,[20490,15370,7190,3820,0,0,0,0,0,0,0]],[2040,2050,[20810,15570,7390,4020,0,0,0,0,0,0,0]],[2050,2060,[21130,15780,7590,4210,0,0,0,0,0,0,0]],[2060,2070,[21450,15990,7790,4410,1040,0,0,0,0,0,0]],[2070,2080,[21770,16190,7990,4610,1240,0,0,0,0,0,0]],[2080,2090,[22090,16400,8180,4810,1430,0,0,0,0,0,0]],[2090,2100,[22420,16600,8380,5010,1630,0,0,0,0,0,0]],[2100,2110,[22740,16810,8580,5210,1830,0,0,0,0,0,0]],[2110,2120,[23060,17020,8780,5400,2030,0,0,0,0,0,0]],[2120,2130,[23380,17220,8980,5600,2230,0,0,0,0,0,0]],[2130,2140,[23700,17430,9180,5800,2430,0,0,0,0,0,0]],[2140,2150,[24020,17640,9380,6000,2630,0,0,0,0,0,0]],[2150,2160,[24340,17840,9570,6200,2820,0,0,0,0,0,0]],[2160,2170,[24660,18050,9770,6400,3020,0,0,0,0,0,0]],[2170,2180,[24990,18260,9970,6600,3220,0,0,0,0,0,0]],[2180,2190,[25310,18460,10170,6790,3420,0,0,0,0,0,0]],[2190,2200,[25630,18670,10370,6990,3620,0,0,0,0,0,0]],[2200,2210,[25950,18950,10570,7190,3820,0,0,0,0,0,0]],[2210,2220,[26270,19270,10760,7390,4010,0,0,0,0,0,0]],[2220,2230,[26590,19590,10960,7590,4210,0,0,0,0,0,0]],[2230,2240,[26910,19910,11160,7790,4410,1040,0,0,0,0,0]],[2240,2250,[27240,20240,11360,7980,4610,1230,0,0,0,0,0]],[2250,2260,[27560,20560,11560,8180,4810,1430,0,0,0,0,0]],[2260,2270,[27880,20880,11760,8380,5010,1630,0,0,0,0,0]],[2270,2280,[28200,21200,11960,8580,5210,1830,0,0,0,0,0]],[2280,2290,[28520,21520,12150,8780,5400,2030,0,0,0,0,0]],[2290,2300,[28840,21840,12350,8980,5600,2230,0,0,0,0,0]],[2300,2310,[29160,22160,12550,9180,5800,2430,0,0,0,0,0]],[2310,2320,[29480,22480,12750,9370,6000,2620,0,0,0,0,0]],[2320,2330,[29810,22810,12950,9570,6200,2820,0,0,0,0,0]],[2330,2340,[30130,23130,13150,9770,6400,3020,0,0,0,0,0]],[2340,2350,[30450,23450,13340,9970,6590,3220,0,0,0,0,0]],[2350,2360,[30770,23770,13540,10170,6790,3420,0,0,0,0,0]],[2360,2370,[31090,24090,13740,10370,6990,3620,0,0,0,0,0]],[2370,2380,[31410,24410,13940,10560,7190,3810,0,0,0,0,0]],[2380,2390,[31730,24730,14140,10760,7390,4010,0,0,0,0,0]],[2390,2400,[32050,25050,14340,10960,7590,4210,0,0,0,0,0]],[2400,2410,[32380,25380,14530,11160,7780,4410,1030,0,0,0,0]],[2410,2420,[32700,25700,14730,11360,7980,4610,1230,0,0,0,0]],[2420,2430,[33020,26020,14930,11560,8180,4810,1430,0,0,0,0]],[2430,2440,[33340,26340,15130,11760,8380,5010,1630,0,0,0,0]],[2440,2450,[33660,26660,15330,11950,8580,5200,1830,0,0,0,0]],[2450,2460,[33980,26980,15530,12150,8780,5400,2030,0,0,0,0]],[2460,2470,[34300,27300,15730,12350,8980,5600,2230,0,0,0,0]],[2470,2480,[34630,27630,15920,12550,9170,5800,2420,0,0,0,0]],[2480,2490,[34950,27950,16120,12750,9370,6000,2620,0,0,0,0]],[2490,2500,[35270,28270,16320,12950,9570,6200,2820,0,0,0,0]],[2500,2510,[35600,28600,16530,13150,9780,6400,3030,0,0,0,0]],[2510,2520,[35940,28940,16740,13360,9990,6610,3240,0,0,0,0]],[2520,2530,[36280,29280,16950,13580,10200,6830,3450,0,0,0,0]],[2530,2540,[36630,29630,17160,13790,10410,7040,3660,0,0,0,0]],[2540,2550,[36970,29970,17370,14000,10620,7250,3870,0,0,0,0]],[2550,2560,[37310,30310,17590,14210,10840,7460,4090,0,0,0,0]],[2560,2570,[37650,30650,17800,14420,11050,7670,4300,0,0,0,0]],[2570,2580,[38000,31000,18010,14630,11260,7880,4510,1130,0,0,0]],[2580,2590,[38340,31340,18220,14850,11470,8100,4720,1350,0,0,0]],[2590,2600,[38830,31680,18430,15060,11680,8310,4930,1560,0,0,0]],[2600,2610,[39690,32020,18650,15270,11900,8520,5150,1770,0,0,0]],[2610,2620,[40540,32360,18920,15480,12110,8730,5360,1980,0,0,0]],[2620,2630,[41400,32710,19250,15690,12320,8940,5570,2190,0,0,0]],[2630,2640,[42260,33050,19580,15910,12530,9160,5780,2410,0,0,0]],[2640,2650,[43110,33390,19910,16120,12740,9370,5990,2620,0,0,0]],[2650,2660,[43970,33730,20240,16330,12960,9580,6210,2830,0,0,0]],[2660,2670,[44820,34080,20570,16540,13170,9790,6420,3040,0,0,0]],[2670,2680,[45680,34420,20900,16750,13380,10000,6630,3250,0,0,0]],[2680,2690,[46540,34760,21230,16970,13590,10220,6840,3470,0,0,0]],[2690,2700,[47390,35100,21560,17180,13800,10430,7050,3680,0,0,0]],[2700,2710,[48250,35450,21890,17390,14020,10640,7270,3890,0,0,0]],[2710,2720,[49100,35790,22220,17600,14230,10850,7480,4100,0,0,0]],[2720,2730,[49960,36130,22550,17810,14440,11060,7690,4310,0,0,0]],[2730,2740,[50810,36470,22880,18030,14650,11280,7900,4530,1150,0,0]],[2740,2750,[51670,36810,23210,18240,14860,11490,8110,4740,1360,0,0]],[2750,2760,[52530,37160,23540,18450,15070,11700,8320,4950,1570,0,0]],[2760,2770,[53380,37500,23870,18660,15290,11910,8540,5160,1790,0,0]],[2770,2780,[54240,37840,24200,18950,15500,12120,8750,5370,2000,0,0]],[2780,2790,[55090,38180,24520,19270,15710,12340,8960,5590,2210,0,0]],[2790,2800,[55950,38530,24850,19600,15920,12550,9170,5800,2420,0,0]],[2800,2810,[56800,39300,25180,19930,16130,12760,9380,6010,2630,0,0]],[2810,2820,[57660,40160,25510,20260,16350,12970,9600,6220,2850,0,0]],[2820,2830,[58520,41020,25840,20590,16560,13180,9810,6430,3060,0,0]],[2830,2840,[59370,41870,26170,20920,16770,13400,10020,6650,3270,0,0]],[2840,2850,[60230,42730,26500,21250,16980,13610,10230,6860,3480,0,0]],[2850,2860,[61080,43580,26830,21580,17190,13820,10440,7070,3690,0,0]],[2860,2870,[61940,44440,27160,21910,17410,14030,10660,7280,3910,0,0]],[2870,2880,[62790,45290,27490,22240,17620,14240,10870,7490,4120,0,0]],[2880,2890,[63650,46150,27820,22570,17830,14460,11080,7710,4330,0,0]],[2890,2900,[64510,47010,28150,22900,18040,14670,11290,7920,4540,1170,0]],[2900,2910,[65360,47860,28480,23230,18250,14880,11500,8130,4750,1380,0]],[2910,2920,[66220,48720,28810,23560,18470,15090,11720,8340,4970,1590,0]],[2920,2930,[67070,49570,29140,23890,18680,15300,11930,8550,5180,1800,0]],[2930,2940,[67930,50430,29470,24220,18970,15510,12140,8760,5390,2010,0]],[2940,2950,[68780,51280,29800,24550,19300,15730,12350,8980,5600,2230,0]],[2950,2960,[69640,52140,30130,24880,19630,15940,12560,9190,5810,2440,0]],[2960,2970,[70500,53000,30460,25210,19960,16150,12780,9400,6030,2650,0]],[2970,2980,[71350,53850,30790,25540,20290,16360,12990,9610,6240,2860,0]],[2980,2990,[72210,54710,31120,25870,20620,16570,13200,9820,6450,3070,0]],[2990,3000,[73060,55560,31450,26200,20950,16790,13410,10040,6660,3290,0]],[3000,3020,[74350,56850,31940,26690,21440,17100,13730,10350,6980,3600,0]],[3020,3040,[76060,58560,32600,27350,22100,17530,14150,10780,7400,4030,0]],[3040,3060,[77770,60270,33260,28010,22760,17950,14580,11200,7830,4450,1080]],[3060,3080,[79480,61980,33920,28670,23420,18380,15000,11630,8250,4880,1500]],[3080,3100,[81190,63690,34580,29330,24080,18830,15430,12050,8680,5300,1930]],[3100,3120,[82900,65400,35240,29990,24740,19490,15850,12470,9100,5720,2350]],[3120,3140,[84620,67120,35900,30650,25400,20150,16270,12900,9520,6150,2770]],[3140,3160,[86330,68830,36560,31310,26060,20810,16700,13320,9950,6570,3200]],[3160,3180,[88040,70540,37220,31970,26720,21470,17120,13750,10370,7000,3620]],[3180,3200,[89750,72250,37880,32630,27380,22130,17540,14170,10790,7420,4040]],[3200,3220,[91460,73960,38540,33290,28040,22790,17970,14590,11220,7840,4470]],[3220,3240,[93170,75670,40120,33950,28700,23450,18390,15020,11640,8270,4890]],[3240,3260,[95430,77380,41770,34610,29360,24110,18860,15440,12070,8690,5320]],[3260,3280,[97880,79100,43420,35270,30020,24770,19520,15870,12490,9120,5740]],[3280,3300,[100320,80810,45070,35920,30670,25420,20170,16290,12910,9540,6160]],[3300,3320,[102770,82520,46720,36580,31330,26080,20830,16710,13340,9960,6590]],[3320,3340,[105210,84230,48370,37240,31990,26740,21490,17140,13760,10390,7010]],[3340,3360,[107660,85940,49940,37870,32620,27370,22120,17540,14170,10790,7420]],[3360,3380,[110100,87650,51510,38500,33250,28000,22750,17950,14570,11200,7820]],[3380,3400,[112550,89370,53070,39950,33880,28630,23380,18350,14970,11600,8220]],[3400,3420,[114990,91080,54640,41510,34500,29250,24000,18750,15370,12000,8620]],[3420,3440,[117440,92790,56200,43080,35130,29880,24630,19380,15780,12400,9030]],[3440,3460,[119880,94880,57770,44640,35750,30500,25250,20000,16180,12800,9430]],[3460,3480,[122330,97330,59330,46210,36380,31130,25880,20630,16580,13210,9830]],[3480,3500,[124770,99770,60900,47770,37010,31760,26510,21260,16980,13610,10230]],[3500,3520,[127220,102220,62460,49340,37630,32380,27130,21880,17390,14010,10640]],[3520,3540,[129660,104660,64030,50900,38260,33010,27760,22510,17790,14410,11040]],[3540,3560,[132110,107110,65590,52460,39340,33630,28380,23130,18190,14820,11440]],[3560,3580,[134550,109550,67150,54030,40900,34260,29010,23760,18590,15220,11840]],[3580,3600,[137000,112000,68720,55590,42470,34880,29630,24380,19130,15620,12250]],[3600,3620,[139440,114440,70280,57160,44030,35510,30260,25010,19760,16020,12650]],[3620,3640,[141890,116890,71850,58720,45600,36140,30890,25640,20390,16420,13050]],[3640,3660,[144330,119330,73410,60290,47160,36760,31510,26260,21010,16830,13450]],[3660,3680,[146780,121780,74980,61850,48730,37390,32140,26890,21640,17230,13850]],[3680,3700,[149220,124220,76540,63420,50290,38010,32760,27510,22260,17630,14260]],[3700,3720,[151670,126670,78110,64980,51860,38730,33390,28140,22890,18030,14660]],[3720,3740,[154110,129110,79670,66550,53420,40300,34020,28770,23520,18440,15060]],[3740,3760,[156560,131560,81230,68110,54980,41860,34640,29390,24140,18890,15460]],[3760,3780,[163920,136090,84260,71130,58010,44880,35850,30600,25350,20100,16240]],[3780,3800,[166590,138740,85970,72850,59720,46600,36540,31290,26040,20790,16680]],[3800,3820,[169260,141400,87680,74560,61430,48310,37220,31970,26720,21470,17120]],[3820,3840,[171930,144050,89390,76270,63140,50020,37900,32650,27400,22150,17560]],[3840,3860,[174600,146710,91100,77980,64850,51730,38600,33340,28090,22840,18000]],[3860,3880,[177270,149360,92820,79690,66570,53440,40320,34020,28770,23520,18440]],[3880,3900,[179940,152020,94920,81400,68280,55150,42030,34710,29460,24210,18960]],[3900,3920,[182610,154670,97370,83110,69990,56860,43740,35390,30140,24890,19640]],[3920,3940,[185280,157330,99810,84830,71700,58580,45450,36080,30830,25580,20330]],[3940,3960,[187950,159980,102260,86540,73410,60290,47160,36760,31510,26260,21010]],[3960,3980,[190620,162640,104700,88250,75120,62000,48870,37450,32200,26950,21700]],[3980,4000,[193290,165290,107150,89960,76840,63710,50590,38130,32880,27630,22380]],[4000,4020,[195960,167950,109590,91670,78550,65420,52300,39170,33570,28320,23070]],[4020,4040,[198630,170600,112040,93380,80260,67130,54010,40880,34250,29000,23750]],[4040,4060,[201300,173260,114480,95730,81970,68840,55720,42590,34930,29680,24430]],[4060,4080,[203970,175910,116930,98180,83680,70560,57430,44310,35620,30370,25120]],[4080,4100,[206640,178570,119370,100620,85390,72270,59140,46020,36300,31050,25800]],[4100,4120,[209310,181220,121820,103070,87100,73980,60850,47730,36990,31740,26490]],[4120,4140,[211980,183880,124260,105510,88820,75690,62570,49440,37670,32420,27170]],[4140,4160,[214650,186530,126710,107960,90530,77400,64280,51150,38360,33110,27860]],[4160,4180,[217320,189190,129150,110400,92240,79110,65990,52860,39740,33790,28540]],[4180,4200,[219990,191840,131600,112850,94100,80830,67700,54580,41450,34480,29230]],[4200,4220,[222660,194500,134040,115290,96540,82540,69410,56290,43160,35160,29910]],[4220,4240,[225330,197150,136490,117740,98990,84250,71120,58000,44870,35850,30600]],[4240,4260,[228000,199810,138930,120180,101430,85960,72830,59710,46580,36530,31280]],[4260,4280,[230670,202460,141380,122630,103880,87670,74550,61420,48300,37220,31970]],[4280,4300,[233340,205120,143820,125070,106320,89380,76260,63130,50010,37900,32650]],[4300,4320,[236010,207770,146270,127520,108770,91090,77970,64840,51720,38590,33330]],[4320,4340,[238680,210430,148710,129960,111210,92810,79680,66560,53430,40310,34020]],[4340,4360,[241350,213080,151160,132410,113660,94910,81390,68270,55140,42020,34700]],[4360,4380,[244020,215740,153600,134850,116100,97350,83100,69980,56850,43730,35390]],[4380,4400,[246690,218390,156050,137300,118550,99800,84820,71690,58570,45440,36070]],[4400,4420,[249360,221050,158490,139740,120990,102240,86530,73400,60280,47150,36760]],[4420,4440,[252030,223700,160940,142190,123440,104690,88240,75110,61990,48860,37440]],[4440,4460,[254700,226360,163380,144630,125880,107130,89950,76820,63700,50570,38130]],[4460,4480,[257370,229010,165830,147080,128330,109580,91660,78540,65410,52290,39160]],[4480,4500,[260040,231670,168270,149520,130770,112020,93370,80250,67120,54000,40870]],[4500,4520,[262840,234460,170850,152100,133350,114600,95850,82050,68930,55800,42680]],[4520,4540,[265650,237250,173430,154680,135930,117180,98430,83860,70730,57610,44480]],[4540,4560,[268450,240040,176010,157260,138510,119760,101010,85670,72540,59420,46290]],[4560,4580,[271260,242830,178590,159840,141090,122340,103590,87470,74350,61220,48100]],[4580,4600,[276560,248120,183670,164920,146170,127420,108670,89920,76150,63030,49900]],[4600,4620,[279370,250910,186250,167500,148750,130000,111250,92500,77960,64830,51710]],[4620,4640,[282170,253700,188830,170080,151330,132580,113830,95080,79760,66640,53510]],[4640,4660,[284980,256490,191410,172660,153910,135160,116410,97660,81570,68450,55320]],[4660,4680,[287780,259280,193990,175240,156490,137740,118990,100240,83380,70250,57130]],[4680,4700,[290590,262070,196570,177820,159070,140320,121570,102820,85180,72060,58930]],[4700,4720,[293390,264860,199150,180400,161650,142900,124150,105400,86990,73860,60740]],[4720,4740,[296200,267650,201730,182980,164230,145480,126730,107980,89230,75670,62540]],[4740,4760,[299000,270440,204310,185560,166810,148060,129310,110560,91810,77480,64350]],[4760,4780,[301810,273230,206890,188140,169390,150640,131890,113140,94390,79280,66160]],[4780,4800,[304610,276020,209470,190720,171970,153220,134470,115720,96970,81090,67960]],[4800,4820,[307420,278810,212050,193300,174550,155800,137050,118300,99550,82890,69770]],[4820,4840,[310220,281600,214630,195880,177130,158380,139630,120880,102130,84700,71570]],[4840,4860,[313030,284390,217210,198460,179710,160960,142210,123460,104710,86510,73380]],[4860,4880,[315830,287180,219790,201040,182290,163540,144790,126040,107290,88540,75190]],[4880,4900,[318640,289970,222370,203620,184870,166120,147370,128620,109870,91120,76990]],[4900,4920,[321440,292760,224950,206200,187450,168700,149950,131200,112450,93700,78800]],[4920,4940,[324250,295550,227530,208780,190030,171280,152530,133780,115030,96280,80600]],[4940,4960,[327050,298340,230110,211360,192610,173860,155110,136360,117610,98860,82410]],[4960,4980,[329860,301130,232690,213940,195190,176440,157690,138940,120190,101440,84220]],[4980,5000,[332660,303920,235270,216520,197770,179020,160270,141520,122770,104020,86020]],[5000,5020,[335470,306710,237850,219100,200350,181600,162850,144100,125350,106600,87850]],[5020,5040,[338270,309500,240430,221680,202930,184180,165430,146680,127930,109180,90430]],[5040,5060,[341080,312290,243010,224260,205510,186760,168010,149260,130510,111760,93010]],[5060,5080,[343880,315080,245590,226840,208090,189340,170590,151840,133090,114340,95590]],[5080,5100,[346690,317870,248170,229420,210670,191920,173170,154420,135670,116920,98170]],[5100,5120,[349490,320660,250750,232000,213250,194500,175750,157000,138250,119500,100750]],[5120,5140,[352300,323450,253330,234580,215830,197080,178330,159580,140830,122080,103330]],[5140,5160,[355100,326240,255910,237160,218410,199660,180910,162160,143410,124660,105910]],[5160,5180,[357910,329030,258490,239740,220990,202240,183490,164740,145990,127240,108490]],[5180,5200,[360710,331820,261070,242320,223570,204820,186070,167320,148570,129820,111070]],[5200,5220,[363520,334610,263650,244900,226150,207400,188650,169900,151150,132400,113650]],[5220,5240,[366320,337400,266230,247480,228730,209980,191230,172480,153730,134980,116230]],[5240,5260,[369130,340190,268810,250060,231310,212560,193810,175060,156310,137560,118810]],[5260,5280,[371930,342980,271390,252640,233890,215140,196390,177640,158890,140140,121390]],[5280,5300,[374740,345770,273970,255220,236470,217720,198970,180220,161470,142720,123970]],[5300,5320,[377540,348560,276550,257800,239050,220300,201550,182800,164050,145300,126550]],[5320,5340,[380350,351350,279130,260380,241630,222880,204130,185380,166630,147880,129130]],[5340,5360,[383150,354140,281710,262960,244210,225460,206710,187960,169210,150460,131710]],[5360,5380,[385960,356930,284290,265540,246790,228040,209290,190540,171790,153040,134290]],[5380,5400,[388760,359720,286870,268120,249370,230620,211870,193120,174370,155620,136870]],[5400,5420,[391570,362510,289450,270700,251950,233200,214450,195700,176950,158200,139450]],[5420,5440,[394370,365300,292030,273280,254530,235780,217030,198280,179530,160780,142030]],[5440,5460,[397180,368090,294610,275860,257110,238360,219610,200860,182110,163360,144610]],[5460,5480,[399980,370880,297190,278440,259690,240940,222190,203440,184690,165940,147190]],[5480,5500,[402790,373670,299770,281020,262270,243520,224770,206020,187270,168520,149770]],[5500,5520,[405590,376460,302350,283600,264850,246100,227350,208600,189850,171100,152350]],[5520,5540,[408400,379250,304930,286180,267430,248680,229930,211180,192430,173680,154930]],[5540,5560,[411200,382040,307510,288760,270010,251260,232510,213760,195010,176260,157510]],[5560,5580,[414010,384830,310090,291340,272590,253840,235090,216340,197590,178840,160090]],[5580,5600,[416810,387620,312670,293920,275170,256420,237670,218920,200170,181420,162670]],[5600,5620,[419620,390410,315250,296500,277750,259000,240250,221500,202750,184000,165250]],[5620,5640,[422420,393200,317830,299080,280330,261580,242830,224080,205330,186580,167830]],[5640,5660,[425230,395990,320410,301660,282910,264160,245410,226660,207910,189160,170410]],[5660,5680,[428030,398780,322990,304240,285490,266740,247990,229240,210490,191740,172990]],[5680,5700,[430840,401570,325570,306820,288070,269320,250570,231820,213070,194320,175570]],[5700,5720,[433640,404360,328150,309400,290650,271900,253150,234400,215650,196900,178150]],[5720,5740,[436450,407150,330730,311980,293230,274480,255730,236980,218230,199480,180730]],[5740,5760,[439250,409940,333310,314560,295810,277060,258310,239560,220810,202060,183310]],[5760,5780,[442060,412730,335890,317140,298390,279640,260890,242140,223390,204640,185890]],[5780,5800,[444860,415520,338470,319720,300970,282220,263470,244720,225970,207220,188470]],[5800,5820,[447670,418310,341050,322300,303550,284800,266050,247300,228550,209800,191050]],[5820,5840,[450470,421100,343630,324880,306130,287380,268630,249880,231130,212380,193630]],[5840,5860,[470380,441000,372100,353350,334600,315850,297100,278350,259600,240850,222100]],[5860,5880,[475720,446320,377240,358490,339740,320990,302240,283490,264740,245990,227240]],[5880,5900,[478690,449140,379880,361130,342380,323630,304880,286130,267380,248630,229880]],[5900,5920,[483220,451960,382520,363770,345020,326270,307520,288770,270020,251270,232520]],[5920,5940,[487760,454780,385160,366410,347660,328910,310160,291410,272660,253910,235160]],[5940,5960,[492300,457600,387800,369050,350300,331550,312800,294050,275300,256550,237800]],[5960,5980,[496830,460420,390440,371690,352940,334190,315440,296690,277940,259190,240440]],[5980,6000,[501370,463240,393080,374330,355580,336830,318080,299330,280580,261830,243080]],[6000,6020,[505900,466060,395720,376970,358220,339470,320720,301970,283220,264470,245720]],[6020,6040,[510440,468880,398360,379610,360860,342110,323360,304610,285860,267110,248360]],[6040,6060,[514980,471700,401000,382250,363500,344750,326000,307250,288500,269750,251000]],[6060,6080,[519510,474520,403640,384890,366140,347390,328640,309890,291140,272390,253640]],[6080,6100,[524050,477340,406280,387530,368780,350030,331280,312530,293780,275030,256280]],[6100,6120,[528580,481250,408920,390170,371420,352670,333920,315170,296420,277670,258920]],[6120,6140,[533120,485760,411560,392810,374060,355310,336560,317810,299060,280310,261560]],[6140,6160,[537660,490280,414200,395450,376700,357950,339200,320450,301700,282950,264200]],[6160,6180,[542190,494790,416840,398090,379340,360590,341840,323090,304340,285590,266840]],[6180,6200,[546730,499300,419480,400730,381980,363230,344480,325730,306980,288230,269480]],[6200,6220,[551260,503810,422120,403370,384620,365870,347120,328370,309620,290870,272120]],[6220,6240,[555800,508320,424760,406010,387260,368510,349760,331010,312260,293510,274760]],[6240,6260,[560340,512840,427400,408650,389900,371150,352400,333650,314900,296150,277400]],[6260,6280,[564870,517350,430040,411290,392540,373790,355040,336290,317540,298790,280040]],[6280,6300,[569410,521860,432680,413930,395180,376430,357680,338930,320180,301430,282680]],[6300,6320,[573940,526370,435320,416570,397820,379070,360320,341570,322820,304070,285320]],[6320,6340,[578480,530880,437960,419210,400460,381710,362960,344210,325460,306710,287960]],[6340,6360,[583020,535400,440600,421850,403100,384350,365600,346850,328100,309350,290600]],[6360,6380,[587550,539910,443240,424490,405740,386990,368240,349490,330740,311990,293240]],[6380,6400,[592090,544420,445880,427130,408380,389630,370880,352130,333380,314630,295880]],[6400,6420,[596620,548930,448520,429770,411020,392270,373520,354770,336020,317270,298520]],[6420,6440,[601160,553440,451160,432410,413660,394910,376160,357410,338660,319910,301160]],[6440,6460,[605700,557960,453800,435050,416300,397550,378800,360050,341300,322550,303800]],[6460,6480,[610230,562470,456440,437690,418940,400190,381440,362690,343940,325190,306440]],[6480,6500,[614770,566980,459080,440330,421580,402830,384080,365330,346580,327830,309080]],[6500,6520,[619300,571490,461720,442970,424220,405470,386720,367970,349220,330470,311720]],[6520,6540,[623840,576000,464360,445610,426860,408110,389360,370610,351860,333110,314360]],[6540,6560,[628380,580520,467000,448250,429500,410750,392000,373250,354500,335750,317000]],[6560,6580,[632910,585030,469640,450890,432140,413390,394640,375890,357140,338390,319640]],[6580,6600,[637450,589540,472280,453530,434780,416030,397280,378530,359780,341030,322280]],[6600,6620,[641980,594050,474920,456170,437420,418670,399920,381170,362420,343670,324920]],[6620,6640,[646520,598560,477560,458810,440060,421310,402560,383810,365060,346310,327560]],[6640,6660,[651060,603080,481320,461450,442700,423950,405200,386450,367700,348950,330200]],[6660,6680,[655590,607590,485540,464090,445340,426590,407840,389090,370340,351590,332840]],[6680,6700,[660130,612100,489760,466730,447980,429230,410480,391730,372980,354230,335480]],[6700,6720,[664660,616610,493990,469370,450620,431870,413120,394370,375620,356870,338120]],[6720,6740,[669200,621120,498210,472010,453260,434510,415760,397010,378260,359510,340760]],[6740,6760,[673740,625640,502440,474650,455900,437150,418400,399650,380900,362150,343400]],[6760,6780,[678270,630150,506660,477290,458540,439790,421040,402290,383540,364790,346040]],[6780,6800,[682810,634660,510880,480880,461180,442430,423680,404930,386180,367430,348680]],[6800,6820,[687340,639170,515110,485110,463820,445070,426320,407570,388820,370070,351320]],[6820,6840,[691880,643680,519330,489330,466460,447710,428960,410210,391460,372710,353960]],[6840,6860,[696420,648200,523560,493560,469100,450350,431600,412850,394100,375350,356600]],[6860,6880,[700950,652710,527780,497780,471740,452990,434240,415490,396740,377990,359240]],[6880,6900,[705490,657220,532000,502000,474380,455630,436880,418130,399380,380630,361880]],[6900,6920,[710020,661730,536230,506230,477020,458270,439520,420770,402020,383270,364520]],[6920,6940,[714560,666240,540450,510450,480450,460910,442160,423410,404660,385910,367160]],[6940,6960,[719100,670760,544680,514680,484680,463550,444800,426050,407300,388550,369800]],[6960,6980,[723630,675270,548900,518900,488900,466190,447440,428690,409940,391190,372440]],[6980,7000,[728170,679780,553120,523120,493120,468830,450080,431330,412580,393830,375080]],[7000,7020,[732700,684290,557350,527350,497350,471470,452720,433970,415220,396470,377720]],[7020,7040,[737240,688800,561570,531570,501570,474110,455360,436610,417860,399110,380360]],[7040,7060,[741780,693320,565800,535800,505800,476750,458000,439250,420500,401750,383000]],[7060,7080,[746310,697830,570020,540020,510020,480020,460640,441890,423140,404390,385640]],[7080,7100,[750850,702340,574240,544240,514240,484240,463280,444530,425780,407030,388280]],[7100,7120,[755380,706850,578470,548470,518470,488470,465920,447170,428420,409670,390920]],[7120,7140,[759920,711360,582690,552690,522690,492690,468560,449810,431060,412310,393560]],[7140,7160,[764460,715880,586920,556920,526920,496920,471200,452450,433700,414950,396200]],[7160,7180,[768990,720390,591140,561140,531140,501140,473840,455090,436340,417590,398840]],[7180,7200,[773530,724900,595360,565360,535360,505360,476480,457730,438980,420230,401480]],[7200,7220,[778060,729410,599590,569590,539590,509590,479590,460370,441620,422870,404120]],[7220,7240,[782600,733920,603810,573810,543810,513810,483810,463010,444260,425510,406760]],[7240,7260,[787140,738440,608040,578040,548040,518040,488040,465650,446900,428150,409400]],[7260,7280,[791670,742950,612260,582260,552260,522260,492260,468290,449540,430790,412040]],[7280,7300,[796210,747460,616480,586480,556480,526480,496480,470930,452180,433430,414680]],[7300,7320,[800740,751970,620710,590710,560710,530710,500710,473570,454820,436070,417320]],[7320,7340,[805280,756480,624930,594930,564930,534930,504930,476210,457460,438710,419960]],[7340,7360,[809820,761000,629160,599160,569160,539160,509160,479160,460100,441350,422600]],[7360,7380,[814350,765510,633380,603380,573380,543380,513380,483380,462740,443990,425240]],[7380,7400,[818890,770020,637600,607600,577600,547600,517600,487600,465380,446630,427880]],[7400,7420,[823420,774530,641830,611830,581830,551830,521830,491830,468020,449270,430520]],[7420,7440,[827960,779040,646050,616050,586050,556050,526050,496050,470660,451910,433160]],[7440,7460,[832500,783560,650280,620280,590280,560280,530280,500280,473300,454550,435800]],[7460,7480,[837030,788070,654500,624500,594500,564500,534500,504500,475940,457190,438440]],[7480,7500,[841570,792580,658720,628720,598720,568720,538720,508720,478720,459830,441080]],[7500,7520,[846100,797090,662950,632950,602950,572950,542950,512950,482950,462470,443720]],[7520,7540,[850640,801600,667170,637170,607170,577170,547170,517170,487170,465110,446360]],[7540,7560,[855180,806120,671400,641400,611400,581400,551400,521400,491400,467750,449000]],[7560,7580,[859710,810630,675620,645620,615620,585620,555620,525620,495620,470390,451640]],[7580,7600,[864250,815140,679840,649840,619840,589840,559840,529840,499840,473030,454280]],[7600,7620,[868780,819650,684070,654070,624070,594070,564070,534070,504070,475670,456920]],[7620,7640,[873320,824160,688290,658290,628290,598290,568290,538290,508290,478310,459560]],[7640,7660,[877860,828680,692520,662520,632520,602520,572520,542520,512520,482520,462200]],[7660,7680,[882390,833190,696740,666740,636740,606740,576740,546740,516740,486740,464840]],[7680,7700,[886930,837700,700960,670960,640960,610960,580960,550960,520960,490960,467480]],[7700,7720,[891460,842210,705190,675190,645190,615190,585190,555190,525190,495190,470120]],[7720,7740,[896000,846720,709410,679410,649410,619410,589410,559410,529410,499410,472760]],[7740,7760,[900540,851240,713640,683640,653640,623640,593640,563640,533640,503640,475400]],[7760,7780,[905070,855750,717860,687860,657860,627860,597860,567860,537860,507860,478040]],[7780,7800,[909610,860260,722080,692080,662080,632080,602080,572080,542080,512080,482080]],[7800,7820,[914140,864770,726310,696310,666310,636310,606310,576310,546310,516310,486310]],[7820,7840,[918680,869280,730530,700530,670530,640530,610530,580530,550530,520530,490530]],[7840,7860,[923220,873800,734760,704760,674760,644760,614760,584760,554760,524760,494760]],[7860,7880,[927750,878310,738980,708980,678980,648980,618980,588980,558980,528980,498980]],[7880,7900,[932290,882820,743200,713200,683200,653200,623200,593200,563200,533200,503200]],[7900,7920,[936820,887330,747430,717430,687430,657430,627430,597430,567430,537430,507430]],[7920,7940,[941360,891840,751650,721650,691650,661650,631650,601650,571650,541650,511650]],[7940,7960,[945900,896360,755880,725880,695880,665880,635880,605880,575880,545880,515880]],[7960,7980,[950430,900870,760100,730100,700100,670100,640100,610100,580100,550100,520100]],[7980,8000,[954970,905380,764320,734320,704320,674320,644320,614320,584320,554320,524320]],[8000,8020,[959500,909890,768550,738550,708550,678550,648550,618550,588550,558550,528550]],[8020,8040,[964040,914400,772770,742770,712770,682770,652770,622770,592770,562770,532770]],[8040,8060,[968580,918920,777000,747000,717000,687000,657000,627000,597000,567000,537000]],[8060,8080,[973110,923430,781220,751220,721220,691220,661220,631220,601220,571220,541220]],[8080,8100,[977650,927940,785440,755440,725440,695440,665440,635440,605440,575440,545440]],[8100,8120,[982180,932450,789670,759670,729670,699670,669670,639670,609670,579670,549670]],[8120,8140,[986720,936960,793890,763890,733890,703890,673890,643890,613890,583890,553890]],[8140,8160,[991260,941480,798120,768120,738120,708120,678120,648120,618120,588120,558120]],[8160,8180,[995790,945990,802340,772340,742340,712340,682340,652340,622340,592340,562340]],[8180,8200,[1000330,950500,806560,776560,746560,716560,686560,656560,626560,596560,566560]],[8200,8220,[1004860,955010,810790,780790,750790,720790,690790,660790,630790,600790,570790]],[8220,8240,[1009400,959520,815010,785010,755010,725010,695010,665010,635010,605010,575010]],[8240,8260,[1013940,964040,819240,789240,759240,729240,699240,669240,639240,609240,579240]],[8260,8280,[1018470,968550,823460,793460,763460,733460,703460,673460,643460,613460,583460]],[8280,8300,[1023010,973060,827680,797680,767680,737680,707680,677680,647680,617680,587680]],[8300,8320,[1027540,977570,831910,801910,771910,741910,711910,681910,651910,621910,591910]],[8320,8340,[1032080,982080,836130,806130,776130,746130,716130,686130,656130,626130,596130]],[8340,8360,[1036740,986720,840480,810480,780480,750480,720480,690480,660480,630480,600480]],[8360,8380,[1041420,991370,844840,814840,784840,754840,724840,694840,664840,634840,604840]],[8380,8400,[1046100,996030,849210,819210,789210,759210,729210,699210,669210,639210,609210]],[8400,8420,[1050780,1000680,853580,823580,793580,763580,733580,703580,673580,643580,613580]],[8420,8440,[1055460,1005340,857950,827950,797950,767950,737950,707950,677950,647950,617950]],[8440,8460,[1060140,1010000,862320,832320,802320,772320,742320,712320,682320,652320,622320]],[8460,8480,[1064820,1014650,866680,836680,806680,776680,746680,716680,686680,656680,626680]],[8480,8500,[1069500,1019310,871050,841050,811050,781050,751050,721050,691050,661050,631050]],[8500,8520,[1074180,1023960,875420,845420,815420,785420,755420,725420,695420,665420,635420]],[8520,8540,[1078860,1028620,879790,849790,819790,789790,759790,729790,699790,669790,639790]],[8540,8560,[1083540,1033280,884160,854160,824160,794160,764160,734160,704160,674160,644160]],[8560,8580,[1088220,1037930,888520,858520,828520,798520,768520,738520,708520,678520,648520]],[8580,8600,[1092900,1042590,892890,862890,832890,802890,772890,742890,712890,682890,652890]],[8600,8620,[1097580,1047240,897260,867260,837260,807260,777260,747260,717260,687260,657260]],[8620,8640,[1102260,1051900,901630,871630,841630,811630,781630,751630,721630,691630,661630]],[8640,8660,[1106940,1056560,906000,876000,846000,816000,786000,756000,726000,696000,666000]],[8660,8680,[1111620,1061210,910360,880360,850360,820360,790360,760360,730360,700360,670360]],[8680,8700,[1116300,1065870,914730,884730,854730,824730,794730,764730,734730,704730,674730]],[8700,8720,[1120980,1070520,919100,889100,859100,829100,799100,769100,739100,709100,679100]],[8720,8740,[1125660,1075180,923470,893470,863470,833470,803470,773470,743470,713470,683470]],[8740,8760,[1130340,1079840,927840,897840,867840,837840,807840,777840,747840,717840,687840]],[8760,8780,[1135020,1084490,932200,902200,872200,842200,812200,782200,752200,722200,692200]],[8780,8800,[1139700,1089150,936570,906570,876570,846570,816570,786570,756570,726570,696570]],[8800,8820,[1144380,1093800,940940,910940,880940,850940,820940,790940,760940,730940,700940]],[8820,8840,[1149060,1098460,945310,915310,885310,855310,825310,795310,765310,735310,705310]],[8840,8860,[1153740,1103120,949680,919680,889680,859680,829680,799680,769680,739680,709680]],[8860,8880,[1158420,1107770,954040,924040,894040,864040,834040,804040,774040,744040,714040]],[8880,8900,[1163100,1112430,958410,928410,898410,868410,838410,808410,778410,748410,718410]],[8900,8920,[1167780,1117080,962780,932780,902780,872780,842780,812780,782780,752780,722780]],[8920,8940,[1172460,1121740,967150,937150,907150,877150,847150,817150,787150,757150,727150]],[8940,8960,[1177140,1126400,971520,941520,911520,881520,851520,821520,791520,761520,731520]],[8960,8980,[1181820,1131050,975880,945880,915880,885880,855880,825880,795880,765880,735880]],[8980,9000,[1186500,1135710,980250,950250,920250,890250,860250,830250,800250,770250,740250]],[9000,9020,[1191180,1140360,984620,954620,924620,894620,864620,834620,804620,774620,744620]],[9020,9040,[1195860,1145020,988990,958990,928990,898990,868990,838990,808990,778990,748990]],[9040,9060,[1200540,1149680,993360,963360,933360,903360,873360,843360,813360,783360,753360]],[9060,9080,[1205220,1154330,997720,967720,937720,907720,877720,847720,817720,787720,757720]],[9080,9100,[1209900,1158990,1002090,972090,942090,912090,882090,852090,822090,792090,762090]],[9100,9120,[1214580,1163640,1006460,976460,946460,916460,886460,856460,826460,796460,766460]],[9120,9140,[1219260,1168300,1010830,980830,950830,920830,890830,860830,830830,800830,770830]],[9140,9160,[1223940,1172960,1015200,985200,955200,925200,895200,865200,835200,805200,775200]],[9160,9180,[1228620,1177610,1019560,989560,959560,929560,899560,869560,839560,809560,779560]],[9180,9200,[1233300,1182270,1023930,993930,963930,933930,903930,873930,843930,813930,783930]],[9200,9220,[1237980,1186920,1028300,998300,968300,938300,908300,878300,848300,818300,788300]],[9220,9240,[1244640,1191580,1032670,1002670,972670,942670,912670,882670,852670,822670,792670]],[9240,9260,[1251470,1196240,1037040,1007040,977040,947040,917040,887040,857040,827040,797040]],[9260,9280,[1258290,1200890,1041400,1011400,981400,951400,921400,891400,861400,831400,801400]],[9280,9300,[1265120,1205550,1045770,1015770,985770,955770,925770,895770,865770,835770,805770]],[9300,9320,[1271940,1210200,1050140,1020140,990140,960140,930140,900140,870140,840140,810140]],[9320,9340,[1278770,1214860,1054510,1024510,994510,964510,934510,904510,874510,844510,814510]],[9340,9360,[1285590,1219520,1058880,1028880,998880,968880,938880,908880,878880,848880,818880]],[9360,9380,[1292420,1224170,1063240,1033240,1003240,973240,943240,913240,883240,853240,823240]],[9380,9400,[1299240,1228830,1067610,1037610,1007610,977610,947610,917610,887610,857610,827610]],[9400,9420,[1306070,1233480,1071980,1041980,1011980,981980,951980,921980,891980,861980,831980]],[9420,9440,[1312890,1238140,1076350,1046350,1016350,986350,956350,926350,896350,866350,836350]],[9440,9460,[1319720,1244840,1080720,1050720,1020720,990720,960720,930720,900720,870720,840720]],[9460,9480,[1326540,1251630,1085080,1055080,1025080,995080,965080,935080,905080,875080,845080]],[9480,9500,[1333370,1258420,1089450,1059450,1029450,999450,969450,939450,909450,879450,849450]],[9500,9520,[1340190,1265210,1093820,1063820,1033820,1003820,973820,943820,913820,883820,853820]],[9520,9540,[1347020,1272000,1098190,1068190,1038190,1008190,978190,948190,918190,888190,858190]],[9540,9560,[1353840,1278790,1102560,1072560,1042560,1012560,982560,952560,922560,892560,862560]],[9560,9580,[1360670,1285580,1106920,1076920,1046920,1016920,986920,956920,926920,896920,866920]],[9580,9600,[1367490,1292370,1111290,1081290,1051290,1021290,991290,961290,931290,901290,871290]],[9600,9620,[1374320,1299160,1115660,1085660,1055660,1025660,995660,965660,935660,905660,875660]],[9620,9640,[1381140,1305950,1120030,1090030,1060030,1030030,1000030,970030,940030,910030,880030]],[9640,9660,[1387970,1312740,1124400,1094400,1064400,1034400,1004400,974400,944400,914400,884400]],[9660,9680,[1394790,1319530,1128760,1098760,1068760,1038760,1008760,978760,948760,918760,888760]],[9680,9700,[1401620,1326320,1133130,1103130,1073130,1043130,1013130,983130,953130,923130,893130]],[9700,9720,[1408440,1333110,1137500,1107500,1077500,1047500,1017500,987500,957500,927500,897500]],[9720,9740,[1415270,1339900,1141870,1111870,1081870,1051870,1021870,991870,961870,931870,901870]],[9740,9760,[1422090,1346690,1146240,1116240,1086240,1056240,1026240,996240,966240,936240,906240]],[9760,9780,[1428920,1353480,1150600,1120600,1090600,1060600,1030600,1000600,970600,940600,910600]],[9780,9800,[1435740,1360270,1154970,1124970,1094970,1064970,1034970,1004970,974970,944970,914970]],[9800,9820,[1442570,1367060,1159340,1129340,1099340,1069340,1039340,1009340,979340,949340,919340]],[9820,9840,[1449390,1373850,1163710,1133710,1103710,1073710,1043710,1013710,983710,953710,923710]],[9840,9860,[1456220,1380640,1168080,1138080,1108080,1078080,1048080,1018080,988080,958080,928080]],[9860,9880,[1463040,1387430,1172440,1142440,1112440,1082440,1052440,1022440,992440,962440,932440]],[9880,9900,[1469870,1394220,1176810,1146810,1116810,1086810,1056810,1026810,996810,966810,936810]],[9900,9920,[1476690,1401010,1181180,1151180,1121180,1091180,1061180,1031180,1001180,971180,941180]],[9920,9940,[1483520,1407800,1185550,1155550,1125550,1095550,1065550,1035550,1005550,975550,945550]],[9940,9960,[1490340,1414590,1189920,1159920,1129920,1099920,1069920,1039920,1009920,979920,949920]],[9960,9980,[1497170,1421380,1194280,1164280,1134280,1104280,1074280,1044280,1014280,984280,954280]],[9980,10000,[1503990,1428170,1198650,1168650,1138650,1108650,1078650,1048650,1018650,988650,958650]],[10000,10000,[1507400,1431570,1200840,1170840,1140840,1110840,1080840,1050840,1020840,990840,960840]]] \ No newline at end of file From 75408d925f897b10e17eaacb9ffe41927e04ccff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 27 Feb 2026 16:28:57 +0900 Subject: [PATCH 011/166] =?UTF-8?q?feat:=20[esign]=20esign=5Fcontracts=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EC=97=90=20completion=5Ftemplate=5F?= =?UTF-8?q?name=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 완료 알림톡 템플릿명을 저장하기 위한 nullable string 컬럼 --- ...completion_template_to_esign_contracts.php | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 database/migrations/2026_02_27_120000_add_completion_template_to_esign_contracts.php diff --git a/database/migrations/2026_02_27_120000_add_completion_template_to_esign_contracts.php b/database/migrations/2026_02_27_120000_add_completion_template_to_esign_contracts.php new file mode 100644 index 0000000..14bae8b --- /dev/null +++ b/database/migrations/2026_02_27_120000_add_completion_template_to_esign_contracts.php @@ -0,0 +1,23 @@ +string('completion_template_name', 100)->nullable()->after('sms_fallback') + ->comment('완료 알림톡 템플릿명'); + }); + } + + public function down(): void + { + Schema::table('esign_contracts', function (Blueprint $table) { + $table->dropColumn('completion_template_name'); + }); + } +}; From ff8b37670e38d7ab7eda1be1c98b706c4393d8b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 27 Feb 2026 20:21:59 +0900 Subject: [PATCH 012/166] =?UTF-8?q?feat:=20[hr]=20=EC=82=AC=EC=97=85?= =?UTF-8?q?=EC=86=8C=EB=93=9D=EC=9E=90=20=EC=9E=84=EA=B8=88=EB=8C=80?= =?UTF-8?q?=EC=9E=A5=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=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 - business_income_payments 테이블 생성 - 소득세(3%)/지방소득세(0.3%) 고정세율 구조 - (tenant_id, user_id, pay_year, pay_month) 유니크 제약 --- ..._create_business_income_payments_table.php | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 database/migrations/2026_02_27_300000_create_business_income_payments_table.php diff --git a/database/migrations/2026_02_27_300000_create_business_income_payments_table.php b/database/migrations/2026_02_27_300000_create_business_income_payments_table.php new file mode 100644 index 0000000..0319293 --- /dev/null +++ b/database/migrations/2026_02_27_300000_create_business_income_payments_table.php @@ -0,0 +1,44 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->comment('테넌트'); + $table->foreignId('user_id')->constrained('users')->comment('사업소득자 사용자'); + $table->unsignedSmallInteger('pay_year')->comment('급여 연도'); + $table->unsignedTinyInteger('pay_month')->comment('급여 월 (1-12)'); + $table->string('service_content', 200)->nullable()->comment('용역내용'); + $table->decimal('gross_amount', 15, 0)->default(0)->comment('지급총액'); + $table->decimal('income_tax', 15, 0)->default(0)->comment('소득세 (3%)'); + $table->decimal('local_income_tax', 15, 0)->default(0)->comment('지방소득세 (0.3%)'); + $table->decimal('total_deductions', 15, 0)->default(0)->comment('공제합계'); + $table->decimal('net_amount', 15, 0)->default(0)->comment('실지급액'); + $table->date('payment_date')->nullable()->comment('지급일자'); + $table->text('note')->nullable()->comment('비고'); + $table->string('status', 20)->default('draft')->comment('상태: draft/confirmed/paid'); + $table->timestamp('confirmed_at')->nullable()->comment('확정일시'); + $table->foreignId('confirmed_by')->nullable()->constrained('users')->comment('확정자'); + $table->timestamp('paid_at')->nullable()->comment('지급일시'); + $table->foreignId('created_by')->nullable()->constrained('users')->comment('생성자'); + $table->foreignId('updated_by')->nullable()->constrained('users')->comment('수정자'); + $table->foreignId('deleted_by')->nullable()->constrained('users')->comment('삭제자'); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['tenant_id', 'user_id', 'pay_year', 'pay_month'], 'bip_tenant_user_period_unique'); + $table->index(['tenant_id', 'pay_year', 'pay_month'], 'bip_tenant_period_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('business_income_payments'); + } +}; From 9e84fa04a6cbddb79f3eab3606650e05b465e286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 27 Feb 2026 12:26:25 +0900 Subject: [PATCH 013/166] =?UTF-8?q?fix:=20[production]=20product=5Fcode=20?= =?UTF-8?q?=EC=A0=84=ED=8C=8C=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 - OrderService::createProductionOrder에 product_code/product_name 추가 - WorkOrderService::store 수주복사 경로에 product_code/product_name 추가 - order_nodes.options → work_order_items.options 전파 누락 해결 --- app/Services/OrderService.php | 2 ++ app/Services/WorkOrderService.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 98b35d1..ab63e6f 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -1410,6 +1410,8 @@ public function createProductionOrder(int $orderId, array $data) $woItemOptions = array_filter([ 'floor' => $orderItem->floor_code, 'code' => $orderItem->symbol_code, + 'product_code' => ! empty($nodeOptions['product_code']) ? $nodeOptions['product_code'] : null, + 'product_name' => ! empty($nodeOptions['product_name']) ? $nodeOptions['product_name'] : null, 'width' => $woWidth, 'height' => $woHeight, 'cutting_info' => $nodeOptions['cutting_info'] ?? null, diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index 3311d68..13e519b 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -285,6 +285,8 @@ public function store(array $data) $options = array_filter([ 'floor' => $orderItem->floor_code, 'code' => $orderItem->symbol_code, + 'product_code' => ! empty($nodeOptions['product_code']) ? $nodeOptions['product_code'] : null, + 'product_name' => ! empty($nodeOptions['product_name']) ? $nodeOptions['product_name'] : null, 'width' => $nodeOptions['width'] ?? $nodeOptions['open_width'] ?? null, 'height' => $nodeOptions['height'] ?? $nodeOptions['open_height'] ?? null, 'cutting_info' => $nodeOptions['cutting_info'] ?? null, From e6c02292d22d7438d4aa96a6ce9c941d743c1585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 27 Feb 2026 15:59:02 +0900 Subject: [PATCH 014/166] =?UTF-8?q?feat:=20[quote/quality]=20Phase=202B=20?= =?UTF-8?q?=EA=B2=AC=EC=A0=81=20product=5Fcode=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20+=20inspections=20work=5Forder=5Fid=20FK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QuoteService: extractProductCodeFromInputs() 추가, store/update에서 자동 추출 - BackfillQuoteProductCodeCommand: 기존 quotes 25건 product_code 보정 - inspections 테이블에 work_order_id FK 마이그레이션 (nullable, nullOnDelete) - Inspection↔WorkOrder 양방향 관계 추가 - InspectionService: store/show/index에 work_order_id 처리 + transformToFrontend - InspectionStoreRequest: work_order_id 검증 규칙 추가 --- .../BackfillQuoteProductCodeCommand.php | 54 +++++++++++++++++++ .../Inspection/InspectionStoreRequest.php | 1 + app/Models/Production/WorkOrder.php | 9 ++++ app/Models/Qualitys/Inspection.php | 11 ++++ app/Services/InspectionService.php | 13 +++-- app/Services/Quote/QuoteService.php | 20 ++++++- ...add_work_order_id_to_inspections_table.php | 34 ++++++++++++ 7 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 app/Console/Commands/BackfillQuoteProductCodeCommand.php create mode 100644 database/migrations/2026_02_27_000001_add_work_order_id_to_inspections_table.php diff --git a/app/Console/Commands/BackfillQuoteProductCodeCommand.php b/app/Console/Commands/BackfillQuoteProductCodeCommand.php new file mode 100644 index 0000000..4550ca9 --- /dev/null +++ b/app/Console/Commands/BackfillQuoteProductCodeCommand.php @@ -0,0 +1,54 @@ +option('dry-run'); + + $quotes = Quote::whereNull('product_code') + ->whereNotNull('calculation_inputs') + ->get(); + + $this->info("대상: {$quotes->count()}건".($dryRun ? ' (dry-run)' : '')); + + $updated = 0; + $skipped = 0; + + foreach ($quotes as $quote) { + $inputs = $quote->calculation_inputs; + if (! is_array($inputs)) { + $inputs = json_decode($inputs, true); + } + + $productCode = $inputs['items'][0]['productCode'] ?? null; + + if (! $productCode) { + $skipped++; + $this->line(" SKIP #{$quote->id} ({$quote->quote_number}) — productCode 없음"); + + continue; + } + + if (! $dryRun) { + $quote->update(['product_code' => $productCode]); + } + + $updated++; + $this->line(' '.($dryRun ? 'WOULD ' : '')."UPDATE #{$quote->id} ({$quote->quote_number}) → {$productCode}"); + } + + $this->info("완료: 보정 {$updated}건, 스킵 {$skipped}건"); + + return self::SUCCESS; + } +} diff --git a/app/Http/Requests/Inspection/InspectionStoreRequest.php b/app/Http/Requests/Inspection/InspectionStoreRequest.php index 56e38c1..9d92a4f 100644 --- a/app/Http/Requests/Inspection/InspectionStoreRequest.php +++ b/app/Http/Requests/Inspection/InspectionStoreRequest.php @@ -22,6 +22,7 @@ public function rules(): array Inspection::TYPE_FQC, ])], 'lot_no' => ['required', 'string', 'max:50'], + 'work_order_id' => ['nullable', 'integer', 'exists:work_orders,id'], 'item_name' => ['nullable', 'string', 'max:200'], 'process_name' => ['nullable', 'string', 'max:100'], 'quantity' => ['nullable', 'numeric', 'min:0'], diff --git a/app/Models/Production/WorkOrder.php b/app/Models/Production/WorkOrder.php index 4715845..0aff877 100644 --- a/app/Models/Production/WorkOrder.php +++ b/app/Models/Production/WorkOrder.php @@ -6,6 +6,7 @@ use App\Models\Members\User; use App\Models\Orders\Order; use App\Models\Process; +use App\Models\Qualitys\Inspection; use App\Models\Tenants\Department; use App\Models\Tenants\Shipment; use App\Traits\Auditable; @@ -234,6 +235,14 @@ public function shipments(): HasMany return $this->hasMany(Shipment::class); } + /** + * 품질검사 (IQC/PQC/FQC) + */ + public function inspections(): HasMany + { + return $this->hasMany(Inspection::class); + } + /** * 생성자 */ diff --git a/app/Models/Qualitys/Inspection.php b/app/Models/Qualitys/Inspection.php index f6453b3..fd4b6a2 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\Models\Production\WorkOrder; use App\Traits\Auditable; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -23,6 +24,7 @@ * @property string|null $inspection_date 검사일 * @property int|null $item_id 품목 ID * @property string $lot_no LOT번호 + * @property int|null $work_order_id 작업지시 ID (PQC/FQC용) * @property int|null $inspector_id 검사자 ID * @property array|null $meta 메타정보 (process_name, quantity, unit 등) * @property array|null $items 검사항목 배열 @@ -47,6 +49,7 @@ class Inspection extends Model 'inspection_date', 'item_id', 'lot_no', + 'work_order_id', 'inspector_id', 'meta', 'items', @@ -92,6 +95,14 @@ class Inspection extends Model // ===== Relationships ===== + /** + * 작업지시 (PQC/FQC용) + */ + public function workOrder() + { + return $this->belongsTo(WorkOrder::class); + } + /** * 품목 */ diff --git a/app/Services/InspectionService.php b/app/Services/InspectionService.php index dcc5e38..2964758 100644 --- a/app/Services/InspectionService.php +++ b/app/Services/InspectionService.php @@ -33,7 +33,7 @@ public function index(array $params) $query = Inspection::query() ->where('tenant_id', $tenantId) - ->with(['inspector:id,name', 'item:id,item_name']); + ->with(['inspector:id,name', 'item:id,item_name', 'workOrder:id,work_order_no']); // 검색어 (검사번호, LOT번호) if ($q !== '') { @@ -126,7 +126,7 @@ public function show(int $id) $tenantId = $this->tenantId(); $inspection = Inspection::where('tenant_id', $tenantId) - ->with(['inspector:id,name', 'item:id,item_name']) + ->with(['inspector:id,name', 'item:id,item_name', 'workOrder:id,work_order_no']) ->find($id); if (! $inspection) { @@ -183,6 +183,7 @@ public function store(array $data) 'inspection_type' => $data['inspection_type'], 'request_date' => $data['request_date'] ?? now()->toDateString(), 'lot_no' => $data['lot_no'], + 'work_order_id' => $data['work_order_id'] ?? null, 'inspector_id' => $data['inspector_id'] ?? null, 'meta' => $meta, 'items' => $items, @@ -200,7 +201,7 @@ public function store(array $data) $inspection->toArray() ); - return $this->transformToFrontend($inspection->load(['inspector:id,name'])); + return $this->transformToFrontend($inspection->load(['inspector:id,name', 'workOrder:id,work_order_no'])); }); } @@ -277,7 +278,7 @@ public function update(int $id, array $data) $inspection->fresh()->toArray() ); - return $this->transformToFrontend($inspection->load(['inspector:id,name'])); + return $this->transformToFrontend($inspection->load(['inspector:id,name', 'workOrder:id,work_order_no'])); }); } @@ -360,7 +361,7 @@ public function complete(int $id, array $data) $inspection->fresh()->toArray() ); - return $this->transformToFrontend($inspection->load(['inspector:id,name'])); + return $this->transformToFrontend($inspection->load(['inspector:id,name', 'workOrder:id,work_order_no'])); }); } @@ -380,6 +381,8 @@ private function transformToFrontend(Inspection $inspection): array 'inspection_date' => $inspection->inspection_date?->format('Y-m-d'), 'item_name' => $inspection->item?->item_name ?? ($meta['item_name'] ?? null), 'lot_no' => $inspection->lot_no, + 'work_order_id' => $inspection->work_order_id, + 'work_order_no' => $inspection->workOrder?->work_order_no, 'process_name' => $meta['process_name'] ?? null, 'quantity' => $meta['quantity'] ?? null, 'unit' => $meta['unit'] ?? null, diff --git a/app/Services/Quote/QuoteService.php b/app/Services/Quote/QuoteService.php index 34e05e0..b42cc01 100644 --- a/app/Services/Quote/QuoteService.php +++ b/app/Services/Quote/QuoteService.php @@ -321,7 +321,7 @@ public function store(array $data): Quote // 제품 정보 'product_category' => $data['product_category'] ?? Quote::CATEGORY_SCREEN, 'product_id' => $data['product_id'] ?? null, - 'product_code' => $data['product_code'] ?? null, + 'product_code' => $data['product_code'] ?? $this->extractProductCodeFromInputs($data), 'product_name' => $data['product_name'] ?? null, // 규격 정보 'open_size_width' => $data['open_size_width'] ?? null, @@ -418,7 +418,7 @@ public function update(int $id, array $data): Quote // 제품 정보 'product_category' => $data['product_category'] ?? $quote->product_category, 'product_id' => $data['product_id'] ?? $quote->product_id, - 'product_code' => $data['product_code'] ?? $quote->product_code, + 'product_code' => $data['product_code'] ?? $this->extractProductCodeFromInputs($data) ?? $quote->product_code, 'product_name' => $data['product_name'] ?? $quote->product_name, // 규격 정보 'open_size_width' => $data['open_size_width'] ?? $quote->open_size_width, @@ -799,6 +799,22 @@ private function resolveLocationIndex(QuoteItem $quoteItem, array $productItems) return 0; } + /** + * calculation_inputs에서 첫 번째 개소의 productCode 추출 + * 다중 개소 시 첫 번째를 대표값으로 사용 + */ + private function extractProductCodeFromInputs(array $data): ?string + { + $inputs = $data['calculation_inputs'] ?? null; + if (! $inputs || ! is_array($inputs)) { + return null; + } + + $items = $inputs['items'] ?? []; + + return $items[0]['productCode'] ?? null; + } + /** * 수주번호 생성 * 형식: ORD-YYMMDD-NNN (예: ORD-260105-001) diff --git a/database/migrations/2026_02_27_000001_add_work_order_id_to_inspections_table.php b/database/migrations/2026_02_27_000001_add_work_order_id_to_inspections_table.php new file mode 100644 index 0000000..f97f351 --- /dev/null +++ b/database/migrations/2026_02_27_000001_add_work_order_id_to_inspections_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('work_order_id') + ->nullable() + ->after('lot_no') + ->comment('작업지시 ID (PQC/FQC용)'); + + $table->foreign('work_order_id') + ->references('id') + ->on('work_orders') + ->nullOnDelete(); + + $table->index('work_order_id'); + }); + } + + public function down(): void + { + Schema::table('inspections', function (Blueprint $table) { + $table->dropForeign(['work_order_id']); + $table->dropIndex(['work_order_id']); + $table->dropColumn('work_order_id'); + }); + } +}; From f970b6bf4b649532f7a17d77ecae1deaaed53008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 27 Feb 2026 16:36:32 +0900 Subject: [PATCH 015/166] =?UTF-8?q?feat:=20[inspection]=20Phase=203=20?= =?UTF-8?q?=EC=A0=88=EA=B3=A1=20=EA=B2=80=EC=82=AC=20=EB=8F=99=EC=A0=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=E2=80=94=20inspection-config=20API=20+=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inspection-config API 신규: GET /work-orders/{id}/inspection-config - 공정 자동 판별 (resolveInspectionProcessType) - bending_info 기반 구성품 목록 + gap_points 반환 - BENDING_GAP_PROFILES 상수 (6개 구성품 간격 기준치) - createInspectionDocument 트랜잭션 보강 - DB::transaction() + lockForUpdate() 적용 - 동시 생성 race condition 방지 Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/WorkOrderController.php | 10 + app/Services/WorkOrderService.php | 318 ++++++++++++++---- routes/api/v1/production.php | 1 + 3 files changed, 263 insertions(+), 66 deletions(-) diff --git a/app/Http/Controllers/Api/V1/WorkOrderController.php b/app/Http/Controllers/Api/V1/WorkOrderController.php index a093065..61828a4 100644 --- a/app/Http/Controllers/Api/V1/WorkOrderController.php +++ b/app/Http/Controllers/Api/V1/WorkOrderController.php @@ -230,6 +230,16 @@ public function inspectionReport(int $id) }, __('message.work_order.fetched')); } + /** + * 작업지시 검사 설정 조회 (공정 자동 판별 + 구성품 목록) + */ + public function inspectionConfig(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->getInspectionConfig($id); + }, __('message.work_order.fetched')); + } + /** * 작업지시의 검사용 문서 템플릿 조회 */ diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index 13e519b..d727b5c 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -5,6 +5,7 @@ use App\Models\Documents\Document; use App\Models\Documents\DocumentTemplate; use App\Models\Orders\Order; +use App\Models\Process; use App\Models\Production\WorkOrder; use App\Models\Production\WorkOrderAssignee; use App\Models\Production\WorkOrderBendingDetail; @@ -1949,6 +1950,186 @@ public function getInspectionData(int $workOrderId, array $params = []): array ]; } + // ────────────────────────────────────────────────────────────── + // 검사 설정 (inspection-config) + // ────────────────────────────────────────────────────────────── + + /** + * 절곡 검사 기준 간격 프로파일 (단면 치수 공학 사양) + * 향후 DB 테이블 또는 테넌트 설정으로 이관 가능 + */ + private const BENDING_GAP_PROFILES = [ + 'guide_rail_wall' => [ + 'name' => '가이드레일 벽면', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '30'], + ['point' => '(2)', 'design_value' => '78'], + ['point' => '(3)', 'design_value' => '25'], + ['point' => '(4)', 'design_value' => '45'], + ], + ], + 'guide_rail_side' => [ + 'name' => '가이드레일 측면', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '28'], + ['point' => '(2)', 'design_value' => '75'], + ['point' => '(3)', 'design_value' => '42'], + ['point' => '(4)', 'design_value' => '38'], + ['point' => '(5)', 'design_value' => '32'], + ], + ], + 'bottom_bar' => [ + 'name' => '하단마감재', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '60'], + ], + ], + 'case_box' => [ + 'name' => '케이스', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '550'], + ['point' => '(2)', 'design_value' => '50'], + ['point' => '(3)', 'design_value' => '385'], + ['point' => '(4)', 'design_value' => '50'], + ['point' => '(5)', 'design_value' => '410'], + ], + ], + 'smoke_w50' => [ + 'name' => '연기차단재 W50', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '50'], + ['point' => '(2)', 'design_value' => '12'], + ], + ], + 'smoke_w80' => [ + 'name' => '연기차단재 W80', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '80'], + ['point' => '(2)', 'design_value' => '12'], + ], + ], + ]; + + /** + * 작업지시의 검사 설정 조회 (공정 자동 판별 + 구성품 목록) + * + * 절곡 공정: bending_info 기반으로 검사 대상 구성품 + 간격 기준치 반환 + * 기타 공정: items 빈 배열 (스크린/슬랫은 별도 구성품 없음) + */ + public function getInspectionConfig(int $workOrderId): array + { + $workOrder = WorkOrder::where('tenant_id', $this->tenantId()) + ->with(['process', 'items' => fn ($q) => $q->orderBy('sort_order')]) + ->findOrFail($workOrderId); + + $process = $workOrder->process; + $processType = $this->resolveInspectionProcessType($process); + + $firstItem = $workOrder->items->first(); + $productCode = $firstItem?->options['product_code'] ?? null; + $templateId = $process?->document_template_id; + + $items = []; + if ($processType === 'bending') { + $items = $this->buildBendingInspectionItems($firstItem); + } + + return [ + 'work_order_id' => $workOrder->id, + 'process_type' => $processType, + 'product_code' => $productCode, + 'template_id' => $templateId, + 'items' => $items, + ]; + } + + /** + * 공정명 → 검사 공정 타입 변환 + */ + private function resolveInspectionProcessType(?Process $process): string + { + if (! $process) { + return 'unknown'; + } + + return match ($process->process_name) { + '스크린' => 'screen', + '슬랫' => 'slat', + '절곡' => 'bending', + default => strtolower($process->process_code ?? 'unknown'), + }; + } + + /** + * 절곡 bending_info에서 검사 대상 구성품 + 간격 기준치 빌드 + */ + private function buildBendingInspectionItems(?WorkOrderItem $firstItem): array + { + if (! $firstItem) { + return []; + } + + $bendingInfo = $firstItem->options['bending_info'] ?? null; + $items = []; + + // 가이드레일 벽면 (벽면형 또는 혼합형) + $guideRail = $bendingInfo['guideRail'] ?? null; + $hasWall = ! $bendingInfo || ($guideRail && ($guideRail['wall'] ?? false)); + $hasSide = $guideRail && ($guideRail['side'] ?? false); + + if ($hasWall) { + $profile = self::BENDING_GAP_PROFILES['guide_rail_wall']; + $items[] = [ + 'id' => 'guide_rail_wall', + 'name' => $profile['name'], + 'gap_points' => $profile['gap_points'], + ]; + } + + if ($hasSide) { + $profile = self::BENDING_GAP_PROFILES['guide_rail_side']; + $items[] = [ + 'id' => 'guide_rail_side', + 'name' => $profile['name'], + 'gap_points' => $profile['gap_points'], + ]; + } + + // 하단마감재 (항상 포함) + $profile = self::BENDING_GAP_PROFILES['bottom_bar']; + $items[] = [ + 'id' => 'bottom_bar', + 'name' => $profile['name'], + 'gap_points' => $profile['gap_points'], + ]; + + // 케이스 (항상 포함) + $profile = self::BENDING_GAP_PROFILES['case_box']; + $items[] = [ + 'id' => 'case_box', + 'name' => $profile['name'], + 'gap_points' => $profile['gap_points'], + ]; + + // 연기차단재 W50 (항상 포함) + $profile = self::BENDING_GAP_PROFILES['smoke_w50']; + $items[] = [ + 'id' => 'smoke_w50', + 'name' => $profile['name'], + 'gap_points' => $profile['gap_points'], + ]; + + // 연기차단재 W80 (항상 포함) + $profile = self::BENDING_GAP_PROFILES['smoke_w80']; + $items[] = [ + 'id' => 'smoke_w80', + 'name' => $profile['name'], + 'gap_points' => $profile['gap_points'], + ]; + + return $items; + } + // ────────────────────────────────────────────────────────────── // 검사 문서 템플릿 연동 // ────────────────────────────────────────────────────────────── @@ -2096,80 +2277,85 @@ public function createInspectionDocument(int $workOrderId, array $inspectionData throw new BadRequestHttpException(__('error.work_order.no_inspection_template')); } - $documentService = app(DocumentService::class); + return DB::transaction(function () use ($workOrder, $workOrderId, $tenantId, $templateId, $inspectionData) { + // 동시 생성 방지: 동일 작업지시에 대한 락 + $workOrder->lockForUpdate(); - // 기존 DRAFT/REJECTED 문서가 있으면 update - $existingDocument = Document::query() - ->where('tenant_id', $tenantId) - ->where('template_id', $templateId) - ->where('linkable_type', 'work_order') - ->where('linkable_id', $workOrderId) - ->whereIn('status', [Document::STATUS_DRAFT, Document::STATUS_REJECTED]) - ->latest() - ->first(); + $documentService = app(DocumentService::class); - // ★ 원본 기반 동기화: work_order_items.options.inspection_data에서 전체 수집 - $rawItems = []; - foreach ($workOrder->items as $item) { - $inspData = $item->getInspectionData(); - if ($inspData) { - $rawItems[] = $inspData; + // 기존 DRAFT/REJECTED 문서가 있으면 update + $existingDocument = Document::query() + ->where('tenant_id', $tenantId) + ->where('template_id', $templateId) + ->where('linkable_type', 'work_order') + ->where('linkable_id', $workOrderId) + ->whereIn('status', [Document::STATUS_DRAFT, Document::STATUS_REJECTED]) + ->latest() + ->first(); + + // ★ 원본 기반 동기화: work_order_items.options.inspection_data에서 전체 수집 + $rawItems = []; + foreach ($workOrder->items as $item) { + $inspData = $item->getInspectionData(); + if ($inspData) { + $rawItems[] = $inspData; + } } - } - $documentDataRecords = $this->transformInspectionDataToDocumentRecords($rawItems, $templateId); + $documentDataRecords = $this->transformInspectionDataToDocumentRecords($rawItems, $templateId); - // 기존 문서의 기본필드(bf_*) 보존 - if ($existingDocument) { - $existingBasicFields = $existingDocument->data() - ->whereNull('section_id') - ->where('field_key', 'LIKE', 'bf_%') - ->get() - ->map(fn ($d) => [ - 'section_id' => null, - 'column_id' => null, - 'row_index' => $d->row_index, - 'field_key' => $d->field_key, - 'field_value' => $d->field_value, - ]) - ->toArray(); + // 기존 문서의 기본필드(bf_*) 보존 + if ($existingDocument) { + $existingBasicFields = $existingDocument->data() + ->whereNull('section_id') + ->where('field_key', 'LIKE', 'bf_%') + ->get() + ->map(fn ($d) => [ + 'section_id' => null, + 'column_id' => null, + 'row_index' => $d->row_index, + 'field_key' => $d->field_key, + 'field_value' => $d->field_value, + ]) + ->toArray(); - $document = $documentService->update($existingDocument->id, [ - 'title' => $inspectionData['title'] ?? $existingDocument->title, - 'data' => array_merge($existingBasicFields, $documentDataRecords), - ]); + $document = $documentService->update($existingDocument->id, [ + 'title' => $inspectionData['title'] ?? $existingDocument->title, + 'data' => array_merge($existingBasicFields, $documentDataRecords), + ]); - $action = 'inspection_document_updated'; - } else { - $documentData = [ - 'template_id' => $templateId, - 'title' => $inspectionData['title'] ?? "중간검사성적서 - {$workOrder->work_order_no}", - 'linkable_type' => 'work_order', - 'linkable_id' => $workOrderId, - 'data' => $documentDataRecords, - 'approvers' => $inspectionData['approvers'] ?? [], + $action = 'inspection_document_updated'; + } else { + $documentData = [ + 'template_id' => $templateId, + 'title' => $inspectionData['title'] ?? "중간검사성적서 - {$workOrder->work_order_no}", + 'linkable_type' => 'work_order', + 'linkable_id' => $workOrderId, + 'data' => $documentDataRecords, + 'approvers' => $inspectionData['approvers'] ?? [], + ]; + + $document = $documentService->create($documentData); + $action = 'inspection_document_created'; + } + + // 감사 로그 + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $workOrderId, + $action, + null, + ['document_id' => $document->id, 'document_no' => $document->document_no] + ); + + return [ + 'document_id' => $document->id, + 'document_no' => $document->document_no, + 'status' => $document->status, + 'is_new' => $action === 'inspection_document_created', ]; - - $document = $documentService->create($documentData); - $action = 'inspection_document_created'; - } - - // 감사 로그 - $this->auditLogger->log( - $tenantId, - self::AUDIT_TARGET, - $workOrderId, - $action, - null, - ['document_id' => $document->id, 'document_no' => $document->document_no] - ); - - return [ - 'document_id' => $document->id, - 'document_no' => $document->document_no, - 'status' => $document->status, - 'is_new' => $action === 'inspection_document_created', - ]; + }); } /** diff --git a/routes/api/v1/production.php b/routes/api/v1/production.php index 0700bfa..5fac326 100644 --- a/routes/api/v1/production.php +++ b/routes/api/v1/production.php @@ -82,6 +82,7 @@ // 중간검사 관리 Route::post('/{id}/items/{itemId}/inspection', [WorkOrderController::class, 'storeItemInspection'])->whereNumber('id')->whereNumber('itemId')->name('v1.work-orders.items.inspection'); // 품목 검사 저장 + Route::get('/{id}/inspection-config', [WorkOrderController::class, 'inspectionConfig'])->whereNumber('id')->name('v1.work-orders.inspection-config'); // 검사 설정 (공정 판별 + 구성품) Route::get('/{id}/inspection-data', [WorkOrderController::class, 'inspectionData'])->whereNumber('id')->name('v1.work-orders.inspection-data'); // 검사 데이터 조회 Route::get('/{id}/inspection-report', [WorkOrderController::class, 'inspectionReport'])->whereNumber('id')->name('v1.work-orders.inspection-report'); // 검사 성적서 조회 Route::get('/{id}/inspection-template', [WorkOrderController::class, 'inspectionTemplate'])->whereNumber('id')->name('v1.work-orders.inspection-template'); // 검사 문서 템플릿 조회 From afc1aa72a83208088229bac7986a001ba91826f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 27 Feb 2026 17:36:28 +0900 Subject: [PATCH 016/166] =?UTF-8?q?feat:=20[inspection]=20=EC=A0=88?= =?UTF-8?q?=EA=B3=A1=20=EA=B2=80=EC=82=AC=20=EB=A7=88=EA=B0=90=EC=9C=A0?= =?UTF-8?q?=ED=98=95(S1/S2/S3)=20=ED=94=84=EB=A1=9C=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=E2=80=94=205130=20=EB=A0=88=EA=B1=B0?= =?UTF-8?q?=EC=8B=9C=20=EA=B8=B0=EC=A4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BENDING_GAP_PROFILES를 S1/S2/S3 + common 계층 구조로 재구성 - S1(KSS01): 벽면 4pt, 측면 6pt, 하단 1pt - S2(KSS02): 벽면 3pt, 측면 5pt, 하단 1pt - S3(KWE01/KSE01+SUS): 벽면 5pt, 측면 7pt, 하단 2pt - resolveFinishingType() 신규: product_code → 마감유형 자동 판별 - buildBendingInspectionItems()에 마감유형별 프로파일 적용 - getInspectionConfig() 응답에 finishing_type 필드 추가 Co-Authored-By: Claude Opus 4.6 --- app/Services/WorkOrderService.php | 207 ++++++++++++++++++++++-------- 1 file changed, 157 insertions(+), 50 deletions(-) diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index d727b5c..77d6a73 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -1955,57 +1955,124 @@ public function getInspectionData(int $workOrderId, array $params = []): array // ────────────────────────────────────────────────────────────── /** - * 절곡 검사 기준 간격 프로파일 (단면 치수 공학 사양) + * 절곡 검사 기준 간격 프로파일 (5130 레거시 기준 S1/S2/S3 마감유형별) + * + * S1: KSS01 계열 (KQTS01 포함) + * S2: KSS02 계열 (EGI 마감 포함) + * S3: KWE01/KSE01 + SUS 별도마감 + * * 향후 DB 테이블 또는 테넌트 설정으로 이관 가능 */ private const BENDING_GAP_PROFILES = [ - 'guide_rail_wall' => [ - 'name' => '가이드레일 벽면', - 'gap_points' => [ - ['point' => '(1)', 'design_value' => '30'], - ['point' => '(2)', 'design_value' => '78'], - ['point' => '(3)', 'design_value' => '25'], - ['point' => '(4)', 'design_value' => '45'], + 'S1' => [ + 'guide_rail_wall' => [ + 'name' => '가이드레일(벽면형)', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '30'], + ['point' => '(2)', 'design_value' => '80'], + ['point' => '(3)', 'design_value' => '45'], + ['point' => '(4)', 'design_value' => '40'], + ], + ], + 'guide_rail_side' => [ + 'name' => '가이드레일(측면형)', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '30'], + ['point' => '(2)', 'design_value' => '70'], + ['point' => '(3)', 'design_value' => '45'], + ['point' => '(4)', 'design_value' => '35'], + ['point' => '(5)', 'design_value' => '95'], + ['point' => '(6)', 'design_value' => '90'], + ], + ], + 'bottom_bar' => [ + 'name' => '하단마감재', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '60'], + ], ], ], - 'guide_rail_side' => [ - 'name' => '가이드레일 측면', - 'gap_points' => [ - ['point' => '(1)', 'design_value' => '28'], - ['point' => '(2)', 'design_value' => '75'], - ['point' => '(3)', 'design_value' => '42'], - ['point' => '(4)', 'design_value' => '38'], - ['point' => '(5)', 'design_value' => '32'], + 'S2' => [ + 'guide_rail_wall' => [ + 'name' => '가이드레일(벽면형)', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '30'], + ['point' => '(2)', 'design_value' => '80'], + ['point' => '(3)', 'design_value' => '45'], + ], + ], + 'guide_rail_side' => [ + 'name' => '가이드레일(측면형)', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '30'], + ['point' => '(2)', 'design_value' => '70'], + ['point' => '(3)', 'design_value' => '45'], + ['point' => '(4)', 'design_value' => '35'], + ['point' => '(5)', 'design_value' => '95'], + ], + ], + 'bottom_bar' => [ + 'name' => '하단마감재', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '60'], + ], ], ], - 'bottom_bar' => [ - 'name' => '하단마감재', - 'gap_points' => [ - ['point' => '(1)', 'design_value' => '60'], + 'S3' => [ + 'guide_rail_wall' => [ + 'name' => '가이드레일(벽면형·별도마감)', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '30'], + ['point' => '(2)', 'design_value' => '80'], + ['point' => '(3)', 'design_value' => '45'], + ['point' => '(4)', 'design_value' => '40'], + ['point' => '(5)', 'design_value' => '34'], + ], + ], + 'guide_rail_side' => [ + 'name' => '가이드레일(측면형·별도마감)', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '30'], + ['point' => '(2)', 'design_value' => '70'], + ['point' => '(3)', 'design_value' => '80'], + ['point' => '(4)', 'design_value' => '45'], + ['point' => '(5)', 'design_value' => '40'], + ['point' => '(6)', 'design_value' => '34'], + ['point' => '(7)', 'design_value' => '74'], + ], + ], + 'bottom_bar' => [ + 'name' => '하단마감재(별도마감)', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '60'], + ['point' => '(2)', 'design_value' => '64'], + ], ], ], - 'case_box' => [ - 'name' => '케이스', - 'gap_points' => [ - ['point' => '(1)', 'design_value' => '550'], - ['point' => '(2)', 'design_value' => '50'], - ['point' => '(3)', 'design_value' => '385'], - ['point' => '(4)', 'design_value' => '50'], - ['point' => '(5)', 'design_value' => '410'], + 'common' => [ + 'case_box' => [ + 'name' => '케이스', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '550'], + ['point' => '(2)', 'design_value' => '50'], + ['point' => '(3)', 'design_value' => '385'], + ['point' => '(4)', 'design_value' => '50'], + ['point' => '(5)', 'design_value' => '410'], + ], ], - ], - 'smoke_w50' => [ - 'name' => '연기차단재 W50', - 'gap_points' => [ - ['point' => '(1)', 'design_value' => '50'], - ['point' => '(2)', 'design_value' => '12'], + 'smoke_w50' => [ + 'name' => '연기차단재 W50', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '50'], + ['point' => '(2)', 'design_value' => '12'], + ], ], - ], - 'smoke_w80' => [ - 'name' => '연기차단재 W80', - 'gap_points' => [ - ['point' => '(1)', 'design_value' => '80'], - ['point' => '(2)', 'design_value' => '12'], + 'smoke_w80' => [ + 'name' => '연기차단재 W80', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '80'], + ['point' => '(2)', 'design_value' => '12'], + ], ], ], ]; @@ -2030,7 +2097,9 @@ public function getInspectionConfig(int $workOrderId): array $templateId = $process?->document_template_id; $items = []; + $finishingType = null; if ($processType === 'bending') { + $finishingType = $this->resolveFinishingType($productCode); $items = $this->buildBendingInspectionItems($firstItem); } @@ -2038,6 +2107,7 @@ public function getInspectionConfig(int $workOrderId): array 'work_order_id' => $workOrder->id, 'process_type' => $processType, 'product_code' => $productCode, + 'finishing_type' => $finishingType, 'template_id' => $templateId, 'items' => $items, ]; @@ -2060,8 +2130,40 @@ private function resolveInspectionProcessType(?Process $process): string }; } + /** + * 제품코드에서 마감유형(S1/S2/S3) 결정 (5130 레거시 기준) + * + * KSS01, KQTS01 → S1 + * KSS02 (및 EGI 마감) → S2 + * KWE01/KSE01 + SUS → S3 + */ + private function resolveFinishingType(?string $productCode): string + { + if (! $productCode) { + return 'S1'; + } + + // FG-{model}-{type}-{material} 형식에서 모델코드와 재질 추출 + $parts = explode('-', $productCode); + $modelCode = $parts[1] ?? ''; + $material = $parts[3] ?? ''; + + // SUS 재질 + KWE/KSE 모델 → S3 (별도마감) + if (stripos($material, 'SUS') !== false && (str_starts_with($modelCode, 'KWE') || str_starts_with($modelCode, 'KSE'))) { + return 'S3'; + } + + return match (true) { + str_starts_with($modelCode, 'KSS01'), str_starts_with($modelCode, 'KQTS') => 'S1', + str_starts_with($modelCode, 'KSS02') => 'S2', + str_starts_with($modelCode, 'KWE'), str_starts_with($modelCode, 'KSE') => 'S2', // EGI마감 = S2 + default => 'S2', // 기본값: S2 (5130 기준 EGI와 동일) + }; + } + /** * 절곡 bending_info에서 검사 대상 구성품 + 간격 기준치 빌드 + * 마감유형(S1/S2/S3)에 따라 gap_points가 달라짐 */ private function buildBendingInspectionItems(?WorkOrderItem $firstItem): array { @@ -2069,6 +2171,11 @@ private function buildBendingInspectionItems(?WorkOrderItem $firstItem): array return []; } + $productCode = $firstItem->options['product_code'] ?? null; + $finishingType = $this->resolveFinishingType($productCode); + $typeProfiles = self::BENDING_GAP_PROFILES[$finishingType] ?? self::BENDING_GAP_PROFILES['S1']; + $commonProfiles = self::BENDING_GAP_PROFILES['common']; + $bendingInfo = $firstItem->options['bending_info'] ?? null; $items = []; @@ -2078,7 +2185,7 @@ private function buildBendingInspectionItems(?WorkOrderItem $firstItem): array $hasSide = $guideRail && ($guideRail['side'] ?? false); if ($hasWall) { - $profile = self::BENDING_GAP_PROFILES['guide_rail_wall']; + $profile = $typeProfiles['guide_rail_wall']; $items[] = [ 'id' => 'guide_rail_wall', 'name' => $profile['name'], @@ -2087,7 +2194,7 @@ private function buildBendingInspectionItems(?WorkOrderItem $firstItem): array } if ($hasSide) { - $profile = self::BENDING_GAP_PROFILES['guide_rail_side']; + $profile = $typeProfiles['guide_rail_side']; $items[] = [ 'id' => 'guide_rail_side', 'name' => $profile['name'], @@ -2095,32 +2202,32 @@ private function buildBendingInspectionItems(?WorkOrderItem $firstItem): array ]; } - // 하단마감재 (항상 포함) - $profile = self::BENDING_GAP_PROFILES['bottom_bar']; + // 하단마감재 (항상 포함, 마감유형별 gap_points 다름) + $profile = $typeProfiles['bottom_bar']; $items[] = [ 'id' => 'bottom_bar', 'name' => $profile['name'], 'gap_points' => $profile['gap_points'], ]; - // 케이스 (항상 포함) - $profile = self::BENDING_GAP_PROFILES['case_box']; + // 케이스 (항상 포함, 공통) + $profile = $commonProfiles['case_box']; $items[] = [ 'id' => 'case_box', 'name' => $profile['name'], 'gap_points' => $profile['gap_points'], ]; - // 연기차단재 W50 (항상 포함) - $profile = self::BENDING_GAP_PROFILES['smoke_w50']; + // 연기차단재 W50 (항상 포함, 공통) + $profile = $commonProfiles['smoke_w50']; $items[] = [ 'id' => 'smoke_w50', 'name' => $profile['name'], 'gap_points' => $profile['gap_points'], ]; - // 연기차단재 W80 (항상 포함) - $profile = self::BENDING_GAP_PROFILES['smoke_w80']; + // 연기차단재 W80 (항상 포함, 공통) + $profile = $commonProfiles['smoke_w80']; $items[] = [ 'id' => 'smoke_w80', 'name' => $profile['name'], From c32d68f0699e31ebca9f8b8e12bbd9287065af31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 27 Feb 2026 23:17:03 +0900 Subject: [PATCH 017/166] =?UTF-8?q?feat:=20[approval]=20=EA=B2=B0=EC=9E=AC?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20Phase=201=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - approvals 테이블: line_id, body, is_urgent, department_id 컬럼 추가 - approval_steps 테이블: approver_name, approver_department, approver_position 스냅샷 컬럼 추가 --- ..._100001_add_columns_to_approvals_table.php | 37 +++++++++++++++++++ ...02_add_columns_to_approval_steps_table.php | 33 +++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 database/migrations/2026_02_27_100001_add_columns_to_approvals_table.php create mode 100644 database/migrations/2026_02_27_100002_add_columns_to_approval_steps_table.php diff --git a/database/migrations/2026_02_27_100001_add_columns_to_approvals_table.php b/database/migrations/2026_02_27_100001_add_columns_to_approvals_table.php new file mode 100644 index 0000000..8fc2dca --- /dev/null +++ b/database/migrations/2026_02_27_100001_add_columns_to_approvals_table.php @@ -0,0 +1,37 @@ +foreignId('line_id')->nullable()->after('form_id') + ->constrained('approval_lines')->nullOnDelete() + ->comment('결재선 템플릿 ID'); + $table->longText('body')->nullable()->after('content') + ->comment('본문 내용'); + $table->boolean('is_urgent')->default(false)->after('status') + ->comment('긴급 여부'); + $table->unsignedBigInteger('department_id')->nullable()->after('drafter_id') + ->comment('기안 부서 ID'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('approvals', function (Blueprint $table) { + $table->dropForeign(['line_id']); + $table->dropColumn(['line_id', 'body', 'is_urgent', 'department_id']); + }); + } +}; diff --git a/database/migrations/2026_02_27_100002_add_columns_to_approval_steps_table.php b/database/migrations/2026_02_27_100002_add_columns_to_approval_steps_table.php new file mode 100644 index 0000000..99e0fa8 --- /dev/null +++ b/database/migrations/2026_02_27_100002_add_columns_to_approval_steps_table.php @@ -0,0 +1,33 @@ +string('approver_name', 50)->nullable()->after('approver_id') + ->comment('결재자명 스냅샷'); + $table->string('approver_department', 100)->nullable()->after('approver_name') + ->comment('결재자 부서 스냅샷'); + $table->string('approver_position', 50)->nullable()->after('approver_department') + ->comment('결재자 직급 스냅샷'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('approval_steps', function (Blueprint $table) { + $table->dropColumn(['approver_name', 'approver_department', 'approver_position']); + }); + } +}; From b04d407fdbe3b517d2b816d2a789e77e5bdd2784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 27 Feb 2026 23:41:34 +0900 Subject: [PATCH 018/166] =?UTF-8?q?feat:=20[approval]=20Phase=202=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 - approval_steps: parallel_group, acted_by, approval_type 컬럼 추가 - approvals: recall_reason, parent_doc_id 컬럼 추가 - approval_delegations 테이블 생성 (위임/대결) --- ...phase2_columns_to_approval_steps_table.php | 27 +++++++++++++++ ..._add_phase2_columns_to_approvals_table.php | 25 ++++++++++++++ ...0005_create_approval_delegations_table.php | 34 +++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 database/migrations/2026_02_27_100003_add_phase2_columns_to_approval_steps_table.php create mode 100644 database/migrations/2026_02_27_100004_add_phase2_columns_to_approvals_table.php create mode 100644 database/migrations/2026_02_27_100005_create_approval_delegations_table.php diff --git a/database/migrations/2026_02_27_100003_add_phase2_columns_to_approval_steps_table.php b/database/migrations/2026_02_27_100003_add_phase2_columns_to_approval_steps_table.php new file mode 100644 index 0000000..39db709 --- /dev/null +++ b/database/migrations/2026_02_27_100003_add_phase2_columns_to_approval_steps_table.php @@ -0,0 +1,27 @@ +integer('parallel_group')->nullable()->after('step_type') + ->comment('병렬 그룹 번호'); + $table->foreignId('acted_by')->nullable()->after('approver_id') + ->comment('실제 처리자 (대결)'); + $table->string('approval_type', 20)->default('normal')->after('status') + ->comment('결재 유형: normal/pre_decided/delegated'); + }); + } + + public function down(): void + { + Schema::table('approval_steps', function (Blueprint $table) { + $table->dropColumn(['parallel_group', 'acted_by', 'approval_type']); + }); + } +}; diff --git a/database/migrations/2026_02_27_100004_add_phase2_columns_to_approvals_table.php b/database/migrations/2026_02_27_100004_add_phase2_columns_to_approvals_table.php new file mode 100644 index 0000000..7996a30 --- /dev/null +++ b/database/migrations/2026_02_27_100004_add_phase2_columns_to_approvals_table.php @@ -0,0 +1,25 @@ +text('recall_reason')->nullable()->after('completed_at') + ->comment('회수 사유'); + $table->foreignId('parent_doc_id')->nullable()->after('recall_reason') + ->comment('재기안 원본 문서'); + }); + } + + public function down(): void + { + Schema::table('approvals', function (Blueprint $table) { + $table->dropColumn(['recall_reason', 'parent_doc_id']); + }); + } +}; diff --git a/database/migrations/2026_02_27_100005_create_approval_delegations_table.php b/database/migrations/2026_02_27_100005_create_approval_delegations_table.php new file mode 100644 index 0000000..4ccaf73 --- /dev/null +++ b/database/migrations/2026_02_27_100005_create_approval_delegations_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('tenant_id')->constrained()->onDelete('cascade'); + $table->foreignId('delegator_id')->constrained('users')->comment('위임자'); + $table->foreignId('delegate_id')->constrained('users')->comment('대리인'); + $table->date('start_date')->comment('시작일'); + $table->date('end_date')->comment('종료일'); + $table->json('form_ids')->nullable()->comment('위임 대상 양식 (NULL=전체)'); + $table->boolean('notify_delegator')->default(true)->comment('대결 시 보고'); + $table->boolean('is_active')->default(true); + $table->string('reason', 200)->nullable()->comment('위임 사유'); + $table->foreignId('created_by')->nullable()->constrained('users'); + $table->softDeletes(); + $table->timestamps(); + + $table->index(['tenant_id', 'delegator_id', 'is_active']); + }); + } + + public function down(): void + { + Schema::dropIfExists('approval_delegations'); + } +}; From 79da7a6da7b58867fe9c98fb508c2ef63ff9582d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 28 Feb 2026 12:37:26 +0900 Subject: [PATCH 019/166] =?UTF-8?q?feat:=20[equipment]=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=20=EC=A0=90=EA=B2=80=EC=A3=BC=EA=B8=B0=20+=20?= =?UTF-8?q?=EB=B6=80=20=EB=8B=B4=EB=8B=B9=EC=9E=90=20DB=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - equipments: sub_manager_id 컬럼 추가 - equipment_inspection_templates: inspection_cycle 컬럼 + 유니크 변경 - equipment_inspections: inspection_cycle 컬럼 + 유니크 변경 --- ...00_add_sub_manager_to_equipments_table.php | 23 +++++++ ...dd_inspection_cycle_to_templates_table.php | 67 +++++++++++++++++++ ..._inspection_cycle_to_inspections_table.php | 43 ++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 database/migrations/2026_02_28_100000_add_sub_manager_to_equipments_table.php create mode 100644 database/migrations/2026_02_28_100100_add_inspection_cycle_to_templates_table.php create mode 100644 database/migrations/2026_02_28_100200_add_inspection_cycle_to_inspections_table.php diff --git a/database/migrations/2026_02_28_100000_add_sub_manager_to_equipments_table.php b/database/migrations/2026_02_28_100000_add_sub_manager_to_equipments_table.php new file mode 100644 index 0000000..c1c195f --- /dev/null +++ b/database/migrations/2026_02_28_100000_add_sub_manager_to_equipments_table.php @@ -0,0 +1,23 @@ +foreignId('sub_manager_id')->nullable()->after('manager_id') + ->comment('부 담당자 ID (users.id)'); + }); + } + + public function down(): void + { + Schema::table('equipments', function (Blueprint $table) { + $table->dropColumn('sub_manager_id'); + }); + } +}; diff --git a/database/migrations/2026_02_28_100100_add_inspection_cycle_to_templates_table.php b/database/migrations/2026_02_28_100100_add_inspection_cycle_to_templates_table.php new file mode 100644 index 0000000..5750d5f --- /dev/null +++ b/database/migrations/2026_02_28_100100_add_inspection_cycle_to_templates_table.php @@ -0,0 +1,67 @@ +string('inspection_cycle', 20)->default('daily')->after('equipment_id') + ->comment('점검주기: daily/weekly/monthly/bimonthly/quarterly/semiannual'); + }); + } + + // FK 삭제 → 유니크 변경 → FK 재생성 (개별 statement) + $this->dropFkIfExists('equipment_inspection_templates', 'equipment_inspection_templates_equipment_id_foreign'); + $this->dropUniqueIfExists('equipment_inspection_templates', 'uq_equipment_item_no'); + + Schema::table('equipment_inspection_templates', function (Blueprint $table) { + $table->unique(['equipment_id', 'inspection_cycle', 'item_no'], 'uq_equipment_cycle_item_no'); + $table->index('inspection_cycle', 'idx_insp_tmpl_cycle'); + + $table->foreign('equipment_id') + ->references('id') + ->on('equipments') + ->onDelete('cascade'); + }); + } + + public function down(): void + { + $this->dropFkIfExists('equipment_inspection_templates', 'equipment_inspection_templates_equipment_id_foreign'); + + Schema::table('equipment_inspection_templates', function (Blueprint $table) { + $table->dropIndex('idx_insp_tmpl_cycle'); + $table->dropUnique('uq_equipment_cycle_item_no'); + $table->unique(['equipment_id', 'item_no'], 'uq_equipment_item_no'); + $table->dropColumn('inspection_cycle'); + + $table->foreign('equipment_id') + ->references('id') + ->on('equipments') + ->onDelete('cascade'); + }); + } + + private function dropFkIfExists(string $table, string $fkName): void + { + $fks = DB::select("SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_TYPE = 'FOREIGN KEY' AND CONSTRAINT_NAME = ?", [$table, $fkName]); + if (count($fks) > 0) { + DB::statement("ALTER TABLE `{$table}` DROP FOREIGN KEY `{$fkName}`"); + } + } + + private function dropUniqueIfExists(string $table, string $indexName): void + { + $indexes = DB::select("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$indexName]); + if (count($indexes) > 0) { + DB::statement("ALTER TABLE `{$table}` DROP INDEX `{$indexName}`"); + } + } +}; diff --git a/database/migrations/2026_02_28_100200_add_inspection_cycle_to_inspections_table.php b/database/migrations/2026_02_28_100200_add_inspection_cycle_to_inspections_table.php new file mode 100644 index 0000000..0de3fe6 --- /dev/null +++ b/database/migrations/2026_02_28_100200_add_inspection_cycle_to_inspections_table.php @@ -0,0 +1,43 @@ +string('inspection_cycle', 20)->default('daily')->after('equipment_id') + ->comment('점검주기: daily/weekly/monthly/bimonthly/quarterly/semiannual'); + }); + + // 기존 레코드를 daily로 설정 + DB::statement("UPDATE equipment_inspections SET inspection_cycle = 'daily' WHERE inspection_cycle = '' OR inspection_cycle IS NULL"); + + Schema::table('equipment_inspections', function (Blueprint $table) { + // 기존 유니크/인덱스 삭제 + $table->dropUnique('uq_inspection_month'); + $table->dropIndex('idx_inspection_ym'); + + // cycle 포함 유니크/인덱스 추가 + $table->unique(['tenant_id', 'equipment_id', 'inspection_cycle', 'year_month'], 'uq_inspection_cycle_period'); + $table->index(['tenant_id', 'inspection_cycle', 'year_month'], 'idx_inspection_cycle_period'); + }); + } + + public function down(): void + { + Schema::table('equipment_inspections', function (Blueprint $table) { + $table->dropIndex('idx_inspection_cycle_period'); + $table->dropUnique('uq_inspection_cycle_period'); + + $table->unique(['tenant_id', 'equipment_id', 'year_month'], 'uq_inspection_month'); + $table->index(['tenant_id', 'year_month'], 'idx_inspection_ym'); + + $table->dropColumn('inspection_cycle'); + }); + } +}; From e40555ad37dd3f95f974330d0bb9745a4e816201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 28 Feb 2026 15:54:34 +0900 Subject: [PATCH 020/166] =?UTF-8?q?feat:=20[leaves]=20=ED=9C=B4=EA=B0=80-?= =?UTF-8?q?=EA=B2=B0=EC=9E=AC=20=EC=97=B0=EB=8F=99=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20DB=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - leaves 테이블에 approval_id 컬럼 추가 (마이그레이션) - 휴가신청 결재 양식(approval_forms) 등록 (마이그레이션) - Leave 모델 fillable에 approval_id 추가 --- app/Models/Tenants/Leave.php | 1 + ...200000_add_approval_id_to_leaves_table.php | 24 +++++++++++ ...2_28_200100_insert_leave_approval_form.php | 40 +++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 database/migrations/2026_02_28_200000_add_approval_id_to_leaves_table.php create mode 100644 database/migrations/2026_02_28_200100_insert_leave_approval_form.php diff --git a/app/Models/Tenants/Leave.php b/app/Models/Tenants/Leave.php index dc5a9af..eee6d93 100644 --- a/app/Models/Tenants/Leave.php +++ b/app/Models/Tenants/Leave.php @@ -53,6 +53,7 @@ class Leave extends Model 'status', 'approved_by', 'approved_at', + 'approval_id', 'reject_reason', 'created_by', 'updated_by', diff --git a/database/migrations/2026_02_28_200000_add_approval_id_to_leaves_table.php b/database/migrations/2026_02_28_200000_add_approval_id_to_leaves_table.php new file mode 100644 index 0000000..fe80767 --- /dev/null +++ b/database/migrations/2026_02_28_200000_add_approval_id_to_leaves_table.php @@ -0,0 +1,24 @@ +unsignedBigInteger('approval_id')->nullable()->after('status')->comment('연결된 결재 문서 ID'); + $table->index('approval_id'); + }); + } + + public function down(): void + { + Schema::table('leaves', function (Blueprint $table) { + $table->dropIndex(['approval_id']); + $table->dropColumn('approval_id'); + }); + } +}; diff --git a/database/migrations/2026_02_28_200100_insert_leave_approval_form.php b/database/migrations/2026_02_28_200100_insert_leave_approval_form.php new file mode 100644 index 0000000..9829591 --- /dev/null +++ b/database/migrations/2026_02_28_200100_insert_leave_approval_form.php @@ -0,0 +1,40 @@ +insertOrIgnore([ + 'tenant_id' => 1, + 'name' => '휴가신청', + 'code' => 'leave', + 'category' => 'request', + 'template' => json_encode([ + 'fields' => [ + ['name' => 'user_name', 'type' => 'text', 'label' => '신청자'], + ['name' => 'leave_type', 'type' => 'text', 'label' => '휴가유형'], + ['name' => 'period', 'type' => 'text', 'label' => '기간'], + ['name' => 'days', 'type' => 'number', 'label' => '일수'], + ['name' => 'reason', 'type' => 'text', 'label' => '사유'], + ['name' => 'remaining_days', 'type' => 'text', 'label' => '잔여연차'], + ], + ], JSON_UNESCAPED_UNICODE), + 'is_active' => true, + 'created_by' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function down(): void + { + DB::table('approval_forms') + ->where('tenant_id', 1) + ->where('code', 'leave') + ->delete(); + } +}; From 73949b1282ed34bc423aac74f0bb3467159c8146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 28 Feb 2026 19:31:46 +0900 Subject: [PATCH 021/166] =?UTF-8?q?feat:=20[document]=20=EB=B8=94=EB=A1=9D?= =?UTF-8?q?=20=EB=B9=8C=EB=8D=94=20=EC=A7=80=EC=9B=90=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=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 - document_templates: builder_type, schema, page_config 컬럼 추가 - documents: data JSON, rendered_html, pdf_path 컬럼 추가 --- ...dd_block_builder_to_document_templates.php | 33 +++++++++++++++++++ ..._28_100001_add_block_data_to_documents.php | 33 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 database/migrations/2026_02_28_100000_add_block_builder_to_document_templates.php create mode 100644 database/migrations/2026_02_28_100001_add_block_data_to_documents.php diff --git a/database/migrations/2026_02_28_100000_add_block_builder_to_document_templates.php b/database/migrations/2026_02_28_100000_add_block_builder_to_document_templates.php new file mode 100644 index 0000000..d742fbc --- /dev/null +++ b/database/migrations/2026_02_28_100000_add_block_builder_to_document_templates.php @@ -0,0 +1,33 @@ +string('builder_type', 20)->default('legacy')->after('category') + ->comment('빌더 유형 (legacy: 기존 EAV, block: 블록 빌더)'); + $table->json('schema')->nullable()->after('footer_judgement_options') + ->comment('블록 빌더 JSON 스키마'); + $table->json('page_config')->nullable()->after('schema') + ->comment('페이지 설정 (용지 크기, 방향, 여백 등)'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('document_templates', function (Blueprint $table) { + $table->dropColumn(['builder_type', 'schema', 'page_config']); + }); + } +}; diff --git a/database/migrations/2026_02_28_100001_add_block_data_to_documents.php b/database/migrations/2026_02_28_100001_add_block_data_to_documents.php new file mode 100644 index 0000000..7d3668f --- /dev/null +++ b/database/migrations/2026_02_28_100001_add_block_data_to_documents.php @@ -0,0 +1,33 @@ +json('data')->nullable()->after('status') + ->comment('블록 빌더 문서 데이터 (JSON)'); + $table->longText('rendered_html')->nullable()->after('data') + ->comment('렌더링된 HTML (PDF 생성용 캐시)'); + $table->string('pdf_path', 500)->nullable()->after('rendered_html') + ->comment('생성된 PDF 파일 경로'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('documents', function (Blueprint $table) { + $table->dropColumn(['data', 'rendered_html', 'pdf_path']); + }); + } +}; From 397d50de1f9f6f7cd3de243ce5487cb142a2ccf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 28 Feb 2026 20:02:33 +0900 Subject: [PATCH 022/166] =?UTF-8?q?feat:=20[interview]=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=EB=B7=B0=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20?= =?UTF-8?q?=EA=B3=A0=EB=8F=84=ED=99=94=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - interview_projects 테이블 신규 (회사별 프로젝트) - interview_attachments 테이블 신규 (첨부파일 + AI 분석) - interview_knowledge 테이블 신규 (AI 추출 지식) - interview_categories에 project_id, domain 컬럼 추가 - interview_questions에 ai_hint, expected_format, depends_on, domain 추가 - interview_answers에 answer_data, attachments JSON 추가 - interview_sessions에 project_id, session_type, voice_recording_id 추가 --- ...100000_create_interview_projects_table.php | 39 ++++++++++++ ...001_create_interview_attachments_table.php | 38 ++++++++++++ ...00002_create_interview_knowledge_table.php | 46 ++++++++++++++ ...t_domain_to_interview_categories_table.php | 28 +++++++++ ...rview_questions_answers_sessions_table.php | 62 +++++++++++++++++++ 5 files changed, 213 insertions(+) create mode 100644 database/migrations/2026_02_28_100000_create_interview_projects_table.php create mode 100644 database/migrations/2026_02_28_100001_create_interview_attachments_table.php create mode 100644 database/migrations/2026_02_28_100002_create_interview_knowledge_table.php create mode 100644 database/migrations/2026_02_28_100003_add_project_domain_to_interview_categories_table.php create mode 100644 database/migrations/2026_02_28_100004_add_fields_to_interview_questions_answers_sessions_table.php diff --git a/database/migrations/2026_02_28_100000_create_interview_projects_table.php b/database/migrations/2026_02_28_100000_create_interview_projects_table.php new file mode 100644 index 0000000..5e604ea --- /dev/null +++ b/database/migrations/2026_02_28_100000_create_interview_projects_table.php @@ -0,0 +1,39 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->string('company_name', 200)->comment('대상 회사명'); + $table->string('company_type', 100)->nullable()->comment('업종 (방화셔터, 블라인드 등)'); + $table->string('contact_person', 100)->nullable()->comment('담당자'); + $table->string('contact_info', 200)->nullable()->comment('연락처'); + $table->enum('status', ['draft', 'interviewing', 'analyzing', 'code_generated', 'deployed']) + ->default('draft')->comment('상태'); + $table->unsignedBigInteger('target_tenant_id')->nullable()->comment('생성될 테넌트 ID'); + $table->json('product_categories')->nullable()->comment('제품 카테고리 목록'); + $table->text('summary')->nullable()->comment('AI 생성 요약'); + $table->unsignedTinyInteger('progress_percent')->default(0)->comment('전체 진행률'); + $table->unsignedBigInteger('created_by')->nullable()->comment('등록자'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자'); + $table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자'); + $table->timestamps(); + $table->softDeletes(); + + $table->index('tenant_id', 'idx_interview_projects_tenant'); + $table->index('status', 'idx_interview_projects_status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('interview_projects'); + } +}; diff --git a/database/migrations/2026_02_28_100001_create_interview_attachments_table.php b/database/migrations/2026_02_28_100001_create_interview_attachments_table.php new file mode 100644 index 0000000..0772652 --- /dev/null +++ b/database/migrations/2026_02_28_100001_create_interview_attachments_table.php @@ -0,0 +1,38 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedBigInteger('interview_project_id')->comment('인터뷰 프로젝트 ID'); + $table->enum('file_type', ['excel_template', 'pdf_quote', 'sample_bom', 'price_list', 'photo', 'voice', 'other']) + ->default('other')->comment('파일 유형'); + $table->string('file_name', 255)->comment('파일명'); + $table->string('file_path', 500)->comment('저장 경로'); + $table->unsignedInteger('file_size')->default(0)->comment('파일 크기 (bytes)'); + $table->string('mime_type', 100)->nullable()->comment('MIME 타입'); + $table->json('ai_analysis')->nullable()->comment('AI 분석 결과'); + $table->enum('ai_analysis_status', ['pending', 'processing', 'completed', 'failed']) + ->default('pending')->comment('AI 분석 상태'); + $table->text('description')->nullable()->comment('설명'); + $table->unsignedBigInteger('created_by')->nullable()->comment('등록자'); + $table->timestamps(); + + $table->index('tenant_id', 'idx_interview_attachments_tenant'); + $table->index('interview_project_id', 'idx_interview_attachments_project'); + $table->index('file_type', 'idx_interview_attachments_type'); + }); + } + + public function down(): void + { + Schema::dropIfExists('interview_attachments'); + } +}; diff --git a/database/migrations/2026_02_28_100002_create_interview_knowledge_table.php b/database/migrations/2026_02_28_100002_create_interview_knowledge_table.php new file mode 100644 index 0000000..47529b7 --- /dev/null +++ b/database/migrations/2026_02_28_100002_create_interview_knowledge_table.php @@ -0,0 +1,46 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedBigInteger('interview_project_id')->comment('인터뷰 프로젝트 ID'); + $table->enum('domain', [ + 'product_classification', 'bom_structure', 'dimension_formula', + 'component_config', 'pricing_structure', 'quantity_formula', + 'conditional_logic', 'quote_format', + ])->comment('도메인 영역'); + $table->enum('knowledge_type', ['fact', 'rule', 'formula', 'mapping', 'range', 'table']) + ->comment('지식 유형'); + $table->string('title', 300)->comment('제목'); + $table->json('content')->comment('구조화된 지식 데이터'); + $table->enum('source_type', ['interview_answer', 'voice_recording', 'document', 'manual']) + ->comment('출처 유형'); + $table->unsignedBigInteger('source_id')->nullable()->comment('출처 레코드 ID'); + $table->decimal('confidence', 3, 2)->default(0.00)->comment('AI 신뢰도 (0.00~1.00)'); + $table->boolean('is_verified')->default(false)->comment('사용자 검증 여부'); + $table->unsignedBigInteger('verified_by')->nullable()->comment('검증자'); + $table->timestamp('verified_at')->nullable()->comment('검증 일시'); + $table->unsignedBigInteger('created_by')->nullable()->comment('등록자'); + $table->timestamps(); + $table->softDeletes(); + + $table->index('tenant_id', 'idx_interview_knowledge_tenant'); + $table->index('interview_project_id', 'idx_interview_knowledge_project'); + $table->index('domain', 'idx_interview_knowledge_domain'); + $table->index('is_verified', 'idx_interview_knowledge_verified'); + }); + } + + public function down(): void + { + Schema::dropIfExists('interview_knowledge'); + } +}; diff --git a/database/migrations/2026_02_28_100003_add_project_domain_to_interview_categories_table.php b/database/migrations/2026_02_28_100003_add_project_domain_to_interview_categories_table.php new file mode 100644 index 0000000..8ea37ac --- /dev/null +++ b/database/migrations/2026_02_28_100003_add_project_domain_to_interview_categories_table.php @@ -0,0 +1,28 @@ +unsignedBigInteger('interview_project_id')->nullable() + ->after('tenant_id')->comment('프로젝트 연결'); + $table->string('domain', 50)->nullable() + ->after('description')->comment('도메인 영역 태그'); + + $table->index('interview_project_id', 'idx_interview_categories_project'); + }); + } + + public function down(): void + { + Schema::table('interview_categories', function (Blueprint $table) { + $table->dropIndex('idx_interview_categories_project'); + $table->dropColumn(['interview_project_id', 'domain']); + }); + } +}; diff --git a/database/migrations/2026_02_28_100004_add_fields_to_interview_questions_answers_sessions_table.php b/database/migrations/2026_02_28_100004_add_fields_to_interview_questions_answers_sessions_table.php new file mode 100644 index 0000000..e10a663 --- /dev/null +++ b/database/migrations/2026_02_28_100004_add_fields_to_interview_questions_answers_sessions_table.php @@ -0,0 +1,62 @@ +text('ai_hint')->nullable()->after('options')->comment('AI 분석 힌트/가이드'); + $table->string('expected_format', 100)->nullable()->after('ai_hint')->comment('예상 답변 형식 (mm, 원/kg 등)'); + $table->json('depends_on')->nullable()->after('expected_format')->comment('조건부 표시 조건'); + $table->string('domain', 50)->nullable()->after('depends_on')->comment('도메인 영역'); + }); + + // question_type 컬럼 확장 (varchar(20) → varchar(50)) + Schema::table('interview_questions', function (Blueprint $table) { + $table->string('question_type', 50)->default('checkbox') + ->comment('질문유형: checkbox|text|number|select|multi_select|file_upload|formula_input|table_input|bom_tree|price_table|dimension_diagram') + ->change(); + }); + + // interview_answers 확장 + Schema::table('interview_answers', function (Blueprint $table) { + $table->json('answer_data')->nullable()->after('answer_text')->comment('구조화 답변 (테이블, 수식, BOM 등)'); + $table->json('attachments')->nullable()->after('answer_data')->comment('첨부 파일 경로 목록'); + }); + + // interview_sessions 확장 + Schema::table('interview_sessions', function (Blueprint $table) { + $table->unsignedBigInteger('interview_project_id')->nullable() + ->after('tenant_id')->comment('프로젝트 ID'); + $table->string('session_type', 20)->default('checklist') + ->after('status')->comment('세션 유형: checklist|structured|voice|review'); + $table->unsignedBigInteger('voice_recording_id')->nullable() + ->after('session_type')->comment('음성 녹음 ID'); + + $table->index('interview_project_id', 'idx_interview_sessions_project'); + }); + } + + public function down(): void + { + Schema::table('interview_sessions', function (Blueprint $table) { + $table->dropIndex('idx_interview_sessions_project'); + $table->dropColumn(['interview_project_id', 'session_type', 'voice_recording_id']); + }); + + Schema::table('interview_answers', function (Blueprint $table) { + $table->dropColumn(['answer_data', 'attachments']); + }); + + Schema::table('interview_questions', function (Blueprint $table) { + $table->string('question_type', 20)->default('checkbox') + ->comment('질문유형: checkbox/text')->change(); + $table->dropColumn(['ai_hint', 'expected_format', 'depends_on', 'domain']); + }); + } +}; From f2d36c3616eaad88c370fc4820013dfa2a192903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 28 Feb 2026 21:23:19 +0900 Subject: [PATCH 023/166] =?UTF-8?q?feat:=20[interview]=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EA=B3=84=EC=B8=B5=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20parent=5Fid=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?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - interview_categories 테이블에 parent_id 컬럼 추가 - self-referencing FK, nullOnDelete --- ...arent_id_to_interview_categories_table.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 database/migrations/2026_02_28_100005_add_parent_id_to_interview_categories_table.php diff --git a/database/migrations/2026_02_28_100005_add_parent_id_to_interview_categories_table.php b/database/migrations/2026_02_28_100005_add_parent_id_to_interview_categories_table.php new file mode 100644 index 0000000..8b926b8 --- /dev/null +++ b/database/migrations/2026_02_28_100005_add_parent_id_to_interview_categories_table.php @@ -0,0 +1,26 @@ +unsignedBigInteger('parent_id')->nullable()->after('interview_project_id')->comment('부모 카테고리 ID (대분류→중분류)'); + $table->index('parent_id', 'idx_interview_categories_parent'); + $table->foreign('parent_id')->references('id')->on('interview_categories')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('interview_categories', function (Blueprint $table) { + $table->dropForeign(['parent_id']); + $table->dropIndex('idx_interview_categories_parent'); + $table->dropColumn('parent_id'); + }); + } +}; From 587bdf5d8065cb486e0657bc6ed56ec8337a55cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 28 Feb 2026 22:05:59 +0900 Subject: [PATCH 024/166] =?UTF-8?q?feat:=20[interview]=20=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=EC=A7=88=EB=AC=B8=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=8B=9C=EB=93=9C=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 - 8개 도메인, 16개 템플릿, 80개 마스터 질문 INSERT - idempotent 처리: 이미 도메인 카테고리 존재 시 스킵 - Jenkins 자동 배포로 운영서버 데이터 반영 목적 --- ...100006_seed_interview_master_questions.php | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 database/migrations/2026_02_28_100006_seed_interview_master_questions.php diff --git a/database/migrations/2026_02_28_100006_seed_interview_master_questions.php b/database/migrations/2026_02_28_100006_seed_interview_master_questions.php new file mode 100644 index 0000000..d002c21 --- /dev/null +++ b/database/migrations/2026_02_28_100006_seed_interview_master_questions.php @@ -0,0 +1,277 @@ +whereNull('interview_project_id') + ->whereNotNull('domain') + ->count(); + + if ($existing > 0) { + return; + } + + DB::unprepared(" + SET @tenant_id = 1; + SET @user_id = 1; + SET @now = NOW(); + + -- 대분류: 제조업-방화셔터 + SET @root_manufacturing = ( + SELECT id FROM interview_categories + WHERE name = '제조업-방화셔터' AND parent_id IS NULL AND interview_project_id IS NULL AND deleted_at IS NULL + LIMIT 1 + ); + + -- 루트가 없으면 생성 + INSERT INTO interview_categories (tenant_id, interview_project_id, parent_id, name, description, domain, sort_order, is_active, created_by, updated_by, created_at, updated_at) + SELECT @tenant_id, NULL, NULL, '제조업-방화셔터', '방화셔터 제조업 인터뷰', NULL, 1, 1, @user_id, @user_id, @now, @now + FROM DUAL WHERE @root_manufacturing IS NULL; + + SET @root_manufacturing = COALESCE(@root_manufacturing, LAST_INSERT_ID()); + + -- Domain 1: 제품 분류 체계 + INSERT INTO interview_categories (tenant_id, interview_project_id, parent_id, name, description, domain, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, NULL, @root_manufacturing, '제품 분류 체계', '제품 카테고리, 모델 코드, 분류 기준 파악', 'product_classification', 3, 1, @user_id, @user_id, @now, @now); + SET @cat_1 = LAST_INSERT_ID(); + + INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, @cat_1, '제품 카테고리 구조', 1, 1, @user_id, @user_id, @now, @now); + SET @tpl_1_1 = LAST_INSERT_ID(); + + INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES + (@tenant_id, @tpl_1_1, '귀사의 주요 제품군을 모두 나열해주세요', 'text', NULL, '쉼표 구분으로 제품군 나열', NULL, NULL, 'product_classification', 1, 1, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_1_1, '각 제품군의 하위 모델명과 코드 체계를 알려주세요', 'table_input', '{\"columns\":[\"모델코드\",\"모델명\",\"비고\"]}', '코드-이름 매핑 테이블', NULL, NULL, 'product_classification', 0, 2, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_1_1, '제품을 분류하는 기준은 무엇인가요? (소재, 용도, 크기 등)', 'multi_select', '{\"choices\":[\"소재별\",\"용도별\",\"크기별\",\"설치방식별\",\"인증여부별\"]}', NULL, NULL, NULL, 'product_classification', 0, 3, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_1_1, '인증(인정) 제품과 비인증 제품의 구분이 있나요?', 'select', '{\"choices\":[\"있음\",\"없음\"]}', NULL, NULL, NULL, 'product_classification', 0, 4, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_1_1, '인증 제품의 경우 구성이 고정되나요?', 'checkbox', NULL, NULL, NULL, '{\"question_index\":3,\"value\":\"있음\"}', 'product_classification', 0, 5, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_1_1, '카테고리별 제품 수는 대략 몇 개인가요?', 'number', NULL, NULL, '개', NULL, 'product_classification', 0, 6, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_1_1, '제품 코드 명명 규칙을 설명해주세요 (예: KSS01의 의미)', 'text', NULL, '코드 체계의 각 자릿수 의미', NULL, NULL, 'product_classification', 0, 7, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_1_1, '기존 시스템(ERP/엑셀)에서 사용하는 제품 분류 방식을 캡처하여 업로드해주세요', 'file_upload', NULL, NULL, NULL, NULL, 'product_classification', 0, 8, 1, @user_id, @user_id, @now, @now); + + INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, @cat_1, '설치 유형별 분류', 2, 1, @user_id, @user_id, @now, @now); + SET @tpl_1_2 = LAST_INSERT_ID(); + + INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES + (@tenant_id, @tpl_1_2, '설치 유형(벽면형, 측면형, 혼합형 등)에 따라 견적이 달라지나요?', 'select', '{\"choices\":[\"예, 크게 달라짐\",\"약간 달라짐\",\"달라지지 않음\"]}', NULL, NULL, NULL, 'product_classification', 0, 1, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_1_2, '각 설치 유형별로 어떤 부품이 달라지나요?', 'table_input', '{\"columns\":[\"설치유형\",\"추가부품\",\"제외부품\",\"비고\"]}', NULL, NULL, NULL, 'product_classification', 0, 2, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_1_2, '설치 유형에 따른 추가 비용 항목이 있나요?', 'text', NULL, NULL, NULL, NULL, 'product_classification', 0, 3, 1, @user_id, @user_id, @now, @now); + + -- Domain 2: BOM 구조 + INSERT INTO interview_categories (tenant_id, interview_project_id, parent_id, name, description, domain, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, NULL, @root_manufacturing, 'BOM 구조', '완제품-부품 관계, 부품 카테고리, BOM 레벨', 'bom_structure', 4, 1, @user_id, @user_id, @now, @now); + SET @cat_2 = LAST_INSERT_ID(); + + INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, @cat_2, '완제품-부품 관계', 1, 1, @user_id, @user_id, @now, @now); + SET @tpl_2_1 = LAST_INSERT_ID(); + + INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES + (@tenant_id, @tpl_2_1, '대표 제품 1개의 완제품→부품 구성을 트리로 그려주세요', 'bom_tree', NULL, '최상위 제품부터 하위 부품까지 트리 구조', NULL, NULL, 'bom_structure', 1, 1, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_2_1, '모든 제품에 공통으로 들어가는 부품은 무엇인가요?', 'multi_select', '{\"choices\":[\"가이드레일\",\"케이스\",\"모터\",\"제어기\",\"브라켓\",\"볼트/너트\"]}', '직접 입력 가능', NULL, NULL, 'bom_structure', 0, 2, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_2_1, '제품별로 선택적(옵션)인 부품은 무엇인가요?', 'table_input', '{\"columns\":[\"제품명\",\"옵션부품\",\"적용조건\"]}', NULL, NULL, NULL, 'bom_structure', 0, 3, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_2_1, 'BOM이 현재 엑셀로 관리되고 있나요? 파일을 업로드해주세요', 'file_upload', NULL, NULL, NULL, NULL, 'bom_structure', 0, 4, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_2_1, '하위 부품의 단계(레벨)는 최대 몇 단계인가요?', 'number', NULL, NULL, '단계', NULL, 'bom_structure', 0, 5, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_2_1, '부품 수량이 고정인 것과 계산이 필요한 것을 구분해주세요', 'table_input', '{\"columns\":[\"부품명\",\"고정/계산\",\"고정수량 또는 계산식\"]}', NULL, NULL, NULL, 'bom_structure', 0, 6, 1, @user_id, @user_id, @now, @now); + + INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, @cat_2, '부품 카테고리', 2, 1, @user_id, @user_id, @now, @now); + SET @tpl_2_2 = LAST_INSERT_ID(); + + INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES + (@tenant_id, @tpl_2_2, '부품을 카테고리로 분류하면 어떻게 나눠지나요? (본체, 절곡품, 전동부, 부자재 등)', 'text', NULL, '부품 분류 체계', NULL, NULL, 'bom_structure', 0, 1, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_2_2, '각 카테고리에 속하는 부품 목록을 정리해주세요', 'table_input', '{\"columns\":[\"카테고리\",\"부품명\",\"규격\"]}', NULL, NULL, NULL, 'bom_structure', 0, 2, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_2_2, '외주 구매 부품과 자체 제작 부품의 구분이 있나요?', 'select', '{\"choices\":[\"있음\",\"없음\"]}', NULL, NULL, NULL, 'bom_structure', 0, 3, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_2_2, '부자재(볼트, 너트, 패킹 등)는 별도 관리하나요?', 'checkbox', NULL, NULL, NULL, NULL, 'bom_structure', 0, 4, 1, @user_id, @user_id, @now, @now); + + -- Domain 3: 치수/변수 계산 + INSERT INTO interview_categories (tenant_id, interview_project_id, parent_id, name, description, domain, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, NULL, @root_manufacturing, '치수/변수 계산', '오픈 사이즈→제작 사이즈 변환, 파생 변수 계산', 'dimension_formula', 5, 1, @user_id, @user_id, @now, @now); + SET @cat_3 = LAST_INSERT_ID(); + + INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, @cat_3, '오픈 사이즈 → 제작 사이즈', 1, 1, @user_id, @user_id, @now, @now); + SET @tpl_3_1 = LAST_INSERT_ID(); + + INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES + (@tenant_id, @tpl_3_1, '고객이 입력하는 기본 치수 항목은 무엇인가요? (폭, 높이, 깊이 등)', 'multi_select', '{\"choices\":[\"폭(W)\",\"높이(H)\",\"깊이(D)\",\"두께(T)\",\"지름(Ø)\"]}', NULL, NULL, NULL, 'dimension_formula', 1, 1, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_3_1, '오픈 사이즈에서 제작 사이즈로 변환할 때 더하는 마진값은?', 'formula_input', NULL, '예: W1 = W0 + 120, H1 = H0 + 50', 'mm', NULL, 'dimension_formula', 0, 2, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_3_1, '제품 카테고리별로 마진값이 다른가요?', 'table_input', '{\"columns\":[\"제품카테고리\",\"W 마진(mm)\",\"H 마진(mm)\",\"비고\"]}', NULL, NULL, NULL, 'dimension_formula', 0, 3, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_3_1, '면적(㎡) 계산 공식을 알려주세요', 'formula_input', NULL, '예: area = W1 * H1 / 1000000', '㎡', NULL, 'dimension_formula', 0, 4, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_3_1, '중량(kg) 계산 공식을 알려주세요', 'formula_input', NULL, '예: weight = area * 단위중량(kg/㎡)', 'kg', NULL, 'dimension_formula', 0, 5, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_3_1, '기타 파생 변수가 있나요? (예: 분할 개수, 절곡 길이 등)', 'table_input', '{\"columns\":[\"변수명\",\"계산식\",\"단위\",\"비고\"]}', NULL, NULL, NULL, 'dimension_formula', 0, 6, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_3_1, '치수 계산에 사용하는 엑셀 수식을 캡처해주세요', 'file_upload', NULL, NULL, NULL, NULL, 'dimension_formula', 0, 7, 1, @user_id, @user_id, @now, @now); + + INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, @cat_3, '변수 의존 관계', 2, 1, @user_id, @user_id, @now, @now); + SET @tpl_3_2 = LAST_INSERT_ID(); + + INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES + (@tenant_id, @tpl_3_2, '변수 간 의존 관계를 설명해주세요 (A는 B와 C로 계산)', 'text', NULL, '계산 순서와 변수 의존성', NULL, NULL, 'dimension_formula', 0, 1, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_3_2, '계산 순서가 중요한 변수가 있나요?', 'text', NULL, NULL, NULL, NULL, 'dimension_formula', 0, 2, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_3_2, '단위는 mm, m, kg 중 어떤 것을 기본으로 사용하나요?', 'select', '{\"choices\":[\"mm\",\"m\",\"cm\",\"혼용\"]}', NULL, NULL, NULL, 'dimension_formula', 0, 3, 1, @user_id, @user_id, @now, @now); + + -- Domain 4: 부품 구성 상세 + INSERT INTO interview_categories (tenant_id, interview_project_id, parent_id, name, description, domain, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, NULL, @root_manufacturing, '부품 구성 상세', '주요 부품별 규격, 선택 기준, 특수 구성', 'component_config', 6, 1, @user_id, @user_id, @now, @now); + SET @cat_4 = LAST_INSERT_ID(); + + INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, @cat_4, '주요 부품별 상세', 1, 1, @user_id, @user_id, @now, @now); + SET @tpl_4_1 = LAST_INSERT_ID(); + + INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES + (@tenant_id, @tpl_4_1, '가이드레일의 표준 길이 규격은? (예: 1219, 2438, 3305mm)', 'table_input', '{\"columns\":[\"규격코드\",\"길이(mm)\",\"비고\"]}', NULL, NULL, NULL, 'component_config', 0, 1, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_4_1, '가이드레일 길이 조합 규칙은? (어떤 길이를 몇 개 사용?)', 'text', NULL, '높이에 따른 가이드레일 조합 로직', NULL, NULL, 'component_config', 0, 2, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_4_1, '케이스(하우징) 크기별 규격과 부속품 차이를 설명해주세요', 'table_input', '{\"columns\":[\"케이스규격\",\"적용조건\",\"부속품\"]}', NULL, NULL, NULL, 'component_config', 0, 3, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_4_1, '모터 용량 종류와 선택 기준은? (무게별? 면적별?)', 'table_input', '{\"columns\":[\"모터용량\",\"적용범위(최소)\",\"적용범위(최대)\",\"단위\"]}', '무게/면적 범위별 모터 매핑', NULL, NULL, 'component_config', 0, 4, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_4_1, '모터 전압 옵션은? (380V, 220V 등)', 'multi_select', '{\"choices\":[\"380V\",\"220V\",\"110V\",\"DC 24V\"]}', NULL, NULL, NULL, 'component_config', 0, 5, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_4_1, '제어기 종류와 선택 기준은? (노출형/매립형 등)', 'table_input', '{\"columns\":[\"제어기유형\",\"적용조건\",\"비고\"]}', NULL, NULL, NULL, 'component_config', 0, 6, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_4_1, '절곡품(판재 가공) 목록과 각각의 치수 결정 방식은?', 'table_input', '{\"columns\":[\"절곡품명\",\"치수결정방식\",\"재질\",\"두께(mm)\"]}', NULL, NULL, NULL, 'component_config', 0, 7, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_4_1, '부자재(볼트, 너트, 패킹 등) 목록과 수량 결정 방식은?', 'table_input', '{\"columns\":[\"부자재명\",\"규격\",\"수량결정방식\",\"기본수량\"]}', NULL, NULL, NULL, 'component_config', 0, 8, 1, @user_id, @user_id, @now, @now); + + INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, @cat_4, '특수 구성', 2, 1, @user_id, @user_id, @now, @now); + SET @tpl_4_2 = LAST_INSERT_ID(); + + INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES + (@tenant_id, @tpl_4_2, '연기차단재 등 특수 부품이 있나요? 적용 조건은?', 'text', NULL, NULL, NULL, NULL, 'component_config', 0, 1, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_4_2, '보강재(샤프트, 파이프, 앵글 등) 사용 조건은?', 'table_input', '{\"columns\":[\"보강재명\",\"규격\",\"적용조건\",\"수량\"]}', NULL, NULL, NULL, 'component_config', 0, 2, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_4_2, '고객 요청에 따라 추가/제외되는 옵션 부품은?', 'table_input', '{\"columns\":[\"옵션부품\",\"추가/제외\",\"추가비용\",\"비고\"]}', NULL, NULL, NULL, 'component_config', 0, 3, 1, @user_id, @user_id, @now, @now); + + -- Domain 5: 단가 체계 + INSERT INTO interview_categories (tenant_id, interview_project_id, parent_id, name, description, domain, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, NULL, @root_manufacturing, '단가 체계', '단가 관리 방식, 계산 방식, 마진/LOSS율', 'pricing_structure', 7, 1, @user_id, @user_id, @now, @now); + SET @cat_5 = LAST_INSERT_ID(); + + INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, @cat_5, '단가 관리 방식', 1, 1, @user_id, @user_id, @now, @now); + SET @tpl_5_1 = LAST_INSERT_ID(); + + INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES + (@tenant_id, @tpl_5_1, '부품별 단가를 어디서 관리하나요? (엑셀, ERP, 구두 등)', 'select', '{\"choices\":[\"엑셀\",\"ERP 시스템\",\"구두/경험\",\"기타\"]}', NULL, NULL, NULL, 'pricing_structure', 0, 1, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_5_1, '단가표 파일을 업로드해주세요', 'file_upload', NULL, NULL, NULL, NULL, 'pricing_structure', 0, 2, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_5_1, '단가 변경 주기는? (월/분기/연 등)', 'select', '{\"choices\":[\"수시\",\"월 단위\",\"분기 단위\",\"반기 단위\",\"연 단위\"]}', NULL, NULL, NULL, 'pricing_structure', 0, 3, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_5_1, '단가에 포함되는 항목은? (재료비만? 가공비 포함?)', 'multi_select', '{\"choices\":[\"재료비\",\"가공비\",\"운송비\",\"설치비\",\"마진\"]}', NULL, NULL, NULL, 'pricing_structure', 0, 4, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_5_1, '고객별/거래처별 차등 단가가 있나요?', 'select', '{\"choices\":[\"있음 (등급별)\",\"있음 (거래처별)\",\"없음 (일괄 동일)\"]}', NULL, NULL, NULL, 'pricing_structure', 0, 5, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_5_1, 'LOSS율(손실률)을 적용하나요? 적용 방식은?', 'formula_input', NULL, '예: 실제수량 = 계산수량 × (1 + LOSS율)', '%', NULL, 'pricing_structure', 0, 6, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_5_1, '마진율 설정 방식은? (일괄? 품목별?)', 'select', '{\"choices\":[\"일괄 마진율\",\"품목별 마진율\",\"카테고리별 마진율\",\"고객별 마진율\"]}', NULL, NULL, NULL, 'pricing_structure', 0, 7, 1, @user_id, @user_id, @now, @now); + + INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, @cat_5, '단가 계산 방식', 2, 1, @user_id, @user_id, @now, @now); + SET @tpl_5_2 = LAST_INSERT_ID(); + + INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES + (@tenant_id, @tpl_5_2, '면적 기반 단가 품목은? (원/㎡)', 'table_input', '{\"columns\":[\"품목명\",\"단가(원/㎡)\",\"비고\"]}', NULL, '원/㎡', NULL, 'pricing_structure', 0, 1, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_5_2, '중량 기반 단가 품목은? (원/kg)', 'table_input', '{\"columns\":[\"품목명\",\"단가(원/kg)\",\"비고\"]}', NULL, '원/kg', NULL, 'pricing_structure', 0, 2, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_5_2, '수량 기반 단가 품목은? (원/EA)', 'table_input', '{\"columns\":[\"품목명\",\"단가(원/EA)\",\"비고\"]}', NULL, '원/EA', NULL, 'pricing_structure', 0, 3, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_5_2, '길이 기반 단가 품목은? (원/m)', 'table_input', '{\"columns\":[\"품목명\",\"단가(원/m)\",\"비고\"]}', NULL, '원/m', NULL, 'pricing_structure', 0, 4, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_5_2, '기타 특수 단가 계산 방식이 있나요?', 'text', NULL, NULL, NULL, NULL, 'pricing_structure', 0, 5, 1, @user_id, @user_id, @now, @now); + + -- Domain 6: 수량 수식 + INSERT INTO interview_categories (tenant_id, interview_project_id, parent_id, name, description, domain, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, NULL, @root_manufacturing, '수량 수식', '부품별 수량 결정 규칙, 계산식, 검증', 'quantity_formula', 8, 1, @user_id, @user_id, @now, @now); + SET @cat_6 = LAST_INSERT_ID(); + + INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, @cat_6, '수량 결정 규칙', 1, 1, @user_id, @user_id, @now, @now); + SET @tpl_6_1 = LAST_INSERT_ID(); + + INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES + (@tenant_id, @tpl_6_1, '고정 수량 부품 목록 (항상 1개, 2개 등)', 'table_input', '{\"columns\":[\"부품명\",\"고정수량\",\"비고\"]}', NULL, NULL, NULL, 'quantity_formula', 0, 1, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_6_1, '치수 기반 수량 계산 부품과 수식', 'formula_input', NULL, '예: 슬랫수량 = CEIL(H1 / 슬랫피치)', NULL, NULL, 'quantity_formula', 0, 2, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_6_1, '면적 기반 수량 계산 부품과 수식', 'formula_input', NULL, '예: 스크린수량 = area / 기준면적', NULL, NULL, 'quantity_formula', 0, 3, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_6_1, '중량 기반 수량 계산 부품과 수식', 'formula_input', NULL, NULL, NULL, NULL, 'quantity_formula', 0, 4, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_6_1, '올림/내림/반올림 규칙이 있는 계산은?', 'table_input', '{\"columns\":[\"계산항목\",\"올림/내림/반올림\",\"소수점자릿수\"]}', NULL, NULL, NULL, 'quantity_formula', 0, 5, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_6_1, '여유 수량(LOSS) 적용 품목과 비율은?', 'table_input', '{\"columns\":[\"품목명\",\"LOSS율(%)\",\"비고\"]}', NULL, NULL, NULL, 'quantity_formula', 0, 6, 1, @user_id, @user_id, @now, @now); + + INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, @cat_6, '수식 검증', 2, 1, @user_id, @user_id, @now, @now); + SET @tpl_6_2 = LAST_INSERT_ID(); + + INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES + (@tenant_id, @tpl_6_2, '실제 견적서에서 수량 계산 예시를 보여주세요 (W=3000, H=2500일 때)', 'table_input', '{\"columns\":[\"부품명\",\"수식\",\"계산결과\",\"단위\"]}', NULL, NULL, NULL, 'quantity_formula', 1, 1, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_6_2, '수식에 사용하는 함수가 있나요? (SUM, CEIL, ROUND 등)', 'multi_select', '{\"choices\":[\"CEIL (올림)\",\"FLOOR (내림)\",\"ROUND (반올림)\",\"MAX\",\"MIN\",\"IF 조건문\",\"SUM\"]}', NULL, NULL, NULL, 'quantity_formula', 0, 2, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_6_2, '조건에 따라 수식이 달라지는 경우가 있나요?', 'text', NULL, '예: 폭이 3000 초과이면 분할 계산', NULL, NULL, 'quantity_formula', 0, 3, 1, @user_id, @user_id, @now, @now); + + -- Domain 7: 조건부 로직 + INSERT INTO interview_categories (tenant_id, interview_project_id, parent_id, name, description, domain, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, NULL, @root_manufacturing, '조건부 로직', '범위/매핑 기반 부품 자동 선택 규칙', 'conditional_logic', 9, 1, @user_id, @user_id, @now, @now); + SET @cat_7 = LAST_INSERT_ID(); + + INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, @cat_7, '범위 기반 선택', 1, 1, @user_id, @user_id, @now, @now); + SET @tpl_7_1 = LAST_INSERT_ID(); + + INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES + (@tenant_id, @tpl_7_1, '무게 범위별 모터 용량 선택표를 작성해주세요', 'price_table', '{\"columns\":[\"범위 시작(kg)\",\"범위 끝(kg)\",\"모터용량\",\"비고\"]}', NULL, NULL, NULL, 'conditional_logic', 1, 1, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_7_1, '크기 범위별 부품 자동 선택 규칙이 있나요?', 'table_input', '{\"columns\":[\"조건(변수)\",\"범위\",\"선택부품\",\"비고\"]}', NULL, NULL, NULL, 'conditional_logic', 0, 2, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_7_1, '브라켓 크기 결정 기준은?', 'table_input', '{\"columns\":[\"조건\",\"범위\",\"브라켓 규격\"]}', NULL, NULL, NULL, 'conditional_logic', 0, 3, 1, @user_id, @user_id, @now, @now); + + INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, @cat_7, '매핑 기반 선택', 2, 1, @user_id, @user_id, @now, @now); + SET @tpl_7_2 = LAST_INSERT_ID(); + + INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES + (@tenant_id, @tpl_7_2, '제품 모델 → 기본 부품 세트 매핑표', 'table_input', '{\"columns\":[\"제품모델\",\"기본부품1\",\"기본부품2\",\"기본부품3\"]}', NULL, NULL, NULL, 'conditional_logic', 0, 1, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_7_2, '설치 유형 → 추가 부품 매핑표', 'table_input', '{\"columns\":[\"설치유형\",\"추가부품\",\"수량\",\"비고\"]}', NULL, NULL, NULL, 'conditional_logic', 0, 2, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_7_2, '제어기 유형 → 부속품 매핑표', 'table_input', '{\"columns\":[\"제어기유형\",\"부속품1\",\"부속품2\",\"부속품3\"]}', NULL, NULL, NULL, 'conditional_logic', 0, 3, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_7_2, '기타 조건부 자동 선택 규칙', 'text', NULL, '위 항목에 해당하지 않는 조건-결과 매핑', NULL, NULL, 'conditional_logic', 0, 4, 1, @user_id, @user_id, @now, @now); + + -- Domain 8: 견적서 양식 + INSERT INTO interview_categories (tenant_id, interview_project_id, parent_id, name, description, domain, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, NULL, @root_manufacturing, '견적서 양식', '출력 양식, 항목 그룹, 소계/합계 구조', 'quote_format', 10, 1, @user_id, @user_id, @now, @now); + SET @cat_8 = LAST_INSERT_ID(); + + INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, @cat_8, '출력 양식', 1, 1, @user_id, @user_id, @now, @now); + SET @tpl_8_1 = LAST_INSERT_ID(); + + INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES + (@tenant_id, @tpl_8_1, '현재 사용 중인 견적서 양식을 업로드해주세요', 'file_upload', NULL, NULL, NULL, NULL, 'quote_format', 1, 1, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_8_1, '견적서에 표시되는 항목 그룹은? (재료비, 노무비, 설치비 등)', 'multi_select', '{\"choices\":[\"재료비\",\"노무비\",\"경비\",\"설치비\",\"운반비\",\"이윤\",\"부가세\"]}', NULL, NULL, NULL, 'quote_format', 0, 2, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_8_1, '소계/합계 계산 구조를 설명해주세요', 'text', NULL, '항목 그룹별 소계와 최종 합계의 관계', NULL, NULL, 'quote_format', 0, 3, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_8_1, '할인 적용 방식은? (일괄? 항목별?)', 'select', '{\"choices\":[\"일괄 할인\",\"항목별 할인\",\"할인 없음\",\"협의 할인\"]}', NULL, NULL, NULL, 'quote_format', 0, 4, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_8_1, '부가세 표시 방식은? (별도? 포함?)', 'select', '{\"choices\":[\"별도 표시\",\"포함 표시\",\"선택 가능\"]}', NULL, NULL, NULL, 'quote_format', 0, 5, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_8_1, '견적서에 표시하지 않는 내부 관리 항목은?', 'text', NULL, NULL, NULL, NULL, 'quote_format', 0, 6, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_8_1, '견적 번호 체계를 알려주세요', 'text', NULL, '예: Q-2026-001 형식', NULL, NULL, 'quote_format', 0, 7, 1, @user_id, @user_id, @now, @now); + + INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) + VALUES (@tenant_id, @cat_8, '특수 요구사항', 2, 1, @user_id, @user_id, @now, @now); + SET @tpl_8_2 = LAST_INSERT_ID(); + + INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES + (@tenant_id, @tpl_8_2, '산출내역서(세부 내역)를 별도로 제공하나요?', 'checkbox', NULL, NULL, NULL, NULL, 'quote_format', 0, 1, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_8_2, '위치별(층/부호) 개별 산출이 필요한가요?', 'checkbox', NULL, NULL, NULL, NULL, 'quote_format', 0, 2, 1, @user_id, @user_id, @now, @now), + (@tenant_id, @tpl_8_2, '일괄 산출(여러 위치 합산)을 사용하나요?', 'checkbox', NULL, NULL, NULL, NULL, 'quote_format', 0, 3, 1, @user_id, @user_id, @now, @now); + "); + } + + public function down(): void + { + // 마스터 데이터 삭제 (프로젝트 복제 데이터는 유지) + $masterCategoryIds = DB::table('interview_categories') + ->whereNull('interview_project_id') + ->whereNotNull('domain') + ->pluck('id'); + + if ($masterCategoryIds->isEmpty()) { + return; + } + + $templateIds = DB::table('interview_templates') + ->whereIn('interview_category_id', $masterCategoryIds) + ->pluck('id'); + + DB::table('interview_questions')->whereIn('interview_template_id', $templateIds)->delete(); + DB::table('interview_templates')->whereIn('id', $templateIds)->delete(); + DB::table('interview_categories')->whereIn('id', $masterCategoryIds)->delete(); + } +}; From 3ca161e9e2be089fb800b7ba32177659b91fbad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Mon, 2 Mar 2026 15:50:05 +0900 Subject: [PATCH 025/166] =?UTF-8?q?feat:=20[roadmap]=20=EC=A4=91=EC=9E=A5?= =?UTF-8?q?=EA=B8=B0=20=EA=B3=84=ED=9A=8D=20=ED=85=8C=EC=9D=B4=EB=B8=94=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 - admin_roadmap_plans: 계획 테이블 (제목, 카테고리, 상태, Phase, 진행률 등) - admin_roadmap_milestones: 마일스톤 테이블 (plan_id FK, 상태, 예정일 등) --- ..._02_000000_create_admin_roadmap_tables.php | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 database/migrations/2026_03_02_000000_create_admin_roadmap_tables.php diff --git a/database/migrations/2026_03_02_000000_create_admin_roadmap_tables.php b/database/migrations/2026_03_02_000000_create_admin_roadmap_tables.php new file mode 100644 index 0000000..c786e5d --- /dev/null +++ b/database/migrations/2026_03_02_000000_create_admin_roadmap_tables.php @@ -0,0 +1,68 @@ +id(); + $table->string('title', 200); + $table->text('description')->nullable(); + $table->longText('content')->nullable(); + $table->string('category', 30)->default('general'); + $table->string('status', 20)->default('planned'); + $table->string('priority', 10)->default('medium'); + $table->string('phase', 30)->default('phase_1'); + $table->date('start_date')->nullable(); + $table->date('end_date')->nullable(); + $table->tinyInteger('progress')->unsigned()->default(0); + $table->string('color', 7)->default('#3B82F6'); + $table->integer('sort_order')->default(0); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->unsignedBigInteger('deleted_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('category'); + $table->index('status'); + $table->index('phase'); + $table->index('priority'); + }); + + Schema::create('admin_roadmap_milestones', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('plan_id'); + $table->string('title', 255); + $table->text('description')->nullable(); + $table->string('status', 20)->default('pending'); + $table->date('due_date')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->unsignedBigInteger('assignee_id')->nullable(); + $table->integer('sort_order')->default(0); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->unsignedBigInteger('deleted_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('plan_id') + ->references('id') + ->on('admin_roadmap_plans') + ->cascadeOnDelete(); + + $table->index('status'); + $table->index('due_date'); + }); + } + + public function down(): void + { + Schema::dropIfExists('admin_roadmap_milestones'); + Schema::dropIfExists('admin_roadmap_plans'); + } +}; From abe04607e43abd4b025a12e2d2a0e78a636603c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Mon, 2 Mar 2026 17:43:36 +0900 Subject: [PATCH 026/166] =?UTF-8?q?feat:=20[rd]=20AI=20=EA=B2=AC=EC=A0=81?= =?UTF-8?q?=20=EC=97=94=EC=A7=84=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20+=20=EB=AA=A8=EB=93=88=20=EC=B9=B4=ED=83=88?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=8B=9C=EB=8D=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ai_quotation_modules: SAM 모듈 카탈로그 (18개 모듈) - ai_quotations: AI 견적 요청/결과 - ai_quotation_items: AI 추천 모듈 목록 - AiQuotationModuleSeeder: customer-pricing 기반 초기 데이터 --- ...3_02_100000_create_ai_quotation_tables.php | 89 ++++++ database/seeders/AiQuotationModuleSeeder.php | 281 ++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 database/migrations/2026_03_02_100000_create_ai_quotation_tables.php create mode 100644 database/seeders/AiQuotationModuleSeeder.php diff --git a/database/migrations/2026_03_02_100000_create_ai_quotation_tables.php b/database/migrations/2026_03_02_100000_create_ai_quotation_tables.php new file mode 100644 index 0000000..ab6f80f --- /dev/null +++ b/database/migrations/2026_03_02_100000_create_ai_quotation_tables.php @@ -0,0 +1,89 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('module_code', 50); + $table->string('module_name', 100); + $table->enum('category', ['basic', 'individual', 'addon']); + $table->text('description')->nullable(); + $table->json('keywords')->nullable(); + $table->decimal('dev_cost', 12, 0)->default(0); + $table->decimal('monthly_fee', 10, 0)->default(0); + $table->boolean('is_active')->default(true); + $table->integer('sort_order')->default(0); + $table->json('options')->nullable(); + $table->timestamps(); + + $table->unique(['tenant_id', 'module_code'], 'uk_tenant_module'); + $table->index('tenant_id'); + $table->index('category'); + }); + + // AI 견적 요청/결과 + Schema::create('ai_quotations', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('title', 200); + $table->enum('input_type', ['text', 'voice', 'document'])->default('text'); + $table->longText('input_text')->nullable(); + $table->string('input_file_path', 500)->nullable(); + $table->string('ai_provider', 20)->default('gemini'); + $table->string('ai_model', 50)->nullable(); + $table->json('analysis_result')->nullable(); + $table->json('quotation_result')->nullable(); + $table->string('status', 20)->default('pending'); + $table->unsignedBigInteger('linked_quote_id')->nullable(); + $table->decimal('total_dev_cost', 12, 0)->default(0); + $table->decimal('total_monthly_fee', 10, 0)->default(0); + $table->unsignedBigInteger('created_by')->nullable(); + $table->json('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('tenant_id'); + $table->index('status'); + $table->index('created_by'); + }); + + // AI 추천 모듈 목록 + Schema::create('ai_quotation_items', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('ai_quotation_id'); + $table->unsignedBigInteger('module_id')->nullable(); + $table->string('module_code', 50); + $table->string('module_name', 100); + $table->boolean('is_required')->default(false); + $table->text('reason')->nullable(); + $table->decimal('dev_cost', 12, 0)->default(0); + $table->decimal('monthly_fee', 10, 0)->default(0); + $table->integer('sort_order')->default(0); + $table->json('options')->nullable(); + $table->timestamps(); + + $table->foreign('ai_quotation_id') + ->references('id') + ->on('ai_quotations') + ->cascadeOnDelete(); + + $table->index('ai_quotation_id'); + $table->index('module_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('ai_quotation_items'); + Schema::dropIfExists('ai_quotations'); + Schema::dropIfExists('ai_quotation_modules'); + } +}; diff --git a/database/seeders/AiQuotationModuleSeeder.php b/database/seeders/AiQuotationModuleSeeder.php new file mode 100644 index 0000000..d321f2d --- /dev/null +++ b/database/seeders/AiQuotationModuleSeeder.php @@ -0,0 +1,281 @@ + 'BASIC_PKG', + 'module_name' => '기본 패키지 (인사+근태+급여+게시판)', + 'category' => 'basic', + 'description' => '인사관리, 근태관리, 급여관리, 게시판/공지사항을 포함하는 기본 패키지. 모든 기업에 필수적인 기본 관리 모듈을 통합 제공합니다.', + 'keywords' => json_encode([ + 'keywords' => ['기본', '인사', '근태', '급여', '게시판', '공지사항', '직원관리'], + 'pain_points' => ['직원 관리 시스템 없음', '출퇴근 기록 수기', '급여 엑셀 관리'], + 'business_needs' => ['직원 기본 관리', '출퇴근 관리', '급여 자동화', '사내 공지'], + ], JSON_UNESCAPED_UNICODE), + 'dev_cost' => 5000000, + 'monthly_fee' => 200000, + 'sort_order' => 1, + ], + [ + 'module_code' => 'HR', + 'module_name' => '인사관리', + 'category' => 'individual', + 'description' => '직원 정보, 조직도, 부서 관리, 입퇴사 처리, 인사발령 관리', + 'keywords' => json_encode([ + 'keywords' => ['직원', '사원', '인사', '조직도', '부서', '입퇴사', '인력', '인사발령'], + 'pain_points' => ['엑셀로 직원 관리', '입퇴사 관리가 번거로움', '조직도 없음'], + 'business_needs' => ['직원 정보 통합', '조직 구조 관리', '인력 현황 파악'], + ], JSON_UNESCAPED_UNICODE), + 'dev_cost' => 2000000, + 'monthly_fee' => 80000, + 'sort_order' => 10, + ], + [ + 'module_code' => 'ATTENDANCE', + 'module_name' => '근태관리', + 'category' => 'individual', + 'description' => '출퇴근 기록, 연차/휴가 관리, 초과근무, 근태 통계', + 'keywords' => json_encode([ + 'keywords' => ['출퇴근', '근태', '연차', '휴가', '초과근무', '야근', '출근부'], + 'pain_points' => ['수기 출퇴근 기록', '연차 잔여 파악 어려움', '초과근무 관리 부재'], + 'business_needs' => ['자동 출퇴근 기록', '연차 관리', '근태 현황 파악'], + ], JSON_UNESCAPED_UNICODE), + 'dev_cost' => 1500000, + 'monthly_fee' => 60000, + 'sort_order' => 11, + ], + [ + 'module_code' => 'PAYROLL', + 'module_name' => '급여관리', + 'category' => 'individual', + 'description' => '급여 계산, 4대보험, 원천징수, 급여명세서, 퇴직금 산출', + 'keywords' => json_encode([ + 'keywords' => ['급여', '월급', '4대보험', '원천징수', '급여명세서', '퇴직금', '세금'], + 'pain_points' => ['엑셀 급여 계산', '4대보험 수동 계산', '급여명세서 수동 발급'], + 'business_needs' => ['급여 자동 계산', '세금/보험 자동 처리', '급여명세서 자동 발급'], + ], JSON_UNESCAPED_UNICODE), + 'dev_cost' => 2500000, + 'monthly_fee' => 100000, + 'sort_order' => 12, + ], + [ + 'module_code' => 'BOARD', + 'module_name' => '게시판/공지사항', + 'category' => 'individual', + 'description' => '사내 공지사항, 자유게시판, 부서별 게시판, 파일 첨부', + 'keywords' => json_encode([ + 'keywords' => ['게시판', '공지사항', '사내공지', '알림', '커뮤니케이션'], + 'pain_points' => ['공지 전달 어려움', '사내 소통 부재'], + 'business_needs' => ['사내 공지 시스템', '부서간 소통'], + ], JSON_UNESCAPED_UNICODE), + 'dev_cost' => 500000, + 'monthly_fee' => 20000, + 'sort_order' => 13, + ], + [ + 'module_code' => 'SALES', + 'module_name' => '영업관리 (CRM+견적+수주)', + 'category' => 'individual', + 'description' => '고객관리(CRM), 견적서 작성/관리, 수주 관리, 영업 현황 대시보드', + 'keywords' => json_encode([ + 'keywords' => ['영업', 'CRM', '고객', '견적', '수주', '거래처', '매출', '영업사원'], + 'pain_points' => ['견적서 수동 작성', '고객 이력 관리 안 됨', '영업 현황 파악 어려움'], + 'business_needs' => ['고객 DB 관리', '견적 자동화', '수주 관리', '영업 실적 분석'], + ], JSON_UNESCAPED_UNICODE), + 'dev_cost' => 5000000, + 'monthly_fee' => 150000, + 'sort_order' => 20, + ], + [ + 'module_code' => 'PURCHASE', + 'module_name' => '구매/자재관리', + 'category' => 'individual', + 'description' => '발주, 입고, 자재관리, 재고현황, 거래처 관리', + 'keywords' => json_encode([ + 'keywords' => ['구매', '자재', '발주', '입고', '재고', '거래처', '원자재', '부품'], + 'pain_points' => ['재고 파악 불가', '발주 관리 수동', '자재 이력 추적 안 됨'], + 'business_needs' => ['실시간 재고 관리', '자동 발주', '자재 이력 추적'], + ], JSON_UNESCAPED_UNICODE), + 'dev_cost' => 3000000, + 'monthly_fee' => 100000, + 'sort_order' => 30, + ], + [ + 'module_code' => 'PRODUCTION', + 'module_name' => '생산관리 (MES)', + 'category' => 'individual', + 'description' => '작업지시, 공정관리, LOT 추적, 생산실적, 불량관리', + 'keywords' => json_encode([ + 'keywords' => ['생산', '제조', '작업지시', '공정', 'LOT', '불량', 'MES', '작업일보'], + 'pain_points' => ['생산 현황 수기 기록', '불량 추적 불가', '납기 관리 어려움', '작업일보 종이'], + 'business_needs' => ['실시간 생산현황', '불량률 관리', '작업지시 자동화', 'LOT 추적'], + ], JSON_UNESCAPED_UNICODE), + 'dev_cost' => 8000000, + 'monthly_fee' => 250000, + 'sort_order' => 40, + ], + [ + 'module_code' => 'QUALITY', + 'module_name' => '품질관리', + 'category' => 'individual', + 'description' => '수입검사, 공정검사, 출하검사, 불량분석, 검사성적서', + 'keywords' => json_encode([ + 'keywords' => ['품질', '검사', '불량', '수입검사', '출하검사', '품질인증', 'ISO'], + 'pain_points' => ['검사 기록 수기', '불량 원인 분석 어려움', '검사성적서 수동 발행'], + 'business_needs' => ['검사 이력 관리', '불량 분석 자동화', '검사성적서 자동 발행'], + ], JSON_UNESCAPED_UNICODE), + 'dev_cost' => 4000000, + 'monthly_fee' => 120000, + 'sort_order' => 41, + ], + [ + 'module_code' => 'FINANCE', + 'module_name' => '재무/회계관리', + 'category' => 'individual', + 'description' => '매출/매입 관리, 세금계산서, 미수금/미지급금, 재무제표', + 'keywords' => json_encode([ + 'keywords' => ['재무', '회계', '매출', '매입', '세금계산서', '미수금', '미지급금', '장부'], + 'pain_points' => ['엑셀 장부 관리', '세금계산서 수동 발행', '미수금 추적 어려움'], + 'business_needs' => ['자동 장부 관리', '세금계산서 자동 발행', '미수금 알림'], + ], JSON_UNESCAPED_UNICODE), + 'dev_cost' => 5000000, + 'monthly_fee' => 150000, + 'sort_order' => 50, + ], + [ + 'module_code' => 'LOGISTICS', + 'module_name' => '물류/출하관리', + 'category' => 'individual', + 'description' => '출하 계획, 배송 관리, 운송장 발행, 출하 이력', + 'keywords' => json_encode([ + 'keywords' => ['물류', '출하', '배송', '운송', '택배', '납품', '출고'], + 'pain_points' => ['출하 현황 파악 어려움', '배송 추적 불가'], + 'business_needs' => ['출하 계획 관리', '배송 추적', '납품서 자동 발행'], + ], JSON_UNESCAPED_UNICODE), + 'dev_cost' => 3000000, + 'monthly_fee' => 100000, + 'sort_order' => 31, + ], + [ + 'module_code' => 'APPROVAL', + 'module_name' => '전자결재', + 'category' => 'individual', + 'description' => '결재 라인 설정, 기안/승인/반려, 결재 양식 관리', + 'keywords' => json_encode([ + 'keywords' => ['결재', '기안', '승인', '품의서', '지출결의', '전자결재', '워크플로우'], + 'pain_points' => ['종이 결재', '결재 지연', '결재 이력 관리 안 됨'], + 'business_needs' => ['전자결재 도입', '결재 프로세스 자동화', '모바일 결재'], + ], JSON_UNESCAPED_UNICODE), + 'dev_cost' => 3000000, + 'monthly_fee' => 80000, + 'sort_order' => 60, + ], + [ + 'module_code' => 'DOCUMENT', + 'module_name' => '문서관리 (전자서명)', + 'category' => 'individual', + 'description' => '문서 생성/관리, 전자서명, 문서 버전 관리, 계약서 관리', + 'keywords' => json_encode([ + 'keywords' => ['문서', '계약서', '전자서명', '서명', '파일관리', '문서보관'], + 'pain_points' => ['종이 계약서', '문서 분실', '서명을 위한 방문'], + 'business_needs' => ['전자서명', '문서 중앙관리', '계약서 자동 생성'], + ], JSON_UNESCAPED_UNICODE), + 'dev_cost' => 2000000, + 'monthly_fee' => 60000, + 'sort_order' => 61, + ], + [ + 'module_code' => 'EQUIPMENT', + 'module_name' => '설비관리', + 'category' => 'individual', + 'description' => '설비 대장, 점검/보전 관리, 고장 이력, 예방보전', + 'keywords' => json_encode([ + 'keywords' => ['설비', '기계', '점검', '보전', '고장', '수리', '유지보수'], + 'pain_points' => ['설비 이력 관리 안 됨', '고장 대응 늦음', '점검 일정 수동 관리'], + 'business_needs' => ['설비 대장 관리', '점검 일정 자동화', '고장 이력 추적'], + ], JSON_UNESCAPED_UNICODE), + 'dev_cost' => 3000000, + 'monthly_fee' => 100000, + 'sort_order' => 42, + ], + [ + 'module_code' => 'INTEGRATED', + 'module_name' => '통합 패키지', + 'category' => 'basic', + 'description' => '모든 개별 모듈을 포함하는 통합 패키지. 개별 구매 대비 할인 적용.', + 'keywords' => json_encode([ + 'keywords' => ['통합', '전사', 'ERP', '올인원', '풀패키지'], + 'pain_points' => ['전체 업무 디지털화 필요', '시스템 분산으로 인한 비효율'], + 'business_needs' => ['전사 통합 관리', '데이터 일원화', '부서간 연계'], + ], JSON_UNESCAPED_UNICODE), + 'dev_cost' => 30000000, + 'monthly_fee' => 800000, + 'sort_order' => 2, + ], + [ + 'module_code' => 'AI_TOKEN', + 'module_name' => 'AI 토큰 추가', + 'category' => 'addon', + 'description' => 'AI 기능 사용을 위한 추가 토큰. 기본 월 100만 토큰 포함, 초과분 별도.', + 'keywords' => json_encode([ + 'keywords' => ['AI', '인공지능', '자동화', '챗봇', '음성인식'], + 'pain_points' => [], + 'business_needs' => ['AI 분석', '자동화 강화'], + ], JSON_UNESCAPED_UNICODE), + 'dev_cost' => 0, + 'monthly_fee' => 0, + 'sort_order' => 90, + ], + [ + 'module_code' => 'STORAGE', + 'module_name' => '파일 저장공간 추가', + 'category' => 'addon', + 'description' => '기본 10GB 포함, 추가 저장공간. 문서, 이미지, 첨부파일 등.', + 'keywords' => json_encode([ + 'keywords' => ['저장공간', '파일', '클라우드', '백업', '스토리지'], + 'pain_points' => [], + 'business_needs' => ['대용량 파일 저장', '클라우드 백업'], + ], JSON_UNESCAPED_UNICODE), + 'dev_cost' => 0, + 'monthly_fee' => 0, + 'sort_order' => 91, + ], + [ + 'module_code' => 'CUSTOM_DEV', + 'module_name' => '커스텀 개발', + 'category' => 'addon', + 'description' => '고객 맞춤형 기능 개발. 별도 협의 필요.', + 'keywords' => json_encode([ + 'keywords' => ['맞춤', '커스텀', '추가개발', '특수기능'], + 'pain_points' => [], + 'business_needs' => ['업종별 특수 기능', '기존 시스템 연동'], + ], JSON_UNESCAPED_UNICODE), + 'dev_cost' => 0, + 'monthly_fee' => 0, + 'sort_order' => 92, + ], + ]; + + foreach ($modules as $module) { + DB::table('ai_quotation_modules')->updateOrInsert( + ['tenant_id' => $tenantId, 'module_code' => $module['module_code']], + array_merge($module, [ + 'tenant_id' => $tenantId, + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]) + ); + } + } +} From f79d00877778b62af7e9c156654165f0da7c3f54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 3 Mar 2026 08:09:08 +0900 Subject: [PATCH 027/166] =?UTF-8?q?chore:=20[ai]=20Gemini=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20gemini-2.0-flash=20=E2=86=92=20gemini-2.5-flash=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=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 - config/services.php fallback 기본값 변경 - AiReportService fallback 기본값 변경 --- app/Services/AiReportService.php | 2 +- config/services.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Services/AiReportService.php b/app/Services/AiReportService.php index 8a65a0e..cc87567 100644 --- a/app/Services/AiReportService.php +++ b/app/Services/AiReportService.php @@ -323,7 +323,7 @@ private function getReceivableData(int $tenantId, Carbon $reportDate): array private function callGeminiApi(array $inputData): array { $apiKey = config('services.gemini.api_key'); - $model = config('services.gemini.model', 'gemini-2.0-flash'); + $model = config('services.gemini.model', 'gemini-2.5-flash'); $baseUrl = config('services.gemini.base_url'); if (empty($apiKey)) { diff --git a/config/services.php b/config/services.php index 3865719..6a99ace 100644 --- a/config/services.php +++ b/config/services.php @@ -43,7 +43,7 @@ */ 'gemini' => [ 'api_key' => env('GEMINI_API_KEY'), - 'model' => env('GEMINI_MODEL', 'gemini-2.0-flash'), + 'model' => env('GEMINI_MODEL', 'gemini-2.5-flash'), 'base_url' => env('GEMINI_BASE_URL', 'https://generativelanguage.googleapis.com/v1beta'), ], From 7e309e4057e11d9dc7a214d9fc548e0bfe9d354a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 3 Mar 2026 09:51:07 +0900 Subject: [PATCH 028/166] =?UTF-8?q?fix:=20[deploy]=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=8B=9C=20.env=20=EA=B6=8C=ED=95=9C=20640=20=EB=B3=B4?= =?UTF-8?q?=EC=9E=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stage/Production 배포 스크립트에 chmod 640 추가 - vi 편집으로 인한 .env 권한 변경(600) 방지 - 2026-03-03 장애 재발 방지 (PHP-FPM이 .env 읽기 실패 → 500) Co-Authored-By: Claude Opus 4.6 --- Jenkinsfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Jenkinsfile b/Jenkinsfile index f76fef7..0a5af7b 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -40,6 +40,7 @@ pipeline { sudo chown -R www-data:webservice storage bootstrap/cache && sudo chmod -R 775 storage bootstrap/cache && ln -sfn /home/webservice/api-stage/shared/.env .env && + sudo chmod 640 /home/webservice/api-stage/shared/.env && ln -sfn /home/webservice/api-stage/shared/storage/app storage/app && composer install --no-dev --optimize-autoloader --no-interaction && php artisan config:cache && @@ -86,6 +87,7 @@ pipeline { sudo chown -R www-data:webservice storage bootstrap/cache && sudo chmod -R 775 storage bootstrap/cache && ln -sfn /home/webservice/api/shared/.env .env && + sudo chmod 640 /home/webservice/api/shared/.env && ln -sfn /home/webservice/api/shared/storage/app storage/app && composer install --no-dev --optimize-autoloader --no-interaction && php artisan config:cache && From b3c7d08b2cd4e971be9eec9a540fdfd6f68ea264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 3 Mar 2026 14:20:33 +0900 Subject: [PATCH 029/166] =?UTF-8?q?feat:=20[hr]=20=EC=82=AC=EC=97=85?= =?UTF-8?q?=EC=86=8C=EB=93=9D=EC=9E=90=20=EC=9E=84=EA=B8=88=EB=8C=80?= =?UTF-8?q?=EC=9E=A5=20display=5Fname/business=5Freg=5Fnumber=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - user_id nullable 변경 (직접 입력 대상자 지원) - display_name, business_reg_number 컬럼 추가 - 기존 데이터 earner 프로필에서 자동 채움 --- ...splay_name_to_business_income_payments.php | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 database/migrations/2026_03_03_100000_add_display_name_to_business_income_payments.php diff --git a/database/migrations/2026_03_03_100000_add_display_name_to_business_income_payments.php b/database/migrations/2026_03_03_100000_add_display_name_to_business_income_payments.php new file mode 100644 index 0000000..eacc9cb --- /dev/null +++ b/database/migrations/2026_03_03_100000_add_display_name_to_business_income_payments.php @@ -0,0 +1,47 @@ +unsignedBigInteger('user_id')->nullable()->change(); + $table->string('display_name', 100)->nullable()->after('user_id')->comment('상호/성명 (표시용)'); + $table->string('business_reg_number', 20)->nullable()->after('display_name')->comment('사업자등록번호'); + }); + + // 기존 데이터에 display_name/business_reg_number 채우기 + DB::statement(" + UPDATE business_income_payments bip + JOIN tenant_user_profiles tup ON tup.user_id = bip.user_id + AND tup.tenant_id = bip.tenant_id AND tup.worker_type = 'business_income' + SET bip.display_name = COALESCE( + JSON_UNQUOTE(JSON_EXTRACT(tup.json_extra, '$.business_name')), + (SELECT name FROM users WHERE id = bip.user_id) + ), + bip.business_reg_number = JSON_UNQUOTE(JSON_EXTRACT(tup.json_extra, '$.business_registration_number')) + WHERE bip.display_name IS NULL + "); + + // earner 프로필 없는 경우 users 테이블에서 이름만이라도 채우기 + DB::statement(" + UPDATE business_income_payments bip + JOIN users u ON u.id = bip.user_id + SET bip.display_name = u.name + WHERE bip.display_name IS NULL AND bip.user_id IS NOT NULL + "); + } + + public function down(): void + { + Schema::table('business_income_payments', function (Blueprint $table) { + $table->dropColumn(['display_name', 'business_reg_number']); + $table->unsignedBigInteger('user_id')->nullable(false)->change(); + }); + } +}; From da1142af628a32f7a378da60d3ebfcc68d857375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 3 Mar 2026 15:57:19 +0900 Subject: [PATCH 030/166] =?UTF-8?q?feat:=20[ai-quotation]=20=EC=A0=9C?= =?UTF-8?q?=EC=A1=B0=20=EA=B2=AC=EC=A0=81=EC=84=9C=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=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 - ai_quotations: quote_mode, quote_number, product_category 컬럼 추가 - ai_quotation_items: specification, unit, quantity, unit_price, total_price, item_category, floor_code 컬럼 추가 - ai_quote_price_tables 테이블 신규 생성 --- ...dd_manufacture_fields_to_ai_quotations.php | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 database/migrations/2026_03_03_200000_add_manufacture_fields_to_ai_quotations.php diff --git a/database/migrations/2026_03_03_200000_add_manufacture_fields_to_ai_quotations.php b/database/migrations/2026_03_03_200000_add_manufacture_fields_to_ai_quotations.php new file mode 100644 index 0000000..b190bce --- /dev/null +++ b/database/migrations/2026_03_03_200000_add_manufacture_fields_to_ai_quotations.php @@ -0,0 +1,79 @@ +string('quote_mode', 20)->default('module')->after('tenant_id') + ->comment('견적 모드: module(모듈추천), manufacture(제조견적)'); + $table->string('quote_number', 50)->nullable()->after('quote_mode') + ->comment('견적번호: AQ-SC-260303-01'); + $table->string('product_category', 50)->nullable()->after('quote_number') + ->comment('제품 카테고리: SCREEN, STEEL'); + + $table->index('quote_mode'); + $table->index('quote_number'); + }); + + // ai_quotation_items 테이블: 제조 견적 품목 필드 추가 + Schema::table('ai_quotation_items', function (Blueprint $table) { + $table->string('specification', 200)->nullable()->after('module_name') + ->comment('규격: 3000×2500'); + $table->string('unit', 20)->nullable()->after('specification') + ->comment('단위: SET, EA, ㎡'); + $table->decimal('quantity', 10, 2)->default(1)->after('unit') + ->comment('수량'); + $table->decimal('unit_price', 15, 2)->default(0)->after('quantity') + ->comment('단가'); + $table->decimal('total_price', 15, 2)->default(0)->after('unit_price') + ->comment('금액 (수량×단가)'); + $table->string('item_category', 50)->nullable()->after('total_price') + ->comment('품목 분류: material, labor, install, etc.'); + $table->string('floor_code', 50)->nullable()->after('item_category') + ->comment('위치 코드: B1-A01, 1F-C01'); + }); + + // ai_quote_price_tables: AI 견적용 단가표 (신규) + Schema::create('ai_quote_price_tables', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('product_category', 50)->comment('SCREEN, STEEL'); + $table->string('price_type', 50)->comment('area_based, weight_based, fixed'); + $table->decimal('min_value', 12, 4)->default(0)->comment('면적/중량 최소'); + $table->decimal('max_value', 12, 4)->default(0)->comment('면적/중량 최대'); + $table->decimal('unit_price', 15, 2)->default(0)->comment('해당 구간 단가'); + $table->decimal('labor_rate', 5, 2)->default(0)->comment('노무비율 (%)'); + $table->decimal('install_rate', 5, 2)->default(0)->comment('설치비율 (%)'); + $table->boolean('is_active')->default(true); + $table->json('options')->nullable(); + $table->timestamps(); + + $table->index('tenant_id'); + $table->index(['product_category', 'price_type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('ai_quote_price_tables'); + + Schema::table('ai_quotation_items', function (Blueprint $table) { + $table->dropColumn([ + 'specification', 'unit', 'quantity', 'unit_price', + 'total_price', 'item_category', 'floor_code', + ]); + }); + + Schema::table('ai_quotations', function (Blueprint $table) { + $table->dropIndex(['quote_mode']); + $table->dropIndex(['quote_number']); + $table->dropColumn(['quote_mode', 'quote_number', 'product_category']); + }); + } +}; From 83a774572ad8c69f379cd3032fc4b818b19b20f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Tue, 3 Mar 2026 18:48:15 +0900 Subject: [PATCH 031/166] =?UTF-8?q?feat:=20[today-issue]=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=EA=B8=B0=EB=B0=98=20=EC=9D=B4=EC=A0=84=20=EC=9D=B4?= =?UTF-8?q?=EC=8A=88=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=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 - TodayIssueController에 date 파라미터(YYYY-MM-DD) 추가 - TodayIssueService.summary()에 날짜 기반 필터링 로직 구현 - 이전 이슈 조회 시 만료(active) 필터 무시하여 과거 데이터 조회 가능 Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/TodayIssueController.php | 5 ++-- app/Services/TodayIssueService.php | 24 ++++++++++++++----- package-lock.json | 2 +- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/app/Http/Controllers/Api/V1/TodayIssueController.php b/app/Http/Controllers/Api/V1/TodayIssueController.php index e25eff5..c510953 100644 --- a/app/Http/Controllers/Api/V1/TodayIssueController.php +++ b/app/Http/Controllers/Api/V1/TodayIssueController.php @@ -20,9 +20,10 @@ public function __construct( public function summary(Request $request): JsonResponse { $limit = (int) $request->input('limit', 30); + $date = $request->input('date'); // YYYY-MM-DD (이전 이슈 조회용) - return ApiResponse::handle(function () use ($limit) { - return $this->todayIssueService->summary($limit); + return ApiResponse::handle(function () use ($limit, $date) { + return $this->todayIssueService->summary($limit, null, $date); }, __('message.fetched')); } diff --git a/app/Services/TodayIssueService.php b/app/Services/TodayIssueService.php index 6a801fd..93f5139 100644 --- a/app/Services/TodayIssueService.php +++ b/app/Services/TodayIssueService.php @@ -17,30 +17,42 @@ class TodayIssueService extends Service * * @param int $limit 조회할 최대 항목 수 (기본 30) * @param string|null $badge 뱃지 필터 (null이면 전체) + * @param string|null $date 조회 날짜 (YYYY-MM-DD, null이면 오늘) */ - public function summary(int $limit = 30, ?string $badge = null): array + public function summary(int $limit = 30, ?string $badge = null, ?string $date = null): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); + // date 파라미터가 있으면 해당 날짜, 없으면 오늘 + $targetDate = $date ? Carbon::parse($date) : today(); + $query = TodayIssue::query() ->where('tenant_id', $tenantId) ->forUser($userId) // 본인 대상 또는 전체 브로드캐스트 - ->active() // 만료되지 않은 이슈만 - ->today() // 오늘 날짜 이슈만 + ->whereDate('created_at', $targetDate) ->orderByDesc('created_at'); + // 이전 이슈 조회 시에는 만료 필터 무시 (과거 데이터도 조회 가능) + if (! $date) { + $query->active(); // 오늘 이슈만 만료 필터 적용 + } + // 뱃지 필터 if ($badge !== null && $badge !== 'all') { $query->byBadge($badge); } - // 전체 개수 (필터 적용 전, 오늘 날짜만) + // 전체 개수 (필터 적용 전) $totalQuery = TodayIssue::query() ->where('tenant_id', $tenantId) ->forUser($userId) - ->active() - ->today(); + ->whereDate('created_at', $targetDate); + + if (! $date) { + $totalQuery->active(); + } + $totalCount = $totalQuery->count(); // 결과 조회 diff --git a/package-lock.json b/package-lock.json index 6898565..bb18a55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "api", + "name": "sam-api", "lockfileVersion": 3, "requires": true, "packages": { From b7465becab78ab513aecbb49f299a43057c35b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Tue, 3 Mar 2026 20:02:07 +0900 Subject: [PATCH 032/166] =?UTF-8?q?feat:=20[approval]=20=EA=B2=B0=EC=9E=AC?= =?UTF-8?q?=20=EC=88=98=EC=8B=A0=ED=95=A8=20=EB=82=A0=EC=A7=9C=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=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 - InboxIndexRequest에 start_date/end_date 검증 룰 추가 - ApprovalService.inbox()에 created_at 날짜 범위 필터 구현 Co-Authored-By: Claude Opus 4.6 --- app/Http/Requests/Approval/InboxIndexRequest.php | 2 ++ app/Services/ApprovalService.php | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/app/Http/Requests/Approval/InboxIndexRequest.php b/app/Http/Requests/Approval/InboxIndexRequest.php index b6d7ac0..702bb65 100644 --- a/app/Http/Requests/Approval/InboxIndexRequest.php +++ b/app/Http/Requests/Approval/InboxIndexRequest.php @@ -22,6 +22,8 @@ public function rules(): array 'sort_dir' => 'nullable|string|in:asc,desc', 'per_page' => 'nullable|integer|min:1', 'page' => 'nullable|integer|min:1', + 'start_date' => 'nullable|date_format:Y-m-d', + 'end_date' => 'nullable|date_format:Y-m-d|after_or_equal:start_date', ]; } } diff --git a/app/Services/ApprovalService.php b/app/Services/ApprovalService.php index 7516427..4e4ce2a 100644 --- a/app/Services/ApprovalService.php +++ b/app/Services/ApprovalService.php @@ -446,6 +446,14 @@ public function inbox(array $params): LengthAwarePaginator } } + // 날짜 범위 필터 + if (! empty($params['start_date'])) { + $query->whereDate('created_at', '>=', $params['start_date']); + } + if (! empty($params['end_date'])) { + $query->whereDate('created_at', '<=', $params['end_date']); + } + // 정렬 $sortBy = $params['sort_by'] ?? 'created_at'; $sortDir = $params['sort_dir'] ?? 'desc'; From ad27090bfcd6c57e27d2bb3a27be710c7958c6ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Tue, 3 Mar 2026 21:06:09 +0900 Subject: [PATCH 033/166] =?UTF-8?q?feat:=20[daily-report]=20=EC=9E=90?= =?UTF-8?q?=EA=B8=88=ED=98=84=ED=99=A9=20=EC=B9=B4=EB=93=9C=EC=9A=A9=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 - 미수금 잔액(receivable_balance) 계산 로직 구현 - 미지급금 잔액(payable_balance) 계산 로직 구현 - 당월 예상 지출(monthly_expense_total) 계산 로직 구현 - summary API 응답에 자금현황 3개 필드 포함 Co-Authored-By: Claude Opus 4.6 --- app/Services/DailyReportService.php | 73 +++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/app/Services/DailyReportService.php b/app/Services/DailyReportService.php index fbabb97..5762d17 100644 --- a/app/Services/DailyReportService.php +++ b/app/Services/DailyReportService.php @@ -5,6 +5,9 @@ use App\Models\Tenants\BankAccount; use App\Models\Tenants\Bill; use App\Models\Tenants\Deposit; +use App\Models\Tenants\ExpectedExpense; +use App\Models\Tenants\Purchase; +use App\Models\Tenants\Sale; use App\Models\Tenants\Withdrawal; use Carbon\Carbon; @@ -155,6 +158,11 @@ public function summary(array $params): array : null; $operatingStability = $this->getOperatingStability($operatingMonths); + // 기획서 D1.7 자금현황 카드용 필드 + $receivableBalance = $this->calculateReceivableBalance($tenantId, $date); + $payableBalance = $this->calculatePayableBalance($tenantId); + $monthlyExpenseTotal = $this->calculateMonthlyExpenseTotal($tenantId, $date); + return [ 'date' => $date->format('Y-m-d'), 'day_of_week' => $date->locale('ko')->dayName, @@ -167,9 +175,74 @@ public function summary(array $params): array 'monthly_operating_expense' => $monthlyOperatingExpense, 'operating_months' => $operatingMonths, 'operating_stability' => $operatingStability, + // 자금현황 카드용 + 'receivable_balance' => $receivableBalance, + 'payable_balance' => $payableBalance, + 'monthly_expense_total' => $monthlyExpenseTotal, ]; } + /** + * 미수금 잔액 계산 + * = 전체 매출 - 전체 입금 - 전체 수취어음 (기준일까지) + * ReceivablesService.getTotalCarryForwardBalance() 동일 로직 + */ + private function calculateReceivableBalance(int $tenantId, Carbon $date): float + { + $endDate = $date->format('Y-m-d'); + + $totalSales = Sale::where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->where('sale_date', '<=', $endDate) + ->sum('total_amount'); + + $totalDeposits = Deposit::where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->where('deposit_date', '<=', $endDate) + ->sum('amount'); + + $totalBills = Bill::where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->where('bill_type', 'received') + ->where('issue_date', '<=', $endDate) + ->sum('amount'); + + return (float) ($totalSales - $totalDeposits - $totalBills); + } + + /** + * 미지급금 잔액 계산 + * = 미지급 상태(pending, partial, overdue)인 ExpectedExpense 합계 + */ + private function calculatePayableBalance(int $tenantId): float + { + return (float) ExpectedExpense::where('tenant_id', $tenantId) + ->whereIn('payment_status', ['pending', 'partial', 'overdue']) + ->sum('amount'); + } + + /** + * 당월 예상 지출 합계 계산 + * = 당월 매입(Purchase) + 당월 예상지출(ExpectedExpense) + */ + private function calculateMonthlyExpenseTotal(int $tenantId, Carbon $date): float + { + $startOfMonth = $date->copy()->startOfMonth()->format('Y-m-d'); + $endOfMonth = $date->copy()->endOfMonth()->format('Y-m-d'); + + // 당월 매입 합계 + $purchaseTotal = Purchase::where('tenant_id', $tenantId) + ->whereBetween('purchase_date', [$startOfMonth, $endOfMonth]) + ->sum('total_amount'); + + // 당월 예상 지출 합계 (매입 외: 카드, 어음, 급여, 임대료 등) + $expectedExpenseTotal = ExpectedExpense::where('tenant_id', $tenantId) + ->whereBetween('expected_payment_date', [$startOfMonth, $endOfMonth]) + ->sum('amount'); + + return (float) ($purchaseTotal + $expectedExpenseTotal); + } + /** * 직전 3개월 평균 월 운영비 계산 * From 42443349dcd889a1c7c26ac254e9cb89c7e385c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Tue, 3 Mar 2026 21:53:30 +0900 Subject: [PATCH 034/166] =?UTF-8?q?feat:=20[stock,client,status-board]=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=ED=95=84=ED=84=B0=20=EB=B0=8F=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StockController/StockService: 입출고 이력 기반 날짜 범위 필터 추가 - ClientService: 등록일 기간 필터(start_date/end_date) 추가 - StatusBoardService: 부실채권 현황에 is_active 조건 추가 (목록 페이지 일치) Co-Authored-By: Claude Opus 4.6 --- app/Http/Controllers/Api/V1/StockController.php | 2 ++ app/Services/ClientService.php | 10 ++++++++++ app/Services/StatusBoardService.php | 1 + app/Services/StockService.php | 14 ++++++++++++++ 4 files changed, 27 insertions(+) diff --git a/app/Http/Controllers/Api/V1/StockController.php b/app/Http/Controllers/Api/V1/StockController.php index fe9e03e..d2aebc8 100644 --- a/app/Http/Controllers/Api/V1/StockController.php +++ b/app/Http/Controllers/Api/V1/StockController.php @@ -29,6 +29,8 @@ public function index(Request $request): JsonResponse 'sort_dir', 'per_page', 'page', + 'start_date', + 'end_date', ]); $stocks = $this->service->index($params); diff --git a/app/Services/ClientService.php b/app/Services/ClientService.php index b3af238..197ee8a 100644 --- a/app/Services/ClientService.php +++ b/app/Services/ClientService.php @@ -22,6 +22,8 @@ public function index(array $params) $q = trim((string) ($params['q'] ?? '')); $onlyActive = $params['only_active'] ?? null; $clientType = $params['client_type'] ?? null; + $startDate = $params['start_date'] ?? null; + $endDate = $params['end_date'] ?? null; $query = Client::query()->where('tenant_id', $tenantId); @@ -43,6 +45,14 @@ public function index(array $params) $query->whereIn('client_type', $types); } + // 등록일 기간 필터 + if ($startDate) { + $query->whereDate('created_at', '>=', $startDate); + } + if ($endDate) { + $query->whereDate('created_at', '<=', $endDate); + } + $query->orderBy('client_code')->orderBy('id'); $paginator = $query->paginate($size, ['*'], 'page', $page); diff --git a/app/Services/StatusBoardService.php b/app/Services/StatusBoardService.php index a924e34..b38dca5 100644 --- a/app/Services/StatusBoardService.php +++ b/app/Services/StatusBoardService.php @@ -70,6 +70,7 @@ private function getBadDebtStatus(int $tenantId): array $count = BadDebt::query() ->where('tenant_id', $tenantId) ->where('status', BadDebt::STATUS_COLLECTING) // 추심 진행 중 + ->where('is_active', true) // 활성 채권만 (목록 페이지와 일치) ->count(); return [ diff --git a/app/Services/StockService.php b/app/Services/StockService.php index d980a30..9cd1475 100644 --- a/app/Services/StockService.php +++ b/app/Services/StockService.php @@ -88,6 +88,20 @@ public function index(array $params): LengthAwarePaginator }); } + // 날짜 범위 필터 (해당 기간에 입출고 이력이 있는 품목만) + if (! empty($params['start_date']) || ! empty($params['end_date'])) { + $query->whereHas('stock', function ($stockQuery) use ($params) { + $stockQuery->whereHas('transactions', function ($txQuery) use ($params) { + if (! empty($params['start_date'])) { + $txQuery->whereDate('created_at', '>=', $params['start_date']); + } + if (! empty($params['end_date'])) { + $txQuery->whereDate('created_at', '<=', $params['end_date']); + } + }); + }); + } + // 정렬 $sortBy = $params['sort_by'] ?? 'code'; $sortDir = $params['sort_dir'] ?? 'asc'; From 23c6cf69190cc4e0fed512a884018472c6d82b57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 3 Mar 2026 23:50:04 +0900 Subject: [PATCH 035/166] =?UTF-8?q?feat:=20[hr]=20Leave=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=ED=99=95=EC=9E=A5=20+=20=EA=B2=B0=EC=9E=AC?= =?UTF-8?q?=EC=96=91=EC=8B=9D=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?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Leave 타입 6개 추가: business_trip, remote, field_work, early_leave, late_reason, absent_reason - 그룹 상수 추가: VACATION_TYPES, ATTENDANCE_REQUEST_TYPES, REASON_REPORT_TYPES - FORM_CODE_MAP: 유형 → 결재양식코드 매핑 상수 - ATTENDANCE_STATUS_MAP: 유형 → 근태상태 매핑 상수 - 결재양식 2개 추가: attendance_request(근태신청), reason_report(사유서) --- app/Models/Tenants/Leave.php | 57 ++++++++++++++++++ ...00100_insert_attendance_approval_forms.php | 59 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 database/migrations/2026_03_03_100100_insert_attendance_approval_forms.php diff --git a/app/Models/Tenants/Leave.php b/app/Models/Tenants/Leave.php index eee6d93..604306b 100644 --- a/app/Models/Tenants/Leave.php +++ b/app/Models/Tenants/Leave.php @@ -82,6 +82,18 @@ class Leave extends Model public const TYPE_PARENTAL = 'parental'; // 육아 + public const TYPE_BUSINESS_TRIP = 'business_trip'; // 출장 + + public const TYPE_REMOTE = 'remote'; // 재택근무 + + public const TYPE_FIELD_WORK = 'field_work'; // 외근 + + public const TYPE_EARLY_LEAVE = 'early_leave'; // 조퇴 + + public const TYPE_LATE_REASON = 'late_reason'; // 지각사유서 + + public const TYPE_ABSENT_REASON = 'absent_reason'; // 결근사유서 + public const STATUS_PENDING = 'pending'; public const STATUS_APPROVED = 'approved'; @@ -98,6 +110,45 @@ class Leave extends Model self::TYPE_FAMILY, self::TYPE_MATERNITY, self::TYPE_PARENTAL, + self::TYPE_BUSINESS_TRIP, + self::TYPE_REMOTE, + self::TYPE_FIELD_WORK, + self::TYPE_EARLY_LEAVE, + self::TYPE_LATE_REASON, + self::TYPE_ABSENT_REASON, + ]; + + // 그룹 상수 + public const VACATION_TYPES = [ + self::TYPE_ANNUAL, self::TYPE_HALF_AM, self::TYPE_HALF_PM, + self::TYPE_SICK, self::TYPE_FAMILY, self::TYPE_MATERNITY, self::TYPE_PARENTAL, + ]; + + public const ATTENDANCE_REQUEST_TYPES = [ + self::TYPE_BUSINESS_TRIP, self::TYPE_REMOTE, self::TYPE_FIELD_WORK, self::TYPE_EARLY_LEAVE, + ]; + + public const REASON_REPORT_TYPES = [ + self::TYPE_LATE_REASON, self::TYPE_ABSENT_REASON, + ]; + + // 유형 → 결재양식코드 매핑 + public const FORM_CODE_MAP = [ + 'annual' => 'leave', 'half_am' => 'leave', 'half_pm' => 'leave', + 'sick' => 'leave', 'family' => 'leave', 'maternity' => 'leave', 'parental' => 'leave', + 'business_trip' => 'attendance_request', 'remote' => 'attendance_request', + 'field_work' => 'attendance_request', 'early_leave' => 'attendance_request', + 'late_reason' => 'reason_report', 'absent_reason' => 'reason_report', + ]; + + // 유형 → 근태상태 매핑 (승인 시 Attendance에 반영할 상태) + public const ATTENDANCE_STATUS_MAP = [ + 'annual' => 'vacation', 'half_am' => 'vacation', 'half_pm' => 'vacation', + 'sick' => 'vacation', 'family' => 'vacation', 'maternity' => 'vacation', 'parental' => 'vacation', + 'business_trip' => 'businessTrip', 'remote' => 'remote', 'field_work' => 'fieldWork', + 'early_leave' => null, + 'late_reason' => null, + 'absent_reason' => null, ]; public const STATUSES = [ @@ -253,6 +304,12 @@ public function getLeaveTypeLabelAttribute(): string self::TYPE_FAMILY => '경조사', self::TYPE_MATERNITY => '출산휴가', self::TYPE_PARENTAL => '육아휴직', + self::TYPE_BUSINESS_TRIP => '출장', + self::TYPE_REMOTE => '재택근무', + self::TYPE_FIELD_WORK => '외근', + self::TYPE_EARLY_LEAVE => '조퇴', + self::TYPE_LATE_REASON => '지각사유서', + self::TYPE_ABSENT_REASON => '결근사유서', default => $this->leave_type, }; } diff --git a/database/migrations/2026_03_03_100100_insert_attendance_approval_forms.php b/database/migrations/2026_03_03_100100_insert_attendance_approval_forms.php new file mode 100644 index 0000000..db3cc7f --- /dev/null +++ b/database/migrations/2026_03_03_100100_insert_attendance_approval_forms.php @@ -0,0 +1,59 @@ +insertOrIgnore([ + 'tenant_id' => 1, + 'name' => '근태신청', + 'code' => 'attendance_request', + 'category' => 'request', + 'template' => json_encode([ + 'fields' => [ + ['name' => 'user_name', 'type' => 'text', 'label' => '신청자'], + ['name' => 'request_type', 'type' => 'text', 'label' => '신청유형'], + ['name' => 'period', 'type' => 'text', 'label' => '기간'], + ['name' => 'days', 'type' => 'number', 'label' => '일수'], + ['name' => 'reason', 'type' => 'text', 'label' => '사유'], + ], + ], JSON_UNESCAPED_UNICODE), + 'is_active' => true, + 'created_by' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // 사유서 결재 양식 (지각사유서/결근사유서) + DB::table('approval_forms')->insertOrIgnore([ + 'tenant_id' => 1, + 'name' => '사유서', + 'code' => 'reason_report', + 'category' => 'request', + 'template' => json_encode([ + 'fields' => [ + ['name' => 'user_name', 'type' => 'text', 'label' => '신청자'], + ['name' => 'report_type', 'type' => 'text', 'label' => '사유서유형'], + ['name' => 'target_date', 'type' => 'date', 'label' => '대상일'], + ['name' => 'reason', 'type' => 'text', 'label' => '사유'], + ], + ], JSON_UNESCAPED_UNICODE), + 'is_active' => true, + 'created_by' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function down(): void + { + DB::table('approval_forms') + ->where('tenant_id', 1) + ->whereIn('code', ['attendance_request', 'reason_report']) + ->delete(); + } +}; From e9fd75fa741414acd8c6359be1a7d617aed11413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Mar 2026 08:33:32 +0900 Subject: [PATCH 036/166] =?UTF-8?q?feat:=20[inspection]=20=EC=BA=98?= =?UTF-8?q?=EB=A6=B0=EB=8D=94=20=EC=8A=A4=EC=BC=80=EC=A4=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/inspections/calendar 엔드포인트 추가 - year, month, inspector, status 파라미터 지원 - React 프론트엔드 CalendarItemApi 형식에 맞춰 응답 --- .../Api/V1/InspectionController.php | 10 +++ app/Services/InspectionService.php | 73 +++++++++++++++++++ routes/api/v1/production.php | 1 + 3 files changed, 84 insertions(+) diff --git a/app/Http/Controllers/Api/V1/InspectionController.php b/app/Http/Controllers/Api/V1/InspectionController.php index 2edc728..d543d12 100644 --- a/app/Http/Controllers/Api/V1/InspectionController.php +++ b/app/Http/Controllers/Api/V1/InspectionController.php @@ -34,6 +34,16 @@ public function stats(Request $request) }, __('message.inspection.fetched')); } + /** + * 캘린더 스케줄 조회 + */ + public function calendar(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->calendar($request->all()); + }, __('message.inspection.fetched')); + } + /** * 단건 조회 */ diff --git a/app/Services/InspectionService.php b/app/Services/InspectionService.php index 2964758..3f142c7 100644 --- a/app/Services/InspectionService.php +++ b/app/Services/InspectionService.php @@ -365,6 +365,79 @@ public function complete(int $id, array $data) }); } + /** + * 캘린더 스케줄 조회 + */ + public function calendar(array $params): array + { + $tenantId = $this->tenantId(); + + $year = (int) ($params['year'] ?? now()->year); + $month = (int) ($params['month'] ?? now()->month); + + // 해당 월의 시작일/종료일 + $startDate = sprintf('%04d-%02d-01', $year, $month); + $endDate = date('Y-m-t', strtotime($startDate)); + + $query = Inspection::query() + ->where('tenant_id', $tenantId) + ->where(function ($q) use ($startDate, $endDate) { + $q->whereBetween('request_date', [$startDate, $endDate]) + ->orWhereBetween('inspection_date', [$startDate, $endDate]); + }) + ->with(['inspector:id,name', 'item:id,item_name']); + + // 검사자 필터 + if (! empty($params['inspector'])) { + $query->whereHas('inspector', function ($q) use ($params) { + $q->where('name', $params['inspector']); + }); + } + + // 상태 필터 + if (! empty($params['status'])) { + $status = $params['status'] === 'reception' ? self::mapStatusFromFrontend('reception') : $params['status']; + $query->where('status', $status); + } + + return $query->orderBy('request_date') + ->get() + ->map(fn (Inspection $item) => [ + 'id' => $item->id, + 'start_date' => $item->request_date?->format('Y-m-d'), + 'end_date' => $item->inspection_date?->format('Y-m-d') ?? $item->request_date?->format('Y-m-d'), + 'inspector' => $item->inspector?->name ?? '', + 'site_name' => $item->item?->item_name ?? ($item->meta['process_name'] ?? $item->inspection_no), + 'status' => self::mapStatusToFrontend($item->status), + ]) + ->values() + ->toArray(); + } + + /** + * 상태를 프론트엔드 형식으로 매핑 + */ + private static function mapStatusToFrontend(string $status): string + { + return match ($status) { + Inspection::STATUS_WAITING => 'reception', + Inspection::STATUS_IN_PROGRESS => 'in_progress', + Inspection::STATUS_COMPLETED => 'completed', + default => $status, + }; + } + + /** + * 프론트엔드 상태를 DB 상태로 매핑 + */ + private static function mapStatusFromFrontend(string $status): string + { + return match ($status) { + 'reception' => Inspection::STATUS_WAITING, + default => $status, + }; + } + /** * DB 데이터를 프론트엔드 형식으로 변환 */ diff --git a/routes/api/v1/production.php b/routes/api/v1/production.php index 5fac326..d7600d9 100644 --- a/routes/api/v1/production.php +++ b/routes/api/v1/production.php @@ -114,6 +114,7 @@ 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::get('/calendar', [InspectionController::class, 'calendar'])->name('v1.inspections.calendar'); // 캘린더 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'); // 수정 From 4f3467c3b0fd6efb5190f4d178acc001d7b92737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Mar 2026 09:03:55 +0900 Subject: [PATCH 037/166] =?UTF-8?q?feat:=20[barobill]=20=EB=B0=94=EB=A1=9C?= =?UTF-8?q?=EB=B9=8C=20=EC=97=B0=EB=8F=99=20API=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=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 - GET /api/v1/barobill/status — 연동 현황 조회 - POST /api/v1/barobill/login — 로그인 정보 등록 - POST /api/v1/barobill/signup — 회원가입 정보 등록 - GET /api/v1/barobill/bank-service-url — 은행 서비스 URL - GET /api/v1/barobill/account-link-url — 계좌 연동 URL - GET /api/v1/barobill/card-link-url — 카드 연동 URL - GET /api/v1/barobill/certificate-url — 공인인증서 URL --- .../Controllers/Api/V1/BarobillController.php | 144 ++++++++++++++++++ routes/api/v1/finance.php | 12 ++ 2 files changed, 156 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/BarobillController.php diff --git a/app/Http/Controllers/Api/V1/BarobillController.php b/app/Http/Controllers/Api/V1/BarobillController.php new file mode 100644 index 0000000..9e90b91 --- /dev/null +++ b/app/Http/Controllers/Api/V1/BarobillController.php @@ -0,0 +1,144 @@ +barobillService->getSetting(); + + return [ + 'bank_service_count' => 0, + 'account_link_count' => 0, + 'member' => $setting ? [ + 'barobill_id' => $setting->barobill_id, + 'biz_no' => $setting->corp_num, + 'status' => $setting->isVerified() ? 'active' : 'inactive', + 'server_mode' => config('services.barobill.test_mode', true) ? 'test' : 'production', + ] : null, + ]; + }, __('message.fetched')); + } + + /** + * 바로빌 로그인 정보 등록 + */ + public function login(Request $request) + { + $data = $request->validate([ + 'barobill_id' => 'required|string', + 'password' => 'required|string', + ]); + + return ApiResponse::handle(function () use ($data) { + return $this->barobillService->saveSetting([ + 'barobill_id' => $data['barobill_id'], + ]); + }, __('message.saved')); + } + + /** + * 바로빌 회원가입 정보 등록 + */ + public function signup(Request $request) + { + $data = $request->validate([ + 'business_number' => 'required|string|size:10', + 'company_name' => 'required|string', + 'ceo_name' => 'required|string', + 'business_type' => 'nullable|string', + 'business_category' => 'nullable|string', + 'address' => 'nullable|string', + 'barobill_id' => 'required|string', + 'password' => 'required|string', + 'manager_name' => 'nullable|string', + 'manager_phone' => 'nullable|string', + 'manager_email' => 'nullable|email', + ]); + + return ApiResponse::handle(function () use ($data) { + return $this->barobillService->saveSetting([ + 'corp_num' => $data['business_number'], + 'corp_name' => $data['company_name'], + 'ceo_name' => $data['ceo_name'], + 'biz_type' => $data['business_type'] ?? null, + 'biz_class' => $data['business_category'] ?? null, + 'addr' => $data['address'] ?? null, + 'barobill_id' => $data['barobill_id'], + 'contact_name' => $data['manager_name'] ?? null, + 'contact_tel' => $data['manager_phone'] ?? null, + 'contact_id' => $data['manager_email'] ?? null, + ]); + }, __('message.saved')); + } + + /** + * 은행 빠른조회 서비스 URL 조회 + */ + public function bankServiceUrl(Request $request) + { + return ApiResponse::handle(function () { + $baseUrl = config('services.barobill.test_mode', true) + ? 'https://testws.barobill.co.kr' + : 'https://ws.barobill.co.kr'; + + return ['url' => $baseUrl.'/Bank/BankAccountService']; + }, __('message.fetched')); + } + + /** + * 계좌 연동 등록 URL 조회 + */ + public function accountLinkUrl() + { + return ApiResponse::handle(function () { + $baseUrl = config('services.barobill.test_mode', true) + ? 'https://testws.barobill.co.kr' + : 'https://ws.barobill.co.kr'; + + return ['url' => $baseUrl.'/Bank/AccountLink']; + }, __('message.fetched')); + } + + /** + * 카드 연동 등록 URL 조회 + */ + public function cardLinkUrl() + { + return ApiResponse::handle(function () { + $baseUrl = config('services.barobill.test_mode', true) + ? 'https://testws.barobill.co.kr' + : 'https://ws.barobill.co.kr'; + + return ['url' => $baseUrl.'/Card/CardLink']; + }, __('message.fetched')); + } + + /** + * 공인인증서 등록 URL 조회 + */ + public function certificateUrl() + { + return ApiResponse::handle(function () { + $baseUrl = config('services.barobill.test_mode', true) + ? 'https://testws.barobill.co.kr' + : 'https://ws.barobill.co.kr'; + + return ['url' => $baseUrl.'/Certificate/Register']; + }, __('message.fetched')); + } +} diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index 2d03aca..feb56bf 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -15,6 +15,7 @@ use App\Http\Controllers\Api\V1\BadDebtController; use App\Http\Controllers\Api\V1\BankAccountController; use App\Http\Controllers\Api\V1\BankTransactionController; +use App\Http\Controllers\Api\V1\BarobillController; use App\Http\Controllers\Api\V1\BarobillSettingController; use App\Http\Controllers\Api\V1\BillController; use App\Http\Controllers\Api\V1\CalendarController; @@ -254,6 +255,17 @@ Route::post('/test-connection', [BarobillSettingController::class, 'testConnection'])->name('v1.barobill-settings.test-connection'); }); +// Barobill Integration API (바로빌 연동) +Route::prefix('barobill')->group(function () { + Route::get('/status', [BarobillController::class, 'status'])->name('v1.barobill.status'); + Route::post('/login', [BarobillController::class, 'login'])->name('v1.barobill.login'); + Route::post('/signup', [BarobillController::class, 'signup'])->name('v1.barobill.signup'); + Route::get('/bank-service-url', [BarobillController::class, 'bankServiceUrl'])->name('v1.barobill.bank-service-url'); + Route::get('/account-link-url', [BarobillController::class, 'accountLinkUrl'])->name('v1.barobill.account-link-url'); + Route::get('/card-link-url', [BarobillController::class, 'cardLinkUrl'])->name('v1.barobill.card-link-url'); + Route::get('/certificate-url', [BarobillController::class, 'certificateUrl'])->name('v1.barobill.certificate-url'); +}); + // Tax Invoice API (세금계산서) Route::prefix('tax-invoices')->group(function () { Route::get('', [TaxInvoiceController::class, 'index'])->name('v1.tax-invoices.index'); From 1deeafc4de481130b7738dee991d4e3871818aa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Wed, 4 Mar 2026 10:42:53 +0900 Subject: [PATCH 038/166] =?UTF-8?q?feat:=20[expense,loan]=20=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=83=81=EC=84=B8=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EB=B0=8F=20=EA=B0=80=EC=A7=80=EA=B8=89=EA=B8=88=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=B6=84=EB=A5=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExpectedExpenseController/Service: dashboardDetail에 start_date/end_date/search 파라미터 추가 - Loan 모델: category 상수 및 라벨 정의 (카드/경조사/상품권/접대비) - LoanService: dashboard에 category_breakdown 집계 추가 - 마이그레이션: loans 테이블 category 컬럼 추가 Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/ExpectedExpenseController.php | 7 ++- app/Models/Tenants/Loan.php | 40 +++++++++++++ app/Services/ExpectedExpenseService.php | 60 ++++++++++++------- app/Services/LoanService.php | 56 ++++++++++++++++- ..._04_100000_add_category_to_loans_table.php | 32 ++++++++++ 5 files changed, 167 insertions(+), 28 deletions(-) create mode 100644 database/migrations/2026_03_04_100000_add_category_to_loans_table.php diff --git a/app/Http/Controllers/Api/V1/ExpectedExpenseController.php b/app/Http/Controllers/Api/V1/ExpectedExpenseController.php index 14e6c6c..e51b431 100644 --- a/app/Http/Controllers/Api/V1/ExpectedExpenseController.php +++ b/app/Http/Controllers/Api/V1/ExpectedExpenseController.php @@ -128,13 +128,16 @@ public function summary(Request $request) /** * 대시보드 상세 조회 (CEO 대시보드 당월 예상 지출내역 모달용) * - * @param Request $request transaction_type 쿼리 파라미터 (purchase, card, bill, null=전체) + * @param Request $request transaction_type (purchase, card, bill, null=전체), start_date, end_date, search */ public function dashboardDetail(Request $request) { $transactionType = $request->query('transaction_type'); + $startDate = $request->query('start_date'); + $endDate = $request->query('end_date'); + $search = $request->query('search'); - $data = $this->service->dashboardDetail($transactionType); + $data = $this->service->dashboardDetail($transactionType, $startDate, $endDate, $search); return ApiResponse::success($data, __('message.fetched')); } diff --git a/app/Models/Tenants/Loan.php b/app/Models/Tenants/Loan.php index 542a3ad..7cf7eac 100644 --- a/app/Models/Tenants/Loan.php +++ b/app/Models/Tenants/Loan.php @@ -36,6 +36,37 @@ class Loan extends Model self::STATUS_PARTIAL, ]; + /** + * 카테고리 상수 (D1.7 기획서) + */ + public const CATEGORY_CARD = 'card'; // 카드 + + public const CATEGORY_CONGRATULATORY = 'congratulatory'; // 경조사 + + public const CATEGORY_GIFT_CERTIFICATE = 'gift_certificate'; // 상품권 + + public const CATEGORY_ENTERTAINMENT = 'entertainment'; // 접대비 + + /** + * 카테고리 목록 + */ + public const CATEGORIES = [ + self::CATEGORY_CARD, + self::CATEGORY_CONGRATULATORY, + self::CATEGORY_GIFT_CERTIFICATE, + self::CATEGORY_ENTERTAINMENT, + ]; + + /** + * 카테고리 라벨 매핑 + */ + public const CATEGORY_LABELS = [ + self::CATEGORY_CARD => '카드', + self::CATEGORY_CONGRATULATORY => '경조사', + self::CATEGORY_GIFT_CERTIFICATE => '상품권', + self::CATEGORY_ENTERTAINMENT => '접대비', + ]; + /** * 인정이자율 (연도별) */ @@ -71,6 +102,7 @@ class Loan extends Model 'settlement_date', 'settlement_amount', 'status', + 'category', 'withdrawal_id', 'created_by', 'updated_by', @@ -137,6 +169,14 @@ public function getStatusLabelAttribute(): string }; } + /** + * 카테고리 라벨 + */ + public function getCategoryLabelAttribute(): string + { + return self::CATEGORY_LABELS[$this->category] ?? $this->category ?? '카드'; + } + /** * 미정산 잔액 */ diff --git a/app/Services/ExpectedExpenseService.php b/app/Services/ExpectedExpenseService.php index da717d0..4998ddc 100644 --- a/app/Services/ExpectedExpenseService.php +++ b/app/Services/ExpectedExpenseService.php @@ -304,34 +304,41 @@ public function summary(array $params): array * 대시보드 상세 조회 (CEO 대시보드 당월 예상 지출내역 모달용) * * @param string|null $transactionType 거래유형 필터 (purchase, card, bill, null=전체) - * @return array{ - * summary: array{ - * total_amount: float, - * previous_month_amount: float, - * change_rate: float, - * remaining_balance: float, - * item_count: int - * }, - * monthly_trend: array, - * vendor_distribution: array, - * items: array, - * footer_summary: array - * } + * @param string|null $startDate 조회 시작일 (null이면 당월 1일) + * @param string|null $endDate 조회 종료일 (null이면 당월 말일) + * @param string|null $search 검색어 (거래처명, 적요) */ - public function dashboardDetail(?string $transactionType = null): array - { + public function dashboardDetail( + ?string $transactionType = null, + ?string $startDate = null, + ?string $endDate = null, + ?string $search = null + ): array { $tenantId = $this->tenantId(); - $currentMonthStart = now()->startOfMonth()->toDateString(); - $currentMonthEnd = now()->endOfMonth()->toDateString(); - $previousMonthStart = now()->subMonth()->startOfMonth()->toDateString(); - $previousMonthEnd = now()->subMonth()->endOfMonth()->toDateString(); - // 기본 쿼리 빌더 (transaction_type 필터 적용) - $baseQuery = function () use ($tenantId, $transactionType) { + // 날짜 범위: 파라미터 우선, 없으면 당월 기본값 + $currentMonthStart = $startDate ?? now()->startOfMonth()->toDateString(); + $currentMonthEnd = $endDate ?? now()->endOfMonth()->toDateString(); + + // 전월 대비: 조회 기간과 동일한 길이의 이전 기간 계산 + $startCarbon = \Carbon\Carbon::parse($currentMonthStart); + $endCarbon = \Carbon\Carbon::parse($currentMonthEnd); + $daysDiff = $startCarbon->diffInDays($endCarbon) + 1; + $previousMonthStart = $startCarbon->copy()->subDays($daysDiff)->toDateString(); + $previousMonthEnd = $startCarbon->copy()->subDay()->toDateString(); + + // 기본 쿼리 빌더 (transaction_type + search 필터 적용) + $baseQuery = function () use ($tenantId, $transactionType, $search) { $query = ExpectedExpense::query()->where('tenant_id', $tenantId); if ($transactionType) { $query->where('transaction_type', $transactionType); } + if ($search) { + $query->where(function ($q) use ($search) { + $q->where('client_name', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%"); + }); + } return $query; }; @@ -361,10 +368,10 @@ public function dashboardDetail(?string $transactionType = null): array // 2. 월별 추이 (최근 7개월) $monthlyTrend = $this->getMonthlyTrend($tenantId, $transactionType); - // 3. 거래처별 분포 (당월, 상위 5개) + // 3. 거래처별 분포 (조회 기간, 상위 5개) $vendorDistribution = $this->getVendorDistribution($tenantId, $transactionType, $currentMonthStart, $currentMonthEnd); - // 4. 지출예상 목록 (당월, 지급일 순) + // 4. 지출예상 목록 (조회 기간, 지급일 순) $itemsQuery = ExpectedExpense::query() ->select([ 'expected_expenses.id', @@ -385,6 +392,13 @@ public function dashboardDetail(?string $transactionType = null): array $itemsQuery->where('expected_expenses.transaction_type', $transactionType); } + if ($search) { + $itemsQuery->where(function ($q) use ($search) { + $q->where('expected_expenses.client_name', 'like', "%{$search}%") + ->orWhere('expected_expenses.description', 'like', "%{$search}%"); + }); + } + $items = $itemsQuery ->orderBy('expected_expenses.expected_payment_date', 'asc') ->get() diff --git a/app/Services/LoanService.php b/app/Services/LoanService.php index 036ae41..56b905a 100644 --- a/app/Services/LoanService.php +++ b/app/Services/LoanService.php @@ -365,7 +365,8 @@ public function calculateInterest(int $year, ?int $userId = null): array /** * 가지급금 대시보드 데이터 * - * CEO 대시보드 카드/가지급금 관리 섹션(cm2) 모달용 데이터 제공 + * CEO 대시보드 카드/가지급금 관리 섹션 데이터 제공 + * D1.7: category_breakdown 추가 (카드/경조사/상품권/접대비 분류) * * @return array{ * summary: array{ @@ -373,6 +374,11 @@ public function calculateInterest(int $year, ?int $userId = null): array * recognized_interest: float, * outstanding_count: int * }, + * category_breakdown: array, * loans: array * } */ @@ -388,7 +394,10 @@ public function dashboard(): array $interestData = $this->calculateInterest($currentYear); $recognizedInterest = $interestData['summary']['total_recognized_interest'] ?? 0; - // 3. 가지급금 목록 (최근 10건, 미정산 우선) + // 3. 카테고리별 집계 (D1.7) + $categoryBreakdown = $this->getCategoryBreakdown($tenantId); + + // 4. 가지급금 목록 (최근 10건, 미정산 우선) $loans = Loan::query() ->where('tenant_id', $tenantId) ->with(['user:id,name,email', 'withdrawal']) @@ -404,7 +413,7 @@ public function dashboard(): array 'id' => $loan->id, 'loan_date' => $loan->loan_date->format('Y-m-d'), 'user_name' => $loan->user?->name ?? '미지정', - 'category' => $loan->withdrawal_id ? '카드' : '계좌', + 'category' => $loan->category_label, 'amount' => (float) $loan->amount, 'status' => $loan->status, 'content' => $loan->purpose ?? '', @@ -418,10 +427,51 @@ public function dashboard(): array 'recognized_interest' => (float) $recognizedInterest, 'outstanding_count' => (int) $summaryData['outstanding_count'], ], + 'category_breakdown' => $categoryBreakdown, 'loans' => $loans, ]; } + /** + * 카테고리별 가지급금 집계 + * + * @return array + */ + private function getCategoryBreakdown(int $tenantId): array + { + // 기본값: 4개 카테고리 모두 0으로 초기화 + $breakdown = []; + foreach (Loan::CATEGORIES as $category) { + $breakdown[$category] = [ + 'outstanding_amount' => 0.0, + 'total_count' => 0, + 'unverified_count' => 0, + ]; + } + + // 카테고리별 미정산 집계 + $stats = Loan::query() + ->where('tenant_id', $tenantId) + ->whereIn('status', [Loan::STATUS_OUTSTANDING, Loan::STATUS_PARTIAL]) + ->selectRaw('category, COUNT(*) as total_count, SUM(amount - COALESCE(settlement_amount, 0)) as outstanding_amount') + ->selectRaw('SUM(CASE WHEN purpose IS NULL OR purpose = \'\' THEN 1 ELSE 0 END) as unverified_count') + ->groupBy('category') + ->get(); + + foreach ($stats as $stat) { + $cat = $stat->category ?? Loan::CATEGORY_CARD; + if (isset($breakdown[$cat])) { + $breakdown[$cat] = [ + 'outstanding_amount' => (float) $stat->outstanding_amount, + 'total_count' => (int) $stat->total_count, + 'unverified_count' => (int) $stat->unverified_count, + ]; + } + } + + return $breakdown; + } + /** * 세금 시뮬레이션 데이터 * diff --git a/database/migrations/2026_03_04_100000_add_category_to_loans_table.php b/database/migrations/2026_03_04_100000_add_category_to_loans_table.php new file mode 100644 index 0000000..70c279c --- /dev/null +++ b/database/migrations/2026_03_04_100000_add_category_to_loans_table.php @@ -0,0 +1,32 @@ +string('category', 30) + ->default('card') + ->after('status') + ->comment('카테고리: card, congratulatory, gift_certificate, entertainment'); + + $table->index(['tenant_id', 'category'], 'idx_tenant_category'); + }); + } + + public function down(): void + { + Schema::table('loans', function (Blueprint $table) { + $table->dropIndex('idx_tenant_category'); + $table->dropColumn('category'); + }); + } +}; \ No newline at end of file From da04b84bb5c8823ba28a3bebaf88e368a8cf0964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Mar 2026 11:06:01 +0900 Subject: [PATCH 039/166] =?UTF-8?q?fix:=20[models]=20User=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20import=20=EB=88=84=EB=9D=BD/=EC=98=A4=EB=A5=98=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 - Loan.php: User import 누락 → App\Models\Members\User 추가 - TodayIssue.php: App\Models\Users\User → App\Models\Members\User 수정 - Tenants 네임스페이스에서 User::class가 App\Models\Tenants\User로 잘못 해석되는 문제 해결 --- app/Models/Tenants/Loan.php | 1 + app/Models/Tenants/TodayIssue.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Models/Tenants/Loan.php b/app/Models/Tenants/Loan.php index 7cf7eac..d2a88db 100644 --- a/app/Models/Tenants/Loan.php +++ b/app/Models/Tenants/Loan.php @@ -2,6 +2,7 @@ namespace App\Models\Tenants; +use App\Models\Members\User; use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; diff --git a/app/Models/Tenants/TodayIssue.php b/app/Models/Tenants/TodayIssue.php index 9a208f2..97547d5 100644 --- a/app/Models/Tenants/TodayIssue.php +++ b/app/Models/Tenants/TodayIssue.php @@ -2,7 +2,7 @@ namespace App\Models\Tenants; -use App\Models\Users\User; +use App\Models\Members\User; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Factories\HasFactory; From 76192fc177447c68245c40c97e0a5259521300c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Mar 2026 11:10:01 +0900 Subject: [PATCH 040/166] =?UTF-8?q?fix:=20[cards]=20cards/stats=20?= =?UTF-8?q?=E2=86=92=20card-transactions/dashboard=20=EB=A6=AC=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EB=A0=89=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 --- routes/api/v1/finance.php | 1 + 1 file changed, 1 insertion(+) diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index feb56bf..bc28827 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -51,6 +51,7 @@ 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'); + Route::get('/stats', fn () => redirect()->route('v1.card-transactions.dashboard', request()->query())); }); // BankAccount API (계좌 관리) From 7cf70dbcaa00e407fc63b5c98bb32703a15fa77c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Mar 2026 11:29:08 +0900 Subject: [PATCH 041/166] =?UTF-8?q?fix:=20[address]=20=EC=A3=BC=EC=86=8C?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20255=EC=9E=90=20=E2=86=92=20500=EC=9E=90?= =?UTF-8?q?=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB 마이그레이션: clients, tenants, site_briefings, sites 테이블 address 컬럼 varchar(500) - FormRequest 8개 파일 max:255 → max:500 변경 --- .../Requests/Client/ClientStoreRequest.php | 2 +- .../Requests/Client/ClientUpdateRequest.php | 2 +- .../SiteBriefing/StoreSiteBriefingRequest.php | 2 +- .../UpdateSiteBriefingRequest.php | 2 +- .../Requests/Tenant/TenantStoreRequest.php | 2 +- .../Requests/Tenant/TenantUpdateRequest.php | 2 +- .../Requests/V1/Site/StoreSiteRequest.php | 2 +- .../Requests/V1/Site/UpdateSiteRequest.php | 2 +- ...4_112800_extend_address_columns_to_500.php | 34 +++++++++++++++++++ 9 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 database/migrations/2026_03_04_112800_extend_address_columns_to_500.php diff --git a/app/Http/Requests/Client/ClientStoreRequest.php b/app/Http/Requests/Client/ClientStoreRequest.php index 09aa304..5802183 100644 --- a/app/Http/Requests/Client/ClientStoreRequest.php +++ b/app/Http/Requests/Client/ClientStoreRequest.php @@ -65,7 +65,7 @@ public function rules(): array 'mobile' => 'nullable|string|max:20', 'fax' => 'nullable|string|max:20', 'email' => 'nullable|email|max:100', - 'address' => 'nullable|string|max:255', + 'address' => 'nullable|string|max:500', // 담당자 정보 'manager_name' => 'nullable|string|max:50', 'manager_tel' => 'nullable|string|max:20', diff --git a/app/Http/Requests/Client/ClientUpdateRequest.php b/app/Http/Requests/Client/ClientUpdateRequest.php index 98c90ed..9c7d2a8 100644 --- a/app/Http/Requests/Client/ClientUpdateRequest.php +++ b/app/Http/Requests/Client/ClientUpdateRequest.php @@ -65,7 +65,7 @@ public function rules(): array 'mobile' => 'nullable|string|max:20', 'fax' => 'nullable|string|max:20', 'email' => 'nullable|email|max:100', - 'address' => 'nullable|string|max:255', + 'address' => 'nullable|string|max:500', // 담당자 정보 'manager_name' => 'nullable|string|max:50', 'manager_tel' => 'nullable|string|max:20', diff --git a/app/Http/Requests/SiteBriefing/StoreSiteBriefingRequest.php b/app/Http/Requests/SiteBriefing/StoreSiteBriefingRequest.php index f5985af..a94bf0c 100644 --- a/app/Http/Requests/SiteBriefing/StoreSiteBriefingRequest.php +++ b/app/Http/Requests/SiteBriefing/StoreSiteBriefingRequest.php @@ -29,7 +29,7 @@ public function rules(): array 'briefing_time' => 'nullable|string|max:10', 'briefing_type' => ['nullable', 'string', Rule::in(SiteBriefing::TYPES)], 'location' => 'nullable|string|max:200', - 'address' => 'nullable|string|max:255', + 'address' => 'nullable|string|max:500', // 상태 정보 'status' => ['nullable', 'string', Rule::in(SiteBriefing::STATUSES)], diff --git a/app/Http/Requests/SiteBriefing/UpdateSiteBriefingRequest.php b/app/Http/Requests/SiteBriefing/UpdateSiteBriefingRequest.php index 0e26378..6e6a0a8 100644 --- a/app/Http/Requests/SiteBriefing/UpdateSiteBriefingRequest.php +++ b/app/Http/Requests/SiteBriefing/UpdateSiteBriefingRequest.php @@ -29,7 +29,7 @@ public function rules(): array 'briefing_time' => 'nullable|string|max:10', 'briefing_type' => ['nullable', 'string', Rule::in(SiteBriefing::TYPES)], 'location' => 'nullable|string|max:200', - 'address' => 'nullable|string|max:255', + 'address' => 'nullable|string|max:500', // 상태 정보 'status' => ['nullable', 'string', Rule::in(SiteBriefing::STATUSES)], diff --git a/app/Http/Requests/Tenant/TenantStoreRequest.php b/app/Http/Requests/Tenant/TenantStoreRequest.php index c3a98c4..7fb8a9b 100644 --- a/app/Http/Requests/Tenant/TenantStoreRequest.php +++ b/app/Http/Requests/Tenant/TenantStoreRequest.php @@ -17,7 +17,7 @@ public function rules(): array 'company_name' => 'required|string|max:100', 'email' => 'nullable|email|max:100', 'phone' => 'nullable|string|max:20', - 'address' => 'nullable|string|max:255', + 'address' => 'nullable|string|max:500', 'business_num' => 'nullable|string|max:20', 'ceo_name' => 'nullable|string|max:100', ]; diff --git a/app/Http/Requests/Tenant/TenantUpdateRequest.php b/app/Http/Requests/Tenant/TenantUpdateRequest.php index 2893181..8503165 100644 --- a/app/Http/Requests/Tenant/TenantUpdateRequest.php +++ b/app/Http/Requests/Tenant/TenantUpdateRequest.php @@ -18,7 +18,7 @@ public function rules(): array 'company_name' => 'sometimes|string|max:100', 'email' => 'nullable|email|max:100', 'phone' => 'nullable|string|max:20', - 'address' => 'nullable|string|max:255', + 'address' => 'nullable|string|max:500', 'business_num' => 'nullable|string|max:20', 'ceo_name' => 'nullable|string|max:100', 'logo' => 'nullable|string|max:255', diff --git a/app/Http/Requests/V1/Site/StoreSiteRequest.php b/app/Http/Requests/V1/Site/StoreSiteRequest.php index 2d51326..fd1cd30 100644 --- a/app/Http/Requests/V1/Site/StoreSiteRequest.php +++ b/app/Http/Requests/V1/Site/StoreSiteRequest.php @@ -15,7 +15,7 @@ public function rules(): array { return [ 'name' => ['required', 'string', 'max:100'], - 'address' => ['nullable', 'string', 'max:255'], + 'address' => ['nullable', 'string', 'max:500'], 'latitude' => ['nullable', 'numeric', 'between:-90,90'], 'longitude' => ['nullable', 'numeric', 'between:-180,180'], 'is_active' => ['sometimes', 'boolean'], diff --git a/app/Http/Requests/V1/Site/UpdateSiteRequest.php b/app/Http/Requests/V1/Site/UpdateSiteRequest.php index 91b6196..1501803 100644 --- a/app/Http/Requests/V1/Site/UpdateSiteRequest.php +++ b/app/Http/Requests/V1/Site/UpdateSiteRequest.php @@ -15,7 +15,7 @@ public function rules(): array { return [ 'name' => ['sometimes', 'string', 'max:100'], - 'address' => ['nullable', 'string', 'max:255'], + 'address' => ['nullable', 'string', 'max:500'], 'latitude' => ['nullable', 'numeric', 'between:-90,90'], 'longitude' => ['nullable', 'numeric', 'between:-180,180'], 'is_active' => ['sometimes', 'boolean'], diff --git a/database/migrations/2026_03_04_112800_extend_address_columns_to_500.php b/database/migrations/2026_03_04_112800_extend_address_columns_to_500.php new file mode 100644 index 0000000..9798d85 --- /dev/null +++ b/database/migrations/2026_03_04_112800_extend_address_columns_to_500.php @@ -0,0 +1,34 @@ +string('address', 500)->nullable()->change(); + }); + } + } + } + + public function down(): void + { + $tables = ['clients', 'tenants', 'site_briefings', 'sites']; + + foreach ($tables as $table) { + if (Schema::hasColumn($table, 'address')) { + Schema::table($table, function (Blueprint $t) { + $t->string('address', 255)->nullable()->change(); + }); + } + } + } +}; From e637e3d1f7c378af35b03838a98522c34bb65fd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Wed, 4 Mar 2026 14:21:05 +0900 Subject: [PATCH 042/166] =?UTF-8?q?feat:=20[dashboard]=20D1.7=20=EA=B8=B0?= =?UTF-8?q?=ED=9A=8D=EC=84=9C=20=EA=B8=B0=EB=B0=98=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=20=EA=B0=90=EC=A7=80=ED=98=95=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EntertainmentService: 리스크 감지형 전환 (주말/심야, 기피업종, 고액결제, 증빙미비) - WelfareService: 리스크 감지형 전환 (비과세 한도 초과, 사적 사용 의심, 특정인 편중, 항목별 한도 초과) - ReceivablesService: summary를 cards + check_points 구조로 개선 (누적/당월 미수금, Top3 거래처) - LoanService: getCategoryBreakdown 전체 대상으로 집계 조건 변경 Co-Authored-By: Claude Opus 4.6 --- app/Services/EntertainmentService.php | 337 +++++++++++---------- app/Services/LoanService.php | 3 +- app/Services/ReceivablesService.php | 242 ++++++++++++++- app/Services/WelfareService.php | 413 ++++++++++++++++++-------- 4 files changed, 715 insertions(+), 280 deletions(-) diff --git a/app/Services/EntertainmentService.php b/app/Services/EntertainmentService.php index a701028..773d3cd 100644 --- a/app/Services/EntertainmentService.php +++ b/app/Services/EntertainmentService.php @@ -6,29 +6,35 @@ use Illuminate\Support\Facades\DB; /** - * 접대비 현황 서비스 + * 접대비 현황 서비스 (D1.7 리스크 감지형) * - * CEO 대시보드용 접대비 데이터를 제공합니다. + * CEO 대시보드용 접대비 리스크 데이터를 제공합니다. + * 카드 4개: 주말/심야, 기피업종, 고액결제, 증빙미비 */ class EntertainmentService extends Service { - // 접대비 기본 한도율 (중소기업 기준: 매출의 0.3%) - private const DEFAULT_LIMIT_RATE = 0.003; + // 고액 결제 기준 (1회 50만원 초과) + private const HIGH_AMOUNT_THRESHOLD = 500000; - // 기업 규모별 기본 한도 (연간) - private const COMPANY_TYPE_LIMITS = [ - 'large' => 36000000, // 대기업: 연 3,600만원 - 'medium' => 36000000, // 중견기업: 연 3,600만원 - 'small' => 24000000, // 중소기업: 연 2,400만원 + // 기피업종 MCC 코드 (유흥, 귀금속, 숙박 등) + private const PROHIBITED_MCC_CODES = [ + '5813', // 음주업소 + '7011', // 숙박업 + '5944', // 귀금속 + '7941', // 레저/스포츠 + '7992', // 골프장 + '7273', // 데이트서비스 + '5932', // 골동품 ]; + // 심야 시간대 (22시 ~ 06시) + private const LATE_NIGHT_START = 22; + + private const LATE_NIGHT_END = 6; + /** - * 접대비 현황 요약 조회 + * 접대비 리스크 현황 요약 조회 (D1.7) * - * @param string|null $limitType 기간 타입 (annual|quarterly, 기본: quarterly) - * @param string|null $companyType 기업 유형 (large|medium|small, 기본: medium) - * @param int|null $year 연도 (기본: 현재 연도) - * @param int|null $quarter 분기 (1-4, 기본: 현재 분기) * @return array{cards: array, check_points: array} */ public function getSummary( @@ -40,73 +46,58 @@ public function getSummary( $tenantId = $this->tenantId(); $now = Carbon::now(); - // 기본값 설정 $year = $year ?? $now->year; - $limitType = $limitType ?? 'quarterly'; - $companyType = $companyType ?? 'medium'; $quarter = $quarter ?? $now->quarter; // 기간 범위 계산 if ($limitType === 'annual') { $startDate = Carbon::create($year, 1, 1)->format('Y-m-d'); $endDate = Carbon::create($year, 12, 31)->format('Y-m-d'); - $periodLabel = "{$year}년"; } else { $startDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d'); $endDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d'); - $periodLabel = "{$quarter}사분기"; } - // 연간 시작일 (매출 계산용) - $yearStartDate = Carbon::create($year, 1, 1)->format('Y-m-d'); - $yearEndDate = Carbon::create($year, 12, 31)->format('Y-m-d'); - - // 매출액 조회 (연간) - $annualSales = $this->getAnnualSales($tenantId, $yearStartDate, $yearEndDate); - - // 접대비 한도 계산 - $annualLimit = $this->calculateLimit($annualSales, $companyType); - $periodLimit = $limitType === 'annual' ? $annualLimit : ($annualLimit / 4); - - // 접대비 사용액 조회 - $usedAmount = $this->getUsedAmount($tenantId, $startDate, $endDate); - - // 잔여 한도 - $remainingLimit = max(0, $periodLimit - $usedAmount); + // 리스크 감지 쿼리 + $weekendLateNight = $this->getWeekendLateNightRisk($tenantId, $startDate, $endDate); + $prohibitedBiz = $this->getProhibitedBizTypeRisk($tenantId, $startDate, $endDate); + $highAmount = $this->getHighAmountRisk($tenantId, $startDate, $endDate); + $missingReceipt = $this->getMissingReceiptRisk($tenantId, $startDate, $endDate); // 카드 데이터 구성 $cards = [ [ - 'id' => 'et_sales', - 'label' => '매출', - 'amount' => (int) $annualSales, + 'id' => 'et_weekend', + 'label' => '주말/심야', + 'amount' => (int) $weekendLateNight['total'], + 'subLabel' => "{$weekendLateNight['count']}건", ], [ - 'id' => 'et_limit', - 'label' => "{{$periodLabel}} 접대비 총 한도", - 'amount' => (int) $periodLimit, + 'id' => 'et_prohibited', + 'label' => '기피업종', + 'amount' => (int) $prohibitedBiz['total'], + 'subLabel' => $prohibitedBiz['count'] > 0 ? "불인정 {$prohibitedBiz['count']}건" : '0건', ], [ - 'id' => 'et_remaining', - 'label' => "{{$periodLabel}} 접대비 잔여한도", - 'amount' => (int) $remainingLimit, + 'id' => 'et_high_amount', + 'label' => '고액 결제', + 'amount' => (int) $highAmount['total'], + 'subLabel' => "{$highAmount['count']}건", ], [ - 'id' => 'et_used', - 'label' => "{{$periodLabel}} 접대비 사용금액", - 'amount' => (int) $usedAmount, + 'id' => 'et_no_receipt', + 'label' => '증빙 미비', + 'amount' => (int) $missingReceipt['total'], + 'subLabel' => "{$missingReceipt['count']}건", ], ]; // 체크포인트 생성 - $checkPoints = $this->generateCheckPoints( - $periodLabel, - $periodLimit, - $usedAmount, - $remainingLimit, - $tenantId, - $startDate, - $endDate + $checkPoints = $this->generateRiskCheckPoints( + $weekendLateNight, + $prohibitedBiz, + $highAmount, + $missingReceipt ); return [ @@ -116,65 +107,82 @@ public function getSummary( } /** - * 연간 매출액 조회 + * 주말/심야 사용 리스크 조회 + * expense_date가 주말(토/일) OR barobill join으로 use_time 22~06시 */ - private function getAnnualSales(int $tenantId, string $startDate, string $endDate): float + private function getWeekendLateNightRisk(int $tenantId, string $startDate, string $endDate): array { - // orders 테이블에서 확정된 수주 합계 조회 - $amount = DB::table('orders') - ->where('tenant_id', $tenantId) - ->where('status_code', 'confirmed') - ->whereBetween('received_at', [$startDate, $endDate]) - ->whereNull('deleted_at') - ->sum('total_amount'); - - return $amount ?: 30530000000; // 임시 기본값 (305억) - } - - /** - * 접대비 한도 계산 - */ - private function calculateLimit(float $annualSales, string $companyType): float - { - // 기본 한도 (기업 규모별) - $baseLimit = self::COMPANY_TYPE_LIMITS[$companyType] ?? self::COMPANY_TYPE_LIMITS['medium']; - - // 매출 기반 한도 (0.3%) - $salesBasedLimit = $annualSales * self::DEFAULT_LIMIT_RATE; - - // 기본 한도 + 매출 기반 한도 (실제 세법은 더 복잡하지만 간소화) - return $baseLimit + $salesBasedLimit; - } - - /** - * 접대비 사용액 조회 - */ - private function getUsedAmount(int $tenantId, string $startDate, string $endDate): float - { - // TODO: 실제 접대비 계정과목에서 조회 - // expense_accounts 또는 card_transactions에서 접대비 항목 합계 - $amount = DB::table('expense_accounts') + // 주말 사용 (토요일=7, 일요일=1 in MySQL DAYOFWEEK) + $weekendResult = DB::table('expense_accounts') ->where('tenant_id', $tenantId) ->where('account_type', 'entertainment') ->whereBetween('expense_date', [$startDate, $endDate]) ->whereNull('deleted_at') - ->sum('amount'); + ->whereRaw('DAYOFWEEK(expense_date) IN (1, 7)') + ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') + ->first(); - return $amount ?: 10000000; // 임시 기본값 + // 심야 사용 (barobill 카드 거래 내역에서 시간 확인) + $lateNightResult = DB::table('expense_accounts as ea') + ->leftJoin('barobill_card_transactions as bct', function ($join) { + $join->on('ea.receipt_no', '=', 'bct.approval_no') + ->on('ea.tenant_id', '=', 'bct.tenant_id'); + }) + ->where('ea.tenant_id', $tenantId) + ->where('ea.account_type', 'entertainment') + ->whereBetween('ea.expense_date', [$startDate, $endDate]) + ->whereNull('ea.deleted_at') + ->whereRaw('DAYOFWEEK(ea.expense_date) NOT IN (1, 7)') // 주말 제외 (중복 방지) + ->where(function ($q) { + $q->whereRaw('HOUR(bct.use_time) >= ?', [self::LATE_NIGHT_START]) + ->orWhereRaw('HOUR(bct.use_time) < ?', [self::LATE_NIGHT_END]); + }) + ->selectRaw('COUNT(*) as count, COALESCE(SUM(ea.amount), 0) as total') + ->first(); + + $totalCount = ($weekendResult->count ?? 0) + ($lateNightResult->count ?? 0); + $totalAmount = ($weekendResult->total ?? 0) + ($lateNightResult->total ?? 0); + + return ['count' => $totalCount, 'total' => $totalAmount]; } /** - * 거래처 누락 건수 조회 + * 기피업종 사용 리스크 조회 + * barobill의 merchant_biz_type가 MCC 코드 매칭 */ - private function getMissingVendorCount(int $tenantId, string $startDate, string $endDate): array + private function getProhibitedBizTypeRisk(int $tenantId, string $startDate, string $endDate): array + { + $result = DB::table('expense_accounts as ea') + ->join('barobill_card_transactions as bct', function ($join) { + $join->on('ea.receipt_no', '=', 'bct.approval_no') + ->on('ea.tenant_id', '=', 'bct.tenant_id'); + }) + ->where('ea.tenant_id', $tenantId) + ->where('ea.account_type', 'entertainment') + ->whereBetween('ea.expense_date', [$startDate, $endDate]) + ->whereNull('ea.deleted_at') + ->whereIn('bct.merchant_biz_type', self::PROHIBITED_MCC_CODES) + ->selectRaw('COUNT(*) as count, COALESCE(SUM(ea.amount), 0) as total') + ->first(); + + return [ + 'count' => $result->count ?? 0, + 'total' => $result->total ?? 0, + ]; + } + + /** + * 고액 결제 리스크 조회 + * 1회 50만원 초과 결제 + */ + private function getHighAmountRisk(int $tenantId, string $startDate, string $endDate): array { - // TODO: 거래처 정보 누락 건수 조회 $result = DB::table('expense_accounts') ->where('tenant_id', $tenantId) ->where('account_type', 'entertainment') ->whereBetween('expense_date', [$startDate, $endDate]) - ->whereNull('vendor_id') ->whereNull('deleted_at') + ->where('amount', '>', self::HIGH_AMOUNT_THRESHOLD) ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') ->first(); @@ -185,72 +193,103 @@ private function getMissingVendorCount(int $tenantId, string $startDate, string } /** - * 체크포인트 생성 + * 증빙 미비 리스크 조회 + * receipt_no가 NULL 또는 빈 값 */ - private function generateCheckPoints( - string $periodLabel, - float $limit, - float $used, - float $remaining, - int $tenantId, - string $startDate, - string $endDate + private function getMissingReceiptRisk(int $tenantId, string $startDate, string $endDate): array + { + $result = DB::table('expense_accounts') + ->where('tenant_id', $tenantId) + ->where('account_type', 'entertainment') + ->whereBetween('expense_date', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->where(function ($q) { + $q->whereNull('receipt_no') + ->orWhere('receipt_no', ''); + }) + ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') + ->first(); + + return [ + 'count' => $result->count ?? 0, + 'total' => $result->total ?? 0, + ]; + } + + /** + * 리스크 감지 체크포인트 생성 + */ + private function generateRiskCheckPoints( + array $weekendLateNight, + array $prohibitedBiz, + array $highAmount, + array $missingReceipt ): array { $checkPoints = []; - $usageRate = $limit > 0 ? ($used / $limit) * 100 : 0; - $usedFormatted = number_format($used / 10000); - $limitFormatted = number_format($limit / 10000); - $remainingFormatted = number_format($remaining / 10000); + $totalRiskCount = $weekendLateNight['count'] + $prohibitedBiz['count'] + + $highAmount['count'] + $missingReceipt['count']; - // 사용률에 따른 체크포인트 - if ($usageRate <= 75) { - // 정상 운영 - $remainingRate = round(100 - $usageRate); + // 주말/심야 + if ($weekendLateNight['count'] > 0) { + $amountFormatted = number_format($weekendLateNight['total'] / 10000); $checkPoints[] = [ - 'id' => 'et_cp_normal', - 'type' => 'success', - 'message' => "{$periodLabel} 접대비 사용 {$usedFormatted}만원 / 한도 {$limitFormatted}만원 ({$remainingRate}%). 여유 있게 운영 중입니다.", - 'highlights' => [ - ['text' => "{$usedFormatted}만원", 'color' => 'green'], - ['text' => "{$limitFormatted}만원 ({$remainingRate}%)", 'color' => 'green'], - ], - ]; - } elseif ($usageRate <= 100) { - // 주의 (85% 이상) - $usageRateRounded = round($usageRate); - $checkPoints[] = [ - 'id' => 'et_cp_warning', + 'id' => 'et_cp_weekend', 'type' => 'warning', - 'message' => "접대비 한도 {$usageRateRounded}% 도달. 잔여 한도 {$remainingFormatted}만원입니다. 사용 계획을 점검해 주세요.", + 'message' => "주말/심야 사용 {$weekendLateNight['count']}건({$amountFormatted}만원) 감지. 업무관련성 소명자료로 증빙해주세요.", 'highlights' => [ - ['text' => "잔여 한도 {$remainingFormatted}만원", 'color' => 'orange'], - ], - ]; - } else { - // 한도 초과 - $overAmount = $used - $limit; - $overFormatted = number_format($overAmount / 10000); - $checkPoints[] = [ - 'id' => 'et_cp_over', - 'type' => 'error', - 'message' => "접대비 한도 초과 {$overFormatted}만원 발생. 초과분은 손금불산입되어 법인세 부담이 증가합니다.", - 'highlights' => [ - ['text' => "{$overFormatted}만원 발생", 'color' => 'red'], + ['text' => "{$weekendLateNight['count']}건({$amountFormatted}만원)", 'color' => 'red'], ], ]; } - // 거래처 정보 누락 체크 - $missingVendor = $this->getMissingVendorCount($tenantId, $startDate, $endDate); - if ($missingVendor['count'] > 0) { - $missingTotal = number_format($missingVendor['total'] / 10000); + // 기피업종 + if ($prohibitedBiz['count'] > 0) { + $amountFormatted = number_format($prohibitedBiz['total'] / 10000); $checkPoints[] = [ - 'id' => 'et_cp_missing', + 'id' => 'et_cp_prohibited', 'type' => 'error', - 'message' => "접대비 사용 중 {$missingVendor['count']}건({$missingTotal}만원)의 거래처 정보가 누락되었습니다. 기록을 보완해 주세요.", + 'message' => "기피업종 사용 {$prohibitedBiz['count']}건({$amountFormatted}만원) 감지. 유흥업종 결제는 접대비 불인정 사유입니다.", 'highlights' => [ - ['text' => "{$missingVendor['count']}건({$missingTotal}만원)", 'color' => 'red'], - ['text' => '거래처 정보가 누락', 'color' => 'red'], + ['text' => "{$prohibitedBiz['count']}건({$amountFormatted}만원)", 'color' => 'red'], + ['text' => '접대비 불인정', 'color' => 'red'], + ], + ]; + } + + // 고액 결제 + if ($highAmount['count'] > 0) { + $amountFormatted = number_format($highAmount['total'] / 10000); + $checkPoints[] = [ + 'id' => 'et_cp_high', + 'type' => 'warning', + 'message' => "고액 결제 {$highAmount['count']}건({$amountFormatted}만원) 감지. 1회 50만원 초과 결제입니다. 증빙이 필요합니다.", + 'highlights' => [ + ['text' => "{$highAmount['count']}건({$amountFormatted}만원)", 'color' => 'red'], + ], + ]; + } + + // 증빙 미비 + if ($missingReceipt['count'] > 0) { + $amountFormatted = number_format($missingReceipt['total'] / 10000); + $checkPoints[] = [ + 'id' => 'et_cp_receipt', + 'type' => 'error', + 'message' => "미증빙 {$missingReceipt['count']}건({$amountFormatted}만원) 감지. 증빙이 필요합니다.", + 'highlights' => [ + ['text' => "{$missingReceipt['count']}건({$amountFormatted}만원)", 'color' => 'red'], + ], + ]; + } + + // 리스크 0건이면 정상 메시지 + if ($totalRiskCount === 0) { + $checkPoints[] = [ + 'id' => 'et_cp_normal', + 'type' => 'success', + 'message' => '접대비 사용 현황이 정상입니다.', + 'highlights' => [ + ['text' => '정상', 'color' => 'green'], ], ]; } diff --git a/app/Services/LoanService.php b/app/Services/LoanService.php index 56b905a..69d6fe5 100644 --- a/app/Services/LoanService.php +++ b/app/Services/LoanService.php @@ -449,10 +449,9 @@ private function getCategoryBreakdown(int $tenantId): array ]; } - // 카테고리별 미정산 집계 + // 카테고리별 집계 (summary와 동일하게 전체 대상) $stats = Loan::query() ->where('tenant_id', $tenantId) - ->whereIn('status', [Loan::STATUS_OUTSTANDING, Loan::STATUS_PARTIAL]) ->selectRaw('category, COUNT(*) as total_count, SUM(amount - COALESCE(settlement_amount, 0)) as outstanding_amount') ->selectRaw('SUM(CASE WHEN purpose IS NULL OR purpose = \'\' THEN 1 ELSE 0 END) as unverified_count') ->groupBy('category') diff --git a/app/Services/ReceivablesService.php b/app/Services/ReceivablesService.php index 3c7b2c9..9eaf56d 100644 --- a/app/Services/ReceivablesService.php +++ b/app/Services/ReceivablesService.php @@ -117,11 +117,14 @@ public function index(array $params): array } /** - * 요약 통계 조회 + * 요약 통계 조회 (D1.7 cards + check_points 구조) + * + * @return array{cards: array, check_points: array} */ public function summary(array $params): array { $tenantId = $this->tenantId(); + $now = Carbon::now(); $recentYear = $params['recent_year'] ?? false; $year = $params['year'] ?? date('Y'); @@ -137,19 +140,19 @@ public function summary(array $params): array $totalCarryForward = $this->getTotalCarryForwardBalance($tenantId, $carryForwardDate); // 기간 내 총 매출 - $totalSales = Sale::where('tenant_id', $tenantId) + $totalSales = (float) Sale::where('tenant_id', $tenantId) ->whereNotNull('client_id') ->whereBetween('sale_date', [$startDate, $endDate]) ->sum('total_amount'); // 기간 내 총 입금 - $totalDeposits = Deposit::where('tenant_id', $tenantId) + $totalDeposits = (float) Deposit::where('tenant_id', $tenantId) ->whereNotNull('client_id') ->whereBetween('deposit_date', [$startDate, $endDate]) ->sum('amount'); // 기간 내 총 어음 - $totalBills = Bill::where('tenant_id', $tenantId) + $totalBills = (float) Bill::where('tenant_id', $tenantId) ->whereNotNull('client_id') ->where('bill_type', 'received') ->whereBetween('issue_date', [$startDate, $endDate]) @@ -158,26 +161,239 @@ public function summary(array $params): array // 총 미수금 (이월잔액 + 매출 - 입금 - 어음) $totalReceivables = $totalCarryForward + $totalSales - $totalDeposits - $totalBills; + // 당월 미수금 + $currentMonthStart = $now->copy()->startOfMonth()->format('Y-m-d'); + $currentMonthEnd = $now->copy()->endOfMonth()->format('Y-m-d'); + + $currentMonthSales = (float) Sale::where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->whereBetween('sale_date', [$currentMonthStart, $currentMonthEnd]) + ->sum('total_amount'); + + $currentMonthDeposits = (float) Deposit::where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->whereBetween('deposit_date', [$currentMonthStart, $currentMonthEnd]) + ->sum('amount'); + + $currentMonthReceivables = $currentMonthSales - $currentMonthDeposits; + // 거래처 수 $vendorCount = Client::where('tenant_id', $tenantId) ->where('is_active', true) ->count(); - // 연체 거래처 수 (미수금이 양수인 거래처) + // 연체 거래처 수 $overdueVendorCount = Client::where('tenant_id', $tenantId) ->where('is_active', true) ->where('is_overdue', true) ->count(); - return [ - 'total_carry_forward' => (float) $totalCarryForward, - 'total_sales' => (float) $totalSales, - 'total_deposits' => (float) $totalDeposits, - 'total_bills' => (float) $totalBills, - 'total_receivables' => (float) $totalReceivables, - 'vendor_count' => $vendorCount, - 'overdue_vendor_count' => $overdueVendorCount, + // 악성채권 건수 + $badDebtCount = $this->getBadDebtCount($tenantId); + + // Top 3 미수금 거래처 + $topVendors = $this->getTopReceivableVendors($tenantId, 3); + + // 카드 데이터 구성 + $cards = [ + [ + 'id' => 'rv_cumulative', + 'label' => '누적 미수금', + 'amount' => (int) $totalReceivables, + 'sub_items' => [ + ['label' => '매출', 'value' => (int) $totalSales], + ['label' => '입금', 'value' => (int) $totalDeposits], + ], + ], + [ + 'id' => 'rv_monthly', + 'label' => '당월 미수금', + 'amount' => (int) $currentMonthReceivables, + 'sub_items' => [ + ['label' => '매출', 'value' => (int) $currentMonthSales], + ['label' => '입금', 'value' => (int) $currentMonthDeposits], + ], + ], + [ + 'id' => 'rv_vendors', + 'label' => '미수금 거래처', + 'amount' => $vendorCount, + 'unit' => '건', + 'subLabel' => "연체 {$overdueVendorCount}건" . ($badDebtCount > 0 ? " · 악성채권 {$badDebtCount}건" : ''), + ], + [ + 'id' => 'rv_top3', + 'label' => '미수금 Top 3', + 'amount' => ! empty($topVendors) ? (int) $topVendors[0]['amount'] : 0, + 'top_items' => $topVendors, + ], ]; + + // 체크포인트 생성 + $checkPoints = $this->generateSummaryCheckPoints( + $tenantId, + $totalReceivables, + $overdueVendorCount, + $topVendors, + $vendorCount + ); + + return [ + 'cards' => $cards, + 'check_points' => $checkPoints, + ]; + } + + /** + * 악성채권 건수 조회 + */ + private function getBadDebtCount(int $tenantId): int + { + // bad_debts 테이블이 존재하면 사용, 없으면 0 + try { + return \DB::table('bad_debts') + ->where('tenant_id', $tenantId) + ->whereIn('status', ['collecting', 'legal_action']) + ->whereNull('deleted_at') + ->count(); + } catch (\Exception $e) { + return 0; + } + } + + /** + * 미수금 Top N 거래처 조회 + */ + private function getTopReceivableVendors(int $tenantId, int $limit = 3): array + { + $salesSub = \DB::table('sales') + ->select('client_id', \DB::raw('SUM(total_amount) as total')) + ->where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->groupBy('client_id'); + + $depositsSub = \DB::table('deposits') + ->select('client_id', \DB::raw('SUM(amount) as total')) + ->where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->groupBy('client_id'); + + $billsSub = \DB::table('bills') + ->select('client_id', \DB::raw('SUM(amount) as total')) + ->where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->where('bill_type', 'received') + ->groupBy('client_id'); + + $results = \DB::table('clients as c') + ->leftJoinSub($salesSub, 's', 'c.id', '=', 's.client_id') + ->leftJoinSub($depositsSub, 'd', 'c.id', '=', 'd.client_id') + ->leftJoinSub($billsSub, 'b', 'c.id', '=', 'b.client_id') + ->select( + 'c.name', + \DB::raw('(COALESCE(s.total, 0) - COALESCE(d.total, 0) - COALESCE(b.total, 0)) as receivable') + ) + ->where('c.tenant_id', $tenantId) + ->where('c.is_active', true) + ->having('receivable', '>', 0) + ->orderByDesc('receivable') + ->limit($limit) + ->get(); + + return $results->map(fn ($v) => [ + 'name' => $v->name, + 'amount' => (int) $v->receivable, + ])->toArray(); + } + + /** + * 대시보드 요약 체크포인트 생성 + */ + private function generateSummaryCheckPoints( + int $tenantId, + float $totalReceivables, + int $overdueVendorCount, + array $topVendors, + int $vendorCount + ): array { + $checkPoints = []; + + // 연체 거래처 경고 + if ($overdueVendorCount > 0) { + $checkPoints[] = [ + 'id' => 'rv_cp_overdue', + 'type' => 'warning', + 'message' => "연체 거래처 {$overdueVendorCount}곳. 회수 조치가 필요합니다.", + 'highlights' => [ + ['text' => "연체 거래처 {$overdueVendorCount}곳", 'color' => 'red'], + ], + ]; + } + + // 90일 이상 장기 미수금 체크 + $longTermCount = $this->getLongTermReceivableCount($tenantId, 90); + if ($longTermCount > 0) { + $checkPoints[] = [ + 'id' => 'rv_cp_longterm', + 'type' => 'error', + 'message' => "90일 이상 장기 미수금 {$longTermCount}건 감지. 악성채권 전환 위험이 있습니다.", + 'highlights' => [ + ['text' => "90일 이상 장기 미수금 {$longTermCount}건", 'color' => 'red'], + ], + ]; + } + + // Top1 거래처 집중도 경고 + if (! empty($topVendors) && $totalReceivables > 0) { + $top1Ratio = round(($topVendors[0]['amount'] / $totalReceivables) * 100); + if ($top1Ratio >= 50) { + $checkPoints[] = [ + 'id' => 'rv_cp_concentration', + 'type' => 'warning', + 'message' => "{$topVendors[0]['name']} 미수금이 전체의 {$top1Ratio}%를 차지합니다. 리스크 분산이 필요합니다.", + 'highlights' => [ + ['text' => "{$topVendors[0]['name']}", 'color' => 'orange'], + ['text' => "전체의 {$top1Ratio}%", 'color' => 'orange'], + ], + ]; + } + } + + // 정상 상태 메시지 + if (empty($checkPoints)) { + $totalFormatted = number_format($totalReceivables / 10000); + $checkPoints[] = [ + 'id' => 'rv_cp_normal', + 'type' => 'success', + 'message' => "총 미수금 {$totalFormatted}만원. 정상적으로 관리되고 있습니다.", + 'highlights' => [ + ['text' => "{$totalFormatted}만원", 'color' => 'green'], + ], + ]; + } + + return $checkPoints; + } + + /** + * N일 이상 장기 미수금 거래처 수 조회 + */ + private function getLongTermReceivableCount(int $tenantId, int $days): int + { + $cutoffDate = Carbon::now()->subDays($days)->format('Y-m-d'); + + // 연체 상태이면서 오래된 매출이 있는 거래처 수 + $clientIds = Sale::where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->where('sale_date', '<=', $cutoffDate) + ->distinct() + ->pluck('client_id'); + + return Client::where('tenant_id', $tenantId) + ->where('is_active', true) + ->where('is_overdue', true) + ->whereIn('id', $clientIds) + ->count(); } /** diff --git a/app/Services/WelfareService.php b/app/Services/WelfareService.php index c17aa54..7cad512 100644 --- a/app/Services/WelfareService.php +++ b/app/Services/WelfareService.php @@ -6,9 +6,10 @@ use Illuminate\Support\Facades\DB; /** - * 복리후생비 현황 서비스 + * 복리후생비 현황 서비스 (D1.7 리스크 감지형) * - * CEO 대시보드용 복리후생비 데이터를 제공합니다. + * CEO 대시보드용 복리후생비 리스크 데이터를 제공합니다. + * 카드 4개: 비과세 한도 초과, 사적 사용 의심, 특정인 편중, 항목별 한도 초과 */ class WelfareService extends Service { @@ -20,15 +21,22 @@ class WelfareService extends Service private const INDUSTRY_AVG_MAX = 250000; + // 특정인 편중 기준 (전체 대비 5% 초과) + private const CONCENTRATION_THRESHOLD = 0.05; + + // 항목별 1인당 월 기준 금액 + private const SUB_TYPE_LIMITS = [ + 'meal' => 200000, // 식대 20만원 + 'transportation' => 100000, // 교통비 10만원 + 'congratulation' => 50000, // 경조사 5만원 + 'health_check' => 30000, // 건강검진 3만원 + 'education' => 80000, // 교육비 8만원 + 'welfare_point' => 100000, // 복지포인트 10만원 + ]; + /** - * 복리후생비 현황 요약 조회 + * 복리후생비 리스크 현황 요약 조회 (D1.7) * - * @param string|null $limitType 기간 타입 (annual|quarterly, 기본: quarterly) - * @param string|null $calculationType 계산 방식 (fixed|ratio, 기본: fixed) - * @param int|null $fixedAmountPerMonth 1인당 월 정액 (기본: 200000) - * @param float|null $ratio 급여 대비 비율 (기본: 0.05) - * @param int|null $year 연도 (기본: 현재 연도) - * @param int|null $quarter 분기 (1-4, 기본: 현재 분기) * @return array{cards: array, check_points: array} */ public function getSummary( @@ -42,79 +50,68 @@ public function getSummary( $tenantId = $this->tenantId(); $now = Carbon::now(); - // 기본값 설정 $year = $year ?? $now->year; - $limitType = $limitType ?? 'quarterly'; - $calculationType = $calculationType ?? 'fixed'; - $fixedAmountPerMonth = $fixedAmountPerMonth ?? 200000; - $ratio = $ratio ?? 0.05; $quarter = $quarter ?? $now->quarter; // 기간 범위 계산 if ($limitType === 'annual') { $startDate = Carbon::create($year, 1, 1)->format('Y-m-d'); $endDate = Carbon::create($year, 12, 31)->format('Y-m-d'); - $periodLabel = "{$year}년"; $monthCount = 12; } else { $startDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d'); $endDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d'); - $periodLabel = "{$quarter}사분기"; $monthCount = 3; } // 직원 수 조회 $employeeCount = $this->getEmployeeCount($tenantId); - // 한도 계산 - if ($calculationType === 'fixed') { - $annualLimit = $fixedAmountPerMonth * 12 * $employeeCount; - } else { - // 급여 총액 기반 비율 계산 - $totalSalary = $this->getTotalSalary($tenantId, $year); - $annualLimit = $totalSalary * $ratio; - } - - $periodLimit = $limitType === 'annual' ? $annualLimit : ($annualLimit / 4); - - // 복리후생비 사용액 조회 - $usedAmount = $this->getUsedAmount($tenantId, $startDate, $endDate); - - // 잔여 한도 - $remainingLimit = max(0, $periodLimit - $usedAmount); + // 리스크 감지 쿼리 + $taxFreeExcess = $this->getTaxFreeExcessRisk($tenantId, $startDate, $endDate, $employeeCount, $monthCount); + $privateUse = $this->getPrivateUseRisk($tenantId, $startDate, $endDate); + $concentration = $this->getConcentrationRisk($tenantId, $startDate, $endDate); + $categoryExcess = $this->getCategoryExcessRisk($tenantId, $startDate, $endDate, $employeeCount, $monthCount); // 카드 데이터 구성 $cards = [ [ - 'id' => 'wf_annual_limit', - 'label' => '당해년도 복리후생비 한도', - 'amount' => (int) $annualLimit, + 'id' => 'wf_tax_excess', + 'label' => '비과세 한도 초과', + 'amount' => (int) $taxFreeExcess['total'], + 'subLabel' => "{$taxFreeExcess['count']}건", ], [ - 'id' => 'wf_period_limit', - 'label' => "{{$periodLabel}} 복리후생비 총 한도", - 'amount' => (int) $periodLimit, + 'id' => 'wf_private_use', + 'label' => '사적 사용 의심', + 'amount' => (int) $privateUse['total'], + 'subLabel' => "{$privateUse['count']}건", ], [ - 'id' => 'wf_remaining', - 'label' => "{{$periodLabel}} 복리후생비 잔여한도", - 'amount' => (int) $remainingLimit, + 'id' => 'wf_concentration', + 'label' => '특정인 편중', + 'amount' => (int) $concentration['total'], + 'subLabel' => "{$concentration['count']}건", ], [ - 'id' => 'wf_used', - 'label' => "{{$periodLabel}} 복리후생비 사용금액", - 'amount' => (int) $usedAmount, + 'id' => 'wf_category_excess', + 'label' => '항목별 한도 초과', + 'amount' => (int) $categoryExcess['total'], + 'subLabel' => "{$categoryExcess['count']}건", ], ]; // 체크포인트 생성 - $checkPoints = $this->generateCheckPoints( + $checkPoints = $this->generateRiskCheckPoints( $tenantId, $employeeCount, - $usedAmount, $monthCount, $startDate, - $endDate + $endDate, + $taxFreeExcess, + $privateUse, + $concentration, + $categoryExcess ); return [ @@ -123,6 +120,259 @@ public function getSummary( ]; } + /** + * 비과세 한도 초과 리스크 조회 + * sub_type='meal' 1인당 월 > 200,000원 + */ + private function getTaxFreeExcessRisk(int $tenantId, string $startDate, string $endDate, int $employeeCount, int $monthCount): array + { + if ($employeeCount <= 0) { + return ['count' => 0, 'total' => 0]; + } + + // 식대 총액 조회 + $mealTotal = DB::table('expense_accounts') + ->where('tenant_id', $tenantId) + ->where('account_type', 'welfare') + ->where('sub_type', 'meal') + ->whereBetween('expense_date', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->sum('amount'); + + $perPersonMonthly = $mealTotal / $employeeCount / max(1, $monthCount); + $excessAmount = max(0, $perPersonMonthly - self::TAX_FREE_MEAL_LIMIT) * $employeeCount * $monthCount; + + if ($excessAmount > 0) { + // 초과 건수 (식대 건수 기준) + $count = DB::table('expense_accounts') + ->where('tenant_id', $tenantId) + ->where('account_type', 'welfare') + ->where('sub_type', 'meal') + ->whereBetween('expense_date', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->count(); + + return ['count' => $count, 'total' => (int) $excessAmount]; + } + + return ['count' => 0, 'total' => 0]; + } + + /** + * 사적 사용 의심 리스크 조회 + * 주말/심야 사용 (접대비와 동일 로직, account_type='welfare') + */ + private function getPrivateUseRisk(int $tenantId, string $startDate, string $endDate): array + { + // 주말 사용 + $weekendResult = DB::table('expense_accounts') + ->where('tenant_id', $tenantId) + ->where('account_type', 'welfare') + ->whereBetween('expense_date', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->whereRaw('DAYOFWEEK(expense_date) IN (1, 7)') + ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') + ->first(); + + // 심야 사용 (barobill 조인) + $lateNightResult = DB::table('expense_accounts as ea') + ->leftJoin('barobill_card_transactions as bct', function ($join) { + $join->on('ea.receipt_no', '=', 'bct.approval_no') + ->on('ea.tenant_id', '=', 'bct.tenant_id'); + }) + ->where('ea.tenant_id', $tenantId) + ->where('ea.account_type', 'welfare') + ->whereBetween('ea.expense_date', [$startDate, $endDate]) + ->whereNull('ea.deleted_at') + ->whereRaw('DAYOFWEEK(ea.expense_date) NOT IN (1, 7)') + ->where(function ($q) { + $q->whereRaw('HOUR(bct.use_time) >= 22') + ->orWhereRaw('HOUR(bct.use_time) < 6'); + }) + ->selectRaw('COUNT(*) as count, COALESCE(SUM(ea.amount), 0) as total') + ->first(); + + return [ + 'count' => ($weekendResult->count ?? 0) + ($lateNightResult->count ?? 0), + 'total' => ($weekendResult->total ?? 0) + ($lateNightResult->total ?? 0), + ]; + } + + /** + * 특정인 편중 리스크 조회 + * 1인 사용비율 > 전체의 5% + */ + private function getConcentrationRisk(int $tenantId, string $startDate, string $endDate): array + { + // 전체 복리후생비 사용액 + $totalAmount = DB::table('expense_accounts') + ->where('tenant_id', $tenantId) + ->where('account_type', 'welfare') + ->whereBetween('expense_date', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->sum('amount'); + + if ($totalAmount <= 0) { + return ['count' => 0, 'total' => 0]; + } + + $threshold = $totalAmount * self::CONCENTRATION_THRESHOLD; + + // 사용자별 사용액 조회 (편중된 사용자) + $concentrated = DB::table('expense_accounts') + ->where('tenant_id', $tenantId) + ->where('account_type', 'welfare') + ->whereBetween('expense_date', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->groupBy('created_by') + ->havingRaw('SUM(amount) > ?', [$threshold]) + ->selectRaw('COUNT(*) as count, SUM(amount) as total') + ->get(); + + $totalConcentrated = $concentrated->sum('total'); + $userCount = $concentrated->count(); + + return ['count' => $userCount, 'total' => (int) $totalConcentrated]; + } + + /** + * 항목별 한도 초과 리스크 조회 + * 각 sub_type별 1인당 월 기준금액 초과 + */ + private function getCategoryExcessRisk(int $tenantId, string $startDate, string $endDate, int $employeeCount, int $monthCount): array + { + if ($employeeCount <= 0) { + return ['count' => 0, 'total' => 0]; + } + + $totalExcess = 0; + $excessCount = 0; + + foreach (self::SUB_TYPE_LIMITS as $subType => $monthlyLimit) { + $amount = DB::table('expense_accounts') + ->where('tenant_id', $tenantId) + ->where('account_type', 'welfare') + ->where('sub_type', $subType) + ->whereBetween('expense_date', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->sum('amount'); + + $perPersonMonthly = $amount / $employeeCount / max(1, $monthCount); + if ($perPersonMonthly > $monthlyLimit) { + $excess = ($perPersonMonthly - $monthlyLimit) * $employeeCount * $monthCount; + $totalExcess += $excess; + $excessCount++; + } + } + + return ['count' => $excessCount, 'total' => (int) $totalExcess]; + } + + /** + * 리스크 감지 체크포인트 생성 + */ + private function generateRiskCheckPoints( + int $tenantId, + int $employeeCount, + int $monthCount, + string $startDate, + string $endDate, + array $taxFreeExcess, + array $privateUse, + array $concentration, + array $categoryExcess + ): array { + $checkPoints = []; + + // 1인당 월 복리후생비 계산 (업계 평균 비교) + $usedAmount = $this->getUsedAmount($tenantId, $startDate, $endDate); + $perPersonMonthly = $employeeCount > 0 && $monthCount > 0 + ? $usedAmount / $employeeCount / $monthCount + : 0; + $perPersonFormatted = number_format($perPersonMonthly / 10000); + + if ($perPersonMonthly >= self::INDUSTRY_AVG_MIN && $perPersonMonthly <= self::INDUSTRY_AVG_MAX) { + $checkPoints[] = [ + 'id' => 'wf_cp_avg', + 'type' => 'success', + 'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 내 정상 운영 중입니다.", + 'highlights' => [ + ['text' => "{$perPersonFormatted}만원", 'color' => 'green'], + ], + ]; + } elseif ($perPersonMonthly > self::INDUSTRY_AVG_MAX) { + $checkPoints[] = [ + 'id' => 'wf_cp_avg_high', + 'type' => 'warning', + 'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 대비 높습니다.", + 'highlights' => [ + ['text' => "{$perPersonFormatted}만원", 'color' => 'orange'], + ], + ]; + } + + // 식대 비과세 한도 체크 + $mealAmount = $this->getMonthlyMealAmount($tenantId, $startDate, $endDate); + $perPersonMeal = $employeeCount > 0 && $monthCount > 0 + ? $mealAmount / $employeeCount / $monthCount + : 0; + + if ($perPersonMeal > self::TAX_FREE_MEAL_LIMIT) { + $mealFormatted = number_format($perPersonMeal / 10000); + $limitFormatted = number_format(self::TAX_FREE_MEAL_LIMIT / 10000); + $checkPoints[] = [ + 'id' => 'wf_cp_meal', + 'type' => 'error', + 'message' => "식대가 월 {$mealFormatted}만원으로 비과세 한도({$limitFormatted}만원)를 초과했습니다. 초과분은 근로소득 과세됩니다.", + 'highlights' => [ + ['text' => "월 {$mealFormatted}만원", 'color' => 'red'], + ['text' => '초과', 'color' => 'red'], + ], + ]; + } + + // 사적 사용 의심 + if ($privateUse['count'] > 0) { + $amountFormatted = number_format($privateUse['total'] / 10000); + $checkPoints[] = [ + 'id' => 'wf_cp_private', + 'type' => 'warning', + 'message' => "주말/심야 사용 {$privateUse['count']}건({$amountFormatted}만원) 감지. 사적 사용 여부를 확인해주세요.", + 'highlights' => [ + ['text' => "{$privateUse['count']}건({$amountFormatted}만원)", 'color' => 'red'], + ], + ]; + } + + // 특정인 편중 + if ($concentration['count'] > 0) { + $amountFormatted = number_format($concentration['total'] / 10000); + $checkPoints[] = [ + 'id' => 'wf_cp_concentration', + 'type' => 'warning', + 'message' => "특정인 편중 {$concentration['count']}명({$amountFormatted}만원). 전체의 5% 초과 사용자가 있습니다.", + 'highlights' => [ + ['text' => "{$concentration['count']}명({$amountFormatted}만원)", 'color' => 'orange'], + ], + ]; + } + + // 리스크 0건이면 정상 + $totalRisk = $taxFreeExcess['count'] + $privateUse['count'] + $concentration['count'] + $categoryExcess['count']; + if ($totalRisk === 0 && empty($checkPoints)) { + $checkPoints[] = [ + 'id' => 'wf_cp_normal', + 'type' => 'success', + 'message' => '복리후생비 사용 현황이 정상입니다.', + 'highlights' => [ + ['text' => '정상', 'color' => 'green'], + ], + ]; + } + + return $checkPoints; + } + /** * 직원 수 조회 (급여 대상 직원 기준) * @@ -506,73 +756,4 @@ private function getQuarterlyStatus(int $tenantId, int $year, float $quarterlyLi return $result; } - /** - * 체크포인트 생성 - */ - private function generateCheckPoints( - int $tenantId, - int $employeeCount, - float $usedAmount, - int $monthCount, - string $startDate, - string $endDate - ): array { - $checkPoints = []; - - // 1인당 월 복리후생비 계산 - $perPersonMonthly = $employeeCount > 0 && $monthCount > 0 - ? $usedAmount / $employeeCount / $monthCount - : 0; - $perPersonFormatted = number_format($perPersonMonthly / 10000); - - // 업계 평균 비교 - if ($perPersonMonthly >= self::INDUSTRY_AVG_MIN && $perPersonMonthly <= self::INDUSTRY_AVG_MAX) { - $checkPoints[] = [ - 'id' => 'wf_cp_normal', - 'type' => 'success', - 'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 내 정상 운영 중입니다.", - 'highlights' => [ - ['text' => "1인당 월 복리후생비 {$perPersonFormatted}만원", 'color' => 'green'], - ], - ]; - } elseif ($perPersonMonthly < self::INDUSTRY_AVG_MIN) { - $checkPoints[] = [ - 'id' => 'wf_cp_low', - 'type' => 'warning', - 'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 대비 낮습니다.", - 'highlights' => [ - ['text' => "1인당 월 복리후생비 {$perPersonFormatted}만원", 'color' => 'orange'], - ], - ]; - } else { - $checkPoints[] = [ - 'id' => 'wf_cp_high', - 'type' => 'warning', - 'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 대비 높습니다.", - 'highlights' => [ - ['text' => "1인당 월 복리후생비 {$perPersonFormatted}만원", 'color' => 'orange'], - ], - ]; - } - - // 식대 비과세 한도 체크 - $mealAmount = $this->getMonthlyMealAmount($tenantId, $startDate, $endDate); - $perPersonMeal = $employeeCount > 0 ? $mealAmount / $employeeCount : 0; - - if ($perPersonMeal > self::TAX_FREE_MEAL_LIMIT) { - $mealFormatted = number_format($perPersonMeal / 10000); - $limitFormatted = number_format(self::TAX_FREE_MEAL_LIMIT / 10000); - $checkPoints[] = [ - 'id' => 'wf_cp_meal', - 'type' => 'error', - 'message' => "식대가 월 {$mealFormatted}만원으로 비과세 한도({$limitFormatted}만원)를 초과했습니다. 초과분은 근로소득 과세됩니다.", - 'highlights' => [ - ['text' => "식대가 월 {$mealFormatted}만원으로", 'color' => 'red'], - ['text' => '초과', 'color' => 'red'], - ], - ]; - } - - return $checkPoints; - } } From f665d3aea8cb85416ce5d90e93ccc767009d8f84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Wed, 4 Mar 2026 14:28:00 +0900 Subject: [PATCH 043/166] =?UTF-8?q?fix:=20[entertainment,welfare]=20?= =?UTF-8?q?=EB=B0=94=EB=A1=9C=EB=B9=8C=20=EC=A1=B0=EC=9D=B8=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=EB=AA=85=20=EB=B0=8F=20=EC=8B=AC=EC=95=BC=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=ED=8C=8C=EC=8B=B1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - approval_no → approval_num 컬럼명 수정 - use_time 심야 판별: HOUR() → SUBSTRING 문자열 파싱으로 변경 - whereNotNull('bct.use_time') 조건 추가 Co-Authored-By: Claude Opus 4.6 --- app/Services/EntertainmentService.php | 9 +++++---- app/Services/WelfareService.php | 7 ++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/Services/EntertainmentService.php b/app/Services/EntertainmentService.php index 773d3cd..b47da70 100644 --- a/app/Services/EntertainmentService.php +++ b/app/Services/EntertainmentService.php @@ -125,7 +125,7 @@ private function getWeekendLateNightRisk(int $tenantId, string $startDate, strin // 심야 사용 (barobill 카드 거래 내역에서 시간 확인) $lateNightResult = DB::table('expense_accounts as ea') ->leftJoin('barobill_card_transactions as bct', function ($join) { - $join->on('ea.receipt_no', '=', 'bct.approval_no') + $join->on('ea.receipt_no', '=', 'bct.approval_num') ->on('ea.tenant_id', '=', 'bct.tenant_id'); }) ->where('ea.tenant_id', $tenantId) @@ -133,9 +133,10 @@ private function getWeekendLateNightRisk(int $tenantId, string $startDate, strin ->whereBetween('ea.expense_date', [$startDate, $endDate]) ->whereNull('ea.deleted_at') ->whereRaw('DAYOFWEEK(ea.expense_date) NOT IN (1, 7)') // 주말 제외 (중복 방지) + ->whereNotNull('bct.use_time') ->where(function ($q) { - $q->whereRaw('HOUR(bct.use_time) >= ?', [self::LATE_NIGHT_START]) - ->orWhereRaw('HOUR(bct.use_time) < ?', [self::LATE_NIGHT_END]); + $q->whereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) >= ?', [self::LATE_NIGHT_START]) + ->orWhereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) < ?', [self::LATE_NIGHT_END]); }) ->selectRaw('COUNT(*) as count, COALESCE(SUM(ea.amount), 0) as total') ->first(); @@ -154,7 +155,7 @@ private function getProhibitedBizTypeRisk(int $tenantId, string $startDate, stri { $result = DB::table('expense_accounts as ea') ->join('barobill_card_transactions as bct', function ($join) { - $join->on('ea.receipt_no', '=', 'bct.approval_no') + $join->on('ea.receipt_no', '=', 'bct.approval_num') ->on('ea.tenant_id', '=', 'bct.tenant_id'); }) ->where('ea.tenant_id', $tenantId) diff --git a/app/Services/WelfareService.php b/app/Services/WelfareService.php index 7cad512..6231f9f 100644 --- a/app/Services/WelfareService.php +++ b/app/Services/WelfareService.php @@ -177,7 +177,7 @@ private function getPrivateUseRisk(int $tenantId, string $startDate, string $end // 심야 사용 (barobill 조인) $lateNightResult = DB::table('expense_accounts as ea') ->leftJoin('barobill_card_transactions as bct', function ($join) { - $join->on('ea.receipt_no', '=', 'bct.approval_no') + $join->on('ea.receipt_no', '=', 'bct.approval_num') ->on('ea.tenant_id', '=', 'bct.tenant_id'); }) ->where('ea.tenant_id', $tenantId) @@ -185,9 +185,10 @@ private function getPrivateUseRisk(int $tenantId, string $startDate, string $end ->whereBetween('ea.expense_date', [$startDate, $endDate]) ->whereNull('ea.deleted_at') ->whereRaw('DAYOFWEEK(ea.expense_date) NOT IN (1, 7)') + ->whereNotNull('bct.use_time') ->where(function ($q) { - $q->whereRaw('HOUR(bct.use_time) >= 22') - ->orWhereRaw('HOUR(bct.use_time) < 6'); + $q->whereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) >= 22') + ->orWhereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) < 6'); }) ->selectRaw('COUNT(*) as count, COALESCE(SUM(ea.amount), 0) as total') ->first(); From b86af29cc9f0434ba23b36b955c1f456ae42003a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Mar 2026 14:18:46 +0900 Subject: [PATCH 044/166] =?UTF-8?q?feat:=20[approval]=20body=5Ftemplate=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A7=80?= =?UTF-8?q?=EC=B6=9C=EA=B2=B0=EC=9D=98=EC=84=9C=20=EC=96=91=EC=8B=9D=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - approval_forms 테이블에 body_template TEXT 컬럼 추가 - 지출결의서(expense) 양식 데이터 등록 (HTML 테이블 본문 템플릿 포함) --- ...00_add_body_template_to_approval_forms.php | 22 +++++ ...04_100100_insert_expense_approval_form.php | 93 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 database/migrations/2026_03_04_100000_add_body_template_to_approval_forms.php create mode 100644 database/migrations/2026_03_04_100100_insert_expense_approval_form.php diff --git a/database/migrations/2026_03_04_100000_add_body_template_to_approval_forms.php b/database/migrations/2026_03_04_100000_add_body_template_to_approval_forms.php new file mode 100644 index 0000000..f040681 --- /dev/null +++ b/database/migrations/2026_03_04_100000_add_body_template_to_approval_forms.php @@ -0,0 +1,22 @@ +text('body_template')->nullable()->after('template')->comment('본문 HTML 템플릿'); + }); + } + + public function down(): void + { + Schema::table('approval_forms', function (Blueprint $table) { + $table->dropColumn('body_template'); + }); + } +}; diff --git a/database/migrations/2026_03_04_100100_insert_expense_approval_form.php b/database/migrations/2026_03_04_100100_insert_expense_approval_form.php new file mode 100644 index 0000000..14f9d61 --- /dev/null +++ b/database/migrations/2026_03_04_100100_insert_expense_approval_form.php @@ -0,0 +1,93 @@ + + + + + + + + + 지출일자 + + 부서 + + + + 거래처 + + + + 계정과목 + 적요 + 금액 + 비고 + + + + + + + + + + + + + + + + + + + + + 합계 + + + + + 지출 사유 + + + +HTML; + + DB::table('approval_forms')->insertOrIgnore([ + 'tenant_id' => 1, + 'name' => '지출결의서', + 'code' => 'expense', + 'category' => 'expense', + 'template' => json_encode([ + 'fields' => [ + ['name' => 'expense_date', 'type' => 'date', 'label' => '지출일자'], + ['name' => 'vendor', 'type' => 'text', 'label' => '거래처'], + ['name' => 'account', 'type' => 'text', 'label' => '계정과목'], + ['name' => 'amount', 'type' => 'number', 'label' => '금액'], + ['name' => 'description', 'type' => 'text', 'label' => '적요'], + ], + ], JSON_UNESCAPED_UNICODE), + 'body_template' => $bodyTemplate, + 'is_active' => true, + 'created_by' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function down(): void + { + DB::table('approval_forms') + ->where('tenant_id', 1) + ->where('code', 'expense') + ->delete(); + } +}; From 282bf26eecd2b6a3a1ab8daae4d74f3874d4c2fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Mar 2026 14:51:16 +0900 Subject: [PATCH 045/166] =?UTF-8?q?feat:=20[approval]=20=EC=A7=80=EC=B6=9C?= =?UTF-8?q?=EA=B2=B0=EC=9D=98=EC=84=9C=20body=5Ftemplate=20=EA=B3=A0?= =?UTF-8?q?=EB=8F=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 참조 문서 기반으로 정형 양식 HTML 리디자인 - 지출형식/세금계산서 체크박스, 기본정보, 8열 내역 테이블, 합계, 첨부 섹션 포함 --- ...te_expense_approval_form_body_template.php | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 database/migrations/2026_03_04_100200_update_expense_approval_form_body_template.php diff --git a/database/migrations/2026_03_04_100200_update_expense_approval_form_body_template.php b/database/migrations/2026_03_04_100200_update_expense_approval_form_body_template.php new file mode 100644 index 0000000..c2c9ef7 --- /dev/null +++ b/database/migrations/2026_03_04_100200_update_expense_approval_form_body_template.php @@ -0,0 +1,180 @@ +지 출 결 의 서 + + + + + + + + + +
지출형식 + ☐ 법인카드    ☐ 송금    ☐ 현금/가지급정산    ☐ 복지카드 +
세금계산서 + ☐ 일반    ☐ 이월발행 +
+ + + + + + + + + + + + + +
작성일자지출부서이름
제목
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
년/월/일내용금액업체명지급은행계좌번호예금주비고
합 계
+

첨부서류:

+HTML; + + DB::table('approval_forms') + ->where('tenant_id', 1) + ->where('code', 'expense') + ->update([ + 'body_template' => $bodyTemplate, + 'updated_at' => now(), + ]); + } + + public function down(): void + { + // 이전 body_template으로 복원 + $oldBodyTemplate = <<<'HTML' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
지출일자부서
거래처
계정과목적요금액비고
합계
지출 사유
+HTML; + + DB::table('approval_forms') + ->where('tenant_id', 1) + ->where('code', 'expense') + ->update([ + 'body_template' => $oldBodyTemplate, + 'updated_at' => now(), + ]); + } +}; From 66da2972faabf9283c38c17ac023f30cf616f450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Wed, 4 Mar 2026 15:11:37 +0900 Subject: [PATCH 046/166] =?UTF-8?q?feat:=20[entertainment,loan]=20?= =?UTF-8?q?=EC=A0=91=EB=8C=80=EB=B9=84=20=EC=83=81=EC=84=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EB=B0=8F=20=EA=B0=80=EC=A7=80=EA=B8=89?= =?UTF-8?q?=EA=B8=88=20=EB=82=A0=EC=A7=9C=20=ED=95=84=ED=84=B0=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 - EntertainmentController/Service: getDetail() 상세 조회 API 추가 (손금한도, 월별추이, 사용자분포, 거래내역, 분기현황) - EntertainmentService: 수입금액별 추가한도 계산(세법 기준), 거래건별 리스크 감지 - LoanController/Service: dashboard에 start_date/end_date 파라미터 지원 - LoanService: getCategoryBreakdown 날짜 필터 적용, 목록 limit 10→50 확대 - 라우트: GET /entertainment/detail 엔드포인트 추가 Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/EntertainmentController.php | 14 + .../Controllers/Api/V1/LoanController.php | 8 +- app/Services/EntertainmentService.php | 326 ++++++++++++++++++ app/Services/LoanService.php | 64 +++- routes/api/v1/finance.php | 1 + 5 files changed, 395 insertions(+), 18 deletions(-) diff --git a/app/Http/Controllers/Api/V1/EntertainmentController.php b/app/Http/Controllers/Api/V1/EntertainmentController.php index a010bee..a5e6e1f 100644 --- a/app/Http/Controllers/Api/V1/EntertainmentController.php +++ b/app/Http/Controllers/Api/V1/EntertainmentController.php @@ -33,4 +33,18 @@ public function summary(Request $request): JsonResponse return $this->entertainmentService->getSummary($limitType, $companyType, $year, $quarter); }, __('message.fetched')); } + + /** + * 접대비 상세 조회 (모달용) + */ + public function detail(Request $request): JsonResponse + { + $companyType = $request->query('company_type', 'medium'); + $year = $request->query('year') ? (int) $request->query('year') : null; + $quarter = $request->query('quarter') ? (int) $request->query('quarter') : null; + + return ApiResponse::handle(function () use ($companyType, $year, $quarter) { + return $this->entertainmentService->getDetail($companyType, $year, $quarter); + }, __('message.fetched')); + } } diff --git a/app/Http/Controllers/Api/V1/LoanController.php b/app/Http/Controllers/Api/V1/LoanController.php index fe27b67..34ff161 100644 --- a/app/Http/Controllers/Api/V1/LoanController.php +++ b/app/Http/Controllers/Api/V1/LoanController.php @@ -11,6 +11,7 @@ use App\Http\Requests\Loan\LoanUpdateRequest; use App\Services\LoanService; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; class LoanController extends Controller { @@ -42,9 +43,12 @@ public function summary(LoanIndexRequest $request): JsonResponse /** * 가지급금 대시보드 */ - public function dashboard(): JsonResponse + public function dashboard(Request $request): JsonResponse { - $result = $this->loanService->dashboard(); + $startDate = $request->query('start_date'); + $endDate = $request->query('end_date'); + + $result = $this->loanService->dashboard($startDate, $endDate); return ApiResponse::success($result, __('message.fetched')); } diff --git a/app/Services/EntertainmentService.php b/app/Services/EntertainmentService.php index b47da70..e1f0892 100644 --- a/app/Services/EntertainmentService.php +++ b/app/Services/EntertainmentService.php @@ -217,6 +217,332 @@ private function getMissingReceiptRisk(int $tenantId, string $startDate, string ]; } + /** + * 접대비 상세 정보 조회 (모달용) + * + * @param string|null $companyType 법인 유형 (large|medium|small, 기본: medium) + * @param int|null $year 연도 (기본: 현재 연도) + * @param int|null $quarter 분기 (1-4, 기본: 현재 분기) + */ + public function getDetail( + ?string $companyType = 'medium', + ?int $year = null, + ?int $quarter = null + ): array { + $tenantId = $this->tenantId(); + $now = Carbon::now(); + + $year = $year ?? $now->year; + $companyType = $companyType ?? 'medium'; + $quarter = $quarter ?? $now->quarter; + + // 연간 기간 범위 + $annualStartDate = Carbon::create($year, 1, 1)->format('Y-m-d'); + $annualEndDate = Carbon::create($year, 12, 31)->format('Y-m-d'); + + // 분기 기간 범위 + $quarterStartDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d'); + $quarterEndDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d'); + + // 기본한도 계산 (중소기업: 3,600만, 일반법인: 1,200만) + $baseLimit = $companyType === 'large' ? 12000000 : 36000000; + + // 수입금액 조회 (sales 테이블) + $revenue = $this->getAnnualRevenue($tenantId, $year); + + // 수입금액별 추가한도 계산 + $revenueAdditional = $this->calculateRevenueAdditionalLimit($revenue); + + // 연간 총 한도 + $annualLimit = $baseLimit + $revenueAdditional; + $quarterlyLimit = $annualLimit / 4; + + // 연간/분기 사용액 조회 + $annualUsed = $this->getUsedAmount($tenantId, $annualStartDate, $annualEndDate); + $quarterlyUsed = $this->getUsedAmount($tenantId, $quarterStartDate, $quarterEndDate); + + // 잔여/초과 계산 + $annualRemaining = max(0, $annualLimit - $annualUsed); + $annualExceeded = max(0, $annualUsed - $annualLimit); + + // 1. 요약 데이터 + $summary = [ + 'annual_limit' => (int) $annualLimit, + 'annual_remaining' => (int) $annualRemaining, + 'annual_used' => (int) $annualUsed, + 'annual_exceeded' => (int) $annualExceeded, + ]; + + // 2. 리스크 검토 카드 (기존 getSummary의 리스크 쿼리 재활용) + $weekendLateNight = $this->getWeekendLateNightRisk($tenantId, $annualStartDate, $annualEndDate); + $prohibitedBiz = $this->getProhibitedBizTypeRisk($tenantId, $annualStartDate, $annualEndDate); + $highAmount = $this->getHighAmountRisk($tenantId, $annualStartDate, $annualEndDate); + $missingReceipt = $this->getMissingReceiptRisk($tenantId, $annualStartDate, $annualEndDate); + + $riskReview = [ + ['label' => '주말/심야', 'amount' => (int) $weekendLateNight['total'], 'count' => $weekendLateNight['count']], + ['label' => '기피업종', 'amount' => (int) $prohibitedBiz['total'], 'count' => $prohibitedBiz['count']], + ['label' => '고액 결제', 'amount' => (int) $highAmount['total'], 'count' => $highAmount['count']], + ['label' => '증빙 미비', 'amount' => (int) $missingReceipt['total'], 'count' => $missingReceipt['count']], + ]; + + // 3. 월별 사용 추이 + $monthlyUsage = $this->getMonthlyUsageTrend($tenantId, $year); + + // 4. 사용자별 분포 + $userDistribution = $this->getUserDistribution($tenantId, $annualStartDate, $annualEndDate); + + // 5. 거래 내역 + $transactions = $this->getTransactions($tenantId, $quarterStartDate, $quarterEndDate); + + // 6. 손금한도 계산 정보 + $calculation = [ + 'company_type' => $companyType, + 'base_limit' => (int) $baseLimit, + 'revenue' => (int) $revenue, + 'revenue_additional' => (int) $revenueAdditional, + 'annual_limit' => (int) $annualLimit, + ]; + + // 7. 분기별 현황 + $quarterly = $this->getQuarterlyStatus($tenantId, $year, $quarterlyLimit); + + return [ + 'summary' => $summary, + 'risk_review' => $riskReview, + 'monthly_usage' => $monthlyUsage, + 'user_distribution' => $userDistribution, + 'transactions' => $transactions, + 'calculation' => $calculation, + 'quarterly' => $quarterly, + ]; + } + + /** + * 접대비 사용액 조회 + */ + private function getUsedAmount(int $tenantId, string $startDate, string $endDate): float + { + return DB::table('expense_accounts') + ->where('tenant_id', $tenantId) + ->where('account_type', 'entertainment') + ->whereBetween('expense_date', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->sum('amount') ?: 0; + } + + /** + * 연간 수입금액(매출) 조회 + */ + private function getAnnualRevenue(int $tenantId, int $year): float + { + return DB::table('sales') + ->where('tenant_id', $tenantId) + ->whereYear('sale_date', $year) + ->whereNull('deleted_at') + ->sum('total_amount') ?: 0; + } + + /** + * 수입금액별 추가한도 계산 (세법 기준) + * 100억 이하: 수입금액 × 0.2% + * 100억 초과 ~ 500억 이하: 2,000만 + (수입금액 - 100억) × 0.1% + * 500억 초과: 6,000만 + (수입금액 - 500억) × 0.03% + */ + private function calculateRevenueAdditionalLimit(float $revenue): float + { + $b10 = 10000000000; // 100억 + $b50 = 50000000000; // 500억 + + if ($revenue <= $b10) { + return $revenue * 0.002; + } elseif ($revenue <= $b50) { + return 20000000 + ($revenue - $b10) * 0.001; + } else { + return 60000000 + ($revenue - $b50) * 0.0003; + } + } + + /** + * 월별 사용 추이 조회 + */ + private function getMonthlyUsageTrend(int $tenantId, int $year): array + { + $monthlyData = DB::table('expense_accounts') + ->select(DB::raw('MONTH(expense_date) as month'), DB::raw('SUM(amount) as amount')) + ->where('tenant_id', $tenantId) + ->where('account_type', 'entertainment') + ->whereYear('expense_date', $year) + ->whereNull('deleted_at') + ->groupBy(DB::raw('MONTH(expense_date)')) + ->orderBy('month') + ->get(); + + $result = []; + for ($i = 1; $i <= 12; $i++) { + $found = $monthlyData->firstWhere('month', $i); + $result[] = [ + 'month' => $i, + 'label' => $i . '월', + 'amount' => $found ? (int) $found->amount : 0, + ]; + } + + return $result; + } + + /** + * 사용자별 분포 조회 + */ + private function getUserDistribution(int $tenantId, string $startDate, string $endDate): array + { + $colors = ['#60A5FA', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#FB923C']; + + $distribution = DB::table('expense_accounts as ea') + ->leftJoin('users as u', 'ea.created_by', '=', 'u.id') + ->select('u.name as user_name', DB::raw('SUM(ea.amount) as amount')) + ->where('ea.tenant_id', $tenantId) + ->where('ea.account_type', 'entertainment') + ->whereBetween('ea.expense_date', [$startDate, $endDate]) + ->whereNull('ea.deleted_at') + ->groupBy('ea.created_by', 'u.name') + ->orderByDesc('amount') + ->limit(5) + ->get(); + + $total = $distribution->sum('amount'); + $result = []; + $idx = 0; + + foreach ($distribution as $item) { + $result[] = [ + 'user_name' => $item->user_name ?? '사용자', + 'amount' => (int) $item->amount, + 'percentage' => $total > 0 ? round(($item->amount / $total) * 100, 1) : 0, + 'color' => $colors[$idx % count($colors)], + ]; + $idx++; + } + + return $result; + } + + /** + * 거래 내역 조회 + */ + private function getTransactions(int $tenantId, string $startDate, string $endDate): array + { + $transactions = DB::table('expense_accounts as ea') + ->leftJoin('users as u', 'ea.created_by', '=', 'u.id') + ->leftJoin('barobill_card_transactions as bct', function ($join) { + $join->on('ea.receipt_no', '=', 'bct.approval_num') + ->on('ea.tenant_id', '=', 'bct.tenant_id'); + }) + ->select([ + 'ea.id', + 'ea.card_no', + 'u.name as user_name', + 'ea.expense_date', + 'ea.vendor_name', + 'ea.amount', + 'ea.receipt_no', + 'bct.use_time', + 'bct.merchant_biz_type', + ]) + ->where('ea.tenant_id', $tenantId) + ->where('ea.account_type', 'entertainment') + ->whereBetween('ea.expense_date', [$startDate, $endDate]) + ->whereNull('ea.deleted_at') + ->orderByDesc('ea.expense_date') + ->limit(100) + ->get(); + + $result = []; + foreach ($transactions as $t) { + $riskType = $this->detectTransactionRiskType($t); + + $result[] = [ + 'id' => $t->id, + 'card_name' => $t->card_no ? '카드 *' . substr($t->card_no, -4) : '카드명', + 'user_name' => $t->user_name ?? '사용자', + 'expense_date' => Carbon::parse($t->expense_date)->format('Y-m-d H:i'), + 'vendor_name' => $t->vendor_name ?? '가맹점명', + 'amount' => (int) $t->amount, + 'risk_type' => $riskType, + ]; + } + + return $result; + } + + /** + * 거래 건별 리스크 유형 감지 + */ + private function detectTransactionRiskType(object $transaction): string + { + // 기피업종 + if ($transaction->merchant_biz_type && in_array($transaction->merchant_biz_type, self::PROHIBITED_MCC_CODES)) { + return '기피업종'; + } + + // 고액 결제 + if ($transaction->amount > self::HIGH_AMOUNT_THRESHOLD) { + return '고액 결제'; + } + + // 증빙 미비 + if (empty($transaction->receipt_no)) { + return '증빙 미비'; + } + + // 주말/심야 감지 + $expenseDate = Carbon::parse($transaction->expense_date); + if ($expenseDate->isWeekend()) { + return '주말/심야'; + } + if ($transaction->use_time) { + $hour = (int) substr($transaction->use_time, 0, 2); + if ($hour >= self::LATE_NIGHT_START || $hour < self::LATE_NIGHT_END) { + return '주말/심야'; + } + } + + return '정상'; + } + + /** + * 분기별 현황 조회 + */ + private function getQuarterlyStatus(int $tenantId, int $year, float $quarterlyLimit): array + { + $result = []; + $previousRemaining = 0; + + for ($q = 1; $q <= 4; $q++) { + $startDate = Carbon::create($year, ($q - 1) * 3 + 1, 1)->format('Y-m-d'); + $endDate = Carbon::create($year, $q * 3, 1)->endOfMonth()->format('Y-m-d'); + + $used = $this->getUsedAmount($tenantId, $startDate, $endDate); + $carryover = $previousRemaining > 0 ? $previousRemaining : 0; + $totalLimit = $quarterlyLimit + $carryover; + $remaining = max(0, $totalLimit - $used); + $exceeded = max(0, $used - $totalLimit); + + $result[] = [ + 'quarter' => $q, + 'limit' => (int) $quarterlyLimit, + 'carryover' => (int) $carryover, + 'used' => (int) $used, + 'remaining' => (int) $remaining, + 'exceeded' => (int) $exceeded, + ]; + + $previousRemaining = $remaining; + } + + return $result; + } + /** * 리스크 감지 체크포인트 생성 */ diff --git a/app/Services/LoanService.php b/app/Services/LoanService.php index 69d6fe5..1fac7b5 100644 --- a/app/Services/LoanService.php +++ b/app/Services/LoanService.php @@ -382,31 +382,54 @@ public function calculateInterest(int $year, ?int $userId = null): array * loans: array * } */ - public function dashboard(): array + public function dashboard(?string $startDate = null, ?string $endDate = null): array { $tenantId = $this->tenantId(); $currentYear = now()->year; - // 1. Summary 데이터 - $summaryData = $this->summary(); + // 날짜 필터 조건 클로저 + $applyDateFilter = function ($query) use ($startDate, $endDate) { + if ($startDate) { + $query->where('loan_date', '>=', $startDate); + } + if ($endDate) { + $query->where('loan_date', '<=', $endDate); + } + return $query; + }; - // 2. 인정이자 계산 (현재 연도 기준) + // 1. Summary 데이터 (날짜 필터 적용) + $summaryQuery = Loan::query()->where('tenant_id', $tenantId); + $applyDateFilter($summaryQuery); + + $stats = $summaryQuery->selectRaw(' + COUNT(*) as total_count, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as outstanding_count, + SUM(amount) as total_amount, + SUM(amount - COALESCE(settlement_amount, 0)) as total_outstanding + ', [Loan::STATUS_OUTSTANDING]) + ->first(); + + // 2. 인정이자 계산 (현재 연도 기준, 날짜 필터 무관) $interestData = $this->calculateInterest($currentYear); $recognizedInterest = $interestData['summary']['total_recognized_interest'] ?? 0; - // 3. 카테고리별 집계 (D1.7) - $categoryBreakdown = $this->getCategoryBreakdown($tenantId); + // 3. 카테고리별 집계 (날짜 필터 적용) + $categoryBreakdown = $this->getCategoryBreakdown($tenantId, $startDate, $endDate); - // 4. 가지급금 목록 (최근 10건, 미정산 우선) - $loans = Loan::query() + // 4. 가지급금 목록 (미정산 우선, 날짜 필터 적용) + $loansQuery = Loan::query() ->where('tenant_id', $tenantId) - ->with(['user:id,name,email', 'withdrawal']) + ->with(['user:id,name,email', 'withdrawal']); + $applyDateFilter($loansQuery); + + $loans = $loansQuery ->orderByRaw('CASE WHEN status = ? THEN 0 WHEN status = ? THEN 1 ELSE 2 END', [ Loan::STATUS_OUTSTANDING, Loan::STATUS_PARTIAL, ]) ->orderByDesc('loan_date') - ->limit(10) + ->limit(50) ->get() ->map(function ($loan) { return [ @@ -423,9 +446,9 @@ public function dashboard(): array return [ 'summary' => [ - 'total_outstanding' => (float) $summaryData['total_outstanding'], + 'total_outstanding' => (float) ($stats->total_outstanding ?? 0), 'recognized_interest' => (float) $recognizedInterest, - 'outstanding_count' => (int) $summaryData['outstanding_count'], + 'outstanding_count' => (int) ($stats->outstanding_count ?? 0), ], 'category_breakdown' => $categoryBreakdown, 'loans' => $loans, @@ -437,7 +460,7 @@ public function dashboard(): array * * @return array */ - private function getCategoryBreakdown(int $tenantId): array + private function getCategoryBreakdown(int $tenantId, ?string $startDate = null, ?string $endDate = null): array { // 기본값: 4개 카테고리 모두 0으로 초기화 $breakdown = []; @@ -449,9 +472,18 @@ private function getCategoryBreakdown(int $tenantId): array ]; } - // 카테고리별 집계 (summary와 동일하게 전체 대상) - $stats = Loan::query() - ->where('tenant_id', $tenantId) + // 카테고리별 집계 (summary와 동일하게 전체 대상, 날짜 필터 적용) + $query = Loan::query() + ->where('tenant_id', $tenantId); + + if ($startDate) { + $query->where('loan_date', '>=', $startDate); + } + if ($endDate) { + $query->where('loan_date', '<=', $endDate); + } + + $stats = $query ->selectRaw('category, COUNT(*) as total_count, SUM(amount - COALESCE(settlement_amount, 0)) as outstanding_amount') ->selectRaw('SUM(CASE WHEN purpose IS NULL OR purpose = \'\' THEN 1 ELSE 0 END) as unverified_count') ->groupBy('category') diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index bc28827..d9627a6 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -206,6 +206,7 @@ // Entertainment API (CEO 대시보드 접대비 현황) Route::get('/entertainment/summary', [EntertainmentController::class, 'summary'])->name('v1.entertainment.summary'); +Route::get('/entertainment/detail', [EntertainmentController::class, 'detail'])->name('v1.entertainment.detail'); // Welfare API (CEO 대시보드 복리후생비 현황) Route::get('/welfare/summary', [WelfareController::class, 'summary'])->name('v1.welfare.summary'); From a173a5a4fc2c8288486425a762a99b092c1bef1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Wed, 4 Mar 2026 15:30:31 +0900 Subject: [PATCH 047/166] =?UTF-8?q?fix:=20[loan]=20getCategoryBreakdown=20?= =?UTF-8?q?SQL=20alias=20=EC=B6=A9=EB=8F=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - outstanding_amount → cat_outstanding alias 변경 (Loan accessor 충돌 방지) Co-Authored-By: Claude Opus 4.6 --- app/Services/LoanService.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/Services/LoanService.php b/app/Services/LoanService.php index 1fac7b5..40e290c 100644 --- a/app/Services/LoanService.php +++ b/app/Services/LoanService.php @@ -483,8 +483,10 @@ private function getCategoryBreakdown(int $tenantId, ?string $startDate = null, $query->where('loan_date', '<=', $endDate); } + // NOTE: SQL alias를 'cat_outstanding'으로 사용 — Loan 모델의 + // getOutstandingAmountAttribute() accessor와 이름 충돌 방지 $stats = $query - ->selectRaw('category, COUNT(*) as total_count, SUM(amount - COALESCE(settlement_amount, 0)) as outstanding_amount') + ->selectRaw('category, COUNT(*) as total_count, SUM(amount - COALESCE(settlement_amount, 0)) as cat_outstanding') ->selectRaw('SUM(CASE WHEN purpose IS NULL OR purpose = \'\' THEN 1 ELSE 0 END) as unverified_count') ->groupBy('category') ->get(); @@ -493,7 +495,7 @@ private function getCategoryBreakdown(int $tenantId, ?string $startDate = null, $cat = $stat->category ?? Loan::CATEGORY_CARD; if (isset($breakdown[$cat])) { $breakdown[$cat] = [ - 'outstanding_amount' => (float) $stat->outstanding_amount, + 'outstanding_amount' => (float) $stat->cat_outstanding, 'total_count' => (int) $stat->total_count, 'unverified_count' => (int) $stat->unverified_count, ]; From 94b96e22f666c65d2c335771f2f1b920a9636bb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Wed, 4 Mar 2026 15:32:16 +0900 Subject: [PATCH 048/166] =?UTF-8?q?feat:=20[entertainment]=20=EC=A0=91?= =?UTF-8?q?=EB=8C=80=EB=B9=84=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=ED=95=84=ED=84=B0=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=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 - EntertainmentController: detail에 start_date/end_date 파라미터 전달 - EntertainmentService: getDetail 리스크/사용자분포/거래내역에 날짜 필터 적용 Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/EntertainmentController.php | 6 ++-- app/Services/EntertainmentService.php | 35 +++++++++++-------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/app/Http/Controllers/Api/V1/EntertainmentController.php b/app/Http/Controllers/Api/V1/EntertainmentController.php index a5e6e1f..0c6a4b7 100644 --- a/app/Http/Controllers/Api/V1/EntertainmentController.php +++ b/app/Http/Controllers/Api/V1/EntertainmentController.php @@ -42,9 +42,11 @@ public function detail(Request $request): JsonResponse $companyType = $request->query('company_type', 'medium'); $year = $request->query('year') ? (int) $request->query('year') : null; $quarter = $request->query('quarter') ? (int) $request->query('quarter') : null; + $startDate = $request->query('start_date'); + $endDate = $request->query('end_date'); - return ApiResponse::handle(function () use ($companyType, $year, $quarter) { - return $this->entertainmentService->getDetail($companyType, $year, $quarter); + return ApiResponse::handle(function () use ($companyType, $year, $quarter, $startDate, $endDate) { + return $this->entertainmentService->getDetail($companyType, $year, $quarter, $startDate, $endDate); }, __('message.fetched')); } } diff --git a/app/Services/EntertainmentService.php b/app/Services/EntertainmentService.php index e1f0892..2e1304f 100644 --- a/app/Services/EntertainmentService.php +++ b/app/Services/EntertainmentService.php @@ -227,7 +227,9 @@ private function getMissingReceiptRisk(int $tenantId, string $startDate, string public function getDetail( ?string $companyType = 'medium', ?int $year = null, - ?int $quarter = null + ?int $quarter = null, + ?string $startDate = null, + ?string $endDate = null ): array { $tenantId = $this->tenantId(); $now = Carbon::now(); @@ -236,13 +238,18 @@ public function getDetail( $companyType = $companyType ?? 'medium'; $quarter = $quarter ?? $now->quarter; - // 연간 기간 범위 + // 연간 기간 범위 (summary, calculation, quarterly, monthly_usage용 - 항상 연간) $annualStartDate = Carbon::create($year, 1, 1)->format('Y-m-d'); $annualEndDate = Carbon::create($year, 12, 31)->format('Y-m-d'); - // 분기 기간 범위 - $quarterStartDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d'); - $quarterEndDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d'); + // 거래/리스크 필터 기간 (start_date/end_date 전달 시 사용, 없으면 분기 기본) + if ($startDate && $endDate) { + $filterStartDate = $startDate; + $filterEndDate = $endDate; + } else { + $filterStartDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d'); + $filterEndDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d'); + } // 기본한도 계산 (중소기업: 3,600만, 일반법인: 1,200만) $baseLimit = $companyType === 'large' ? 12000000 : 36000000; @@ -273,11 +280,11 @@ public function getDetail( 'annual_exceeded' => (int) $annualExceeded, ]; - // 2. 리스크 검토 카드 (기존 getSummary의 리스크 쿼리 재활용) - $weekendLateNight = $this->getWeekendLateNightRisk($tenantId, $annualStartDate, $annualEndDate); - $prohibitedBiz = $this->getProhibitedBizTypeRisk($tenantId, $annualStartDate, $annualEndDate); - $highAmount = $this->getHighAmountRisk($tenantId, $annualStartDate, $annualEndDate); - $missingReceipt = $this->getMissingReceiptRisk($tenantId, $annualStartDate, $annualEndDate); + // 2. 리스크 검토 카드 (날짜 필터 적용) + $weekendLateNight = $this->getWeekendLateNightRisk($tenantId, $filterStartDate, $filterEndDate); + $prohibitedBiz = $this->getProhibitedBizTypeRisk($tenantId, $filterStartDate, $filterEndDate); + $highAmount = $this->getHighAmountRisk($tenantId, $filterStartDate, $filterEndDate); + $missingReceipt = $this->getMissingReceiptRisk($tenantId, $filterStartDate, $filterEndDate); $riskReview = [ ['label' => '주말/심야', 'amount' => (int) $weekendLateNight['total'], 'count' => $weekendLateNight['count']], @@ -289,11 +296,11 @@ public function getDetail( // 3. 월별 사용 추이 $monthlyUsage = $this->getMonthlyUsageTrend($tenantId, $year); - // 4. 사용자별 분포 - $userDistribution = $this->getUserDistribution($tenantId, $annualStartDate, $annualEndDate); + // 4. 사용자별 분포 (날짜 필터 적용) + $userDistribution = $this->getUserDistribution($tenantId, $filterStartDate, $filterEndDate); - // 5. 거래 내역 - $transactions = $this->getTransactions($tenantId, $quarterStartDate, $quarterEndDate); + // 5. 거래 내역 (날짜 필터 적용) + $transactions = $this->getTransactions($tenantId, $filterStartDate, $filterEndDate); // 6. 손금한도 계산 정보 $calculation = [ From 2f3ec13b24605475bb28b24e9a591a4a168e3990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Wed, 4 Mar 2026 20:00:04 +0900 Subject: [PATCH 049/166] =?UTF-8?q?fix:=20[entertainment]=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=20=EC=82=AC=EC=9A=A9=EC=95=A1=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=97=90=20=EB=82=A0=EC=A7=9C=20=ED=95=84=ED=84=B0=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- app/Services/EntertainmentService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Services/EntertainmentService.php b/app/Services/EntertainmentService.php index 2e1304f..7495e6d 100644 --- a/app/Services/EntertainmentService.php +++ b/app/Services/EntertainmentService.php @@ -266,7 +266,7 @@ public function getDetail( // 연간/분기 사용액 조회 $annualUsed = $this->getUsedAmount($tenantId, $annualStartDate, $annualEndDate); - $quarterlyUsed = $this->getUsedAmount($tenantId, $quarterStartDate, $quarterEndDate); + $quarterlyUsed = $this->getUsedAmount($tenantId, $filterStartDate, $filterEndDate); // 잔여/초과 계산 $annualRemaining = max(0, $annualLimit - $annualUsed); From 74a60e06bc50bb209d9870edde8fa947d236179f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Wed, 4 Mar 2026 20:33:04 +0900 Subject: [PATCH 050/166] =?UTF-8?q?feat:=20[calendar,vat]=20=EC=BA=98?= =?UTF-8?q?=EB=A6=B0=EB=8D=94=20CRUD=20=EB=B0=8F=20=EB=B6=80=EA=B0=80?= =?UTF-8?q?=EC=84=B8=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20API=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 - CalendarController/Service: 일정 등록/수정/삭제 API 추가 - VatController/Service: getDetail() 상세 조회 (요약, 참조테이블, 미발행 목록, 신고기간 옵션) - 라우트: POST/PUT/DELETE /calendar/schedules, GET /vat/detail 추가 Co-Authored-By: Claude Opus 4.6 --- .../Controllers/Api/V1/CalendarController.php | 52 +++++++ app/Http/Controllers/Api/V1/VatController.php | 14 ++ app/Services/CalendarService.php | 72 ++++++++++ app/Services/VatService.php | 133 ++++++++++++++++++ routes/api/v1/finance.php | 4 + 5 files changed, 275 insertions(+) diff --git a/app/Http/Controllers/Api/V1/CalendarController.php b/app/Http/Controllers/Api/V1/CalendarController.php index e7bd95e..fb4dfaa 100644 --- a/app/Http/Controllers/Api/V1/CalendarController.php +++ b/app/Http/Controllers/Api/V1/CalendarController.php @@ -51,4 +51,56 @@ public function summary(Request $request) ); }, __('message.fetched')); } + + /** + * 일정 등록 + */ + public function store(Request $request) + { + $validated = $request->validate([ + 'title' => 'required|string|max:200', + 'description' => 'nullable|string|max:1000', + 'start_date' => 'required|date_format:Y-m-d', + 'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date', + 'start_time' => 'nullable|date_format:H:i', + 'end_time' => 'nullable|date_format:H:i', + 'is_all_day' => 'boolean', + 'color' => 'nullable|string|max:20', + ]); + + return ApiResponse::handle(function () use ($validated) { + return $this->calendarService->createSchedule($validated); + }, __('message.created')); + } + + /** + * 일정 수정 + */ + public function update(Request $request, int $id) + { + $validated = $request->validate([ + 'title' => 'required|string|max:200', + 'description' => 'nullable|string|max:1000', + 'start_date' => 'required|date_format:Y-m-d', + 'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date', + 'start_time' => 'nullable|date_format:H:i', + 'end_time' => 'nullable|date_format:H:i', + 'is_all_day' => 'boolean', + 'color' => 'nullable|string|max:20', + ]); + + return ApiResponse::handle(function () use ($id, $validated) { + return $this->calendarService->updateSchedule($id, $validated); + }, __('message.updated')); + } + + /** + * 일정 삭제 + */ + public function destroy(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->calendarService->deleteSchedule($id); + }, __('message.deleted')); + } } diff --git a/app/Http/Controllers/Api/V1/VatController.php b/app/Http/Controllers/Api/V1/VatController.php index 765692b..1f02954 100644 --- a/app/Http/Controllers/Api/V1/VatController.php +++ b/app/Http/Controllers/Api/V1/VatController.php @@ -32,4 +32,18 @@ public function summary(Request $request): JsonResponse return $this->vatService->getSummary($periodType, $year, $period); }, __('message.fetched')); } + + /** + * 부가세 상세 조회 (모달용) + */ + public function detail(Request $request): JsonResponse + { + $periodType = $request->query('period_type', 'quarter'); + $year = $request->query('year') ? (int) $request->query('year') : null; + $period = $request->query('period') ? (int) $request->query('period') : null; + + return ApiResponse::handle(function () use ($periodType, $year, $period) { + return $this->vatService->getDetail($periodType, $year, $period); + }, __('message.fetched')); + } } diff --git a/app/Services/CalendarService.php b/app/Services/CalendarService.php index 61d4150..bdd87ae 100644 --- a/app/Services/CalendarService.php +++ b/app/Services/CalendarService.php @@ -226,6 +226,78 @@ private function getLeaveSchedules( }); } + /** + * 일정 등록 + */ + public function createSchedule(array $data): array + { + $schedule = Schedule::create([ + 'tenant_id' => $this->tenantId(), + 'title' => $data['title'], + 'description' => $data['description'] ?? null, + 'start_date' => $data['start_date'], + 'end_date' => $data['end_date'], + 'start_time' => $data['start_time'] ?? null, + 'end_time' => $data['end_time'] ?? null, + 'is_all_day' => $data['is_all_day'] ?? true, + 'type' => Schedule::TYPE_EVENT, + 'color' => $data['color'] ?? null, + 'is_active' => true, + 'created_by' => $this->apiUserId(), + ]); + + return [ + 'id' => $schedule->id, + 'title' => $schedule->title, + 'start_date' => $schedule->start_date?->format('Y-m-d'), + 'end_date' => $schedule->end_date?->format('Y-m-d'), + ]; + } + + /** + * 일정 수정 + */ + public function updateSchedule(int $id, array $data): array + { + $schedule = Schedule::where('tenant_id', $this->tenantId()) + ->findOrFail($id); + + $schedule->update([ + 'title' => $data['title'], + 'description' => $data['description'] ?? null, + 'start_date' => $data['start_date'], + 'end_date' => $data['end_date'], + 'start_time' => $data['start_time'] ?? null, + 'end_time' => $data['end_time'] ?? null, + 'is_all_day' => $data['is_all_day'] ?? true, + 'color' => $data['color'] ?? null, + 'updated_by' => $this->apiUserId(), + ]); + + return [ + 'id' => $schedule->id, + 'title' => $schedule->title, + 'start_date' => $schedule->start_date?->format('Y-m-d'), + 'end_date' => $schedule->end_date?->format('Y-m-d'), + ]; + } + + /** + * 일정 삭제 (소프트 삭제) + */ + public function deleteSchedule(int $id): array + { + $schedule = Schedule::where('tenant_id', $this->tenantId()) + ->findOrFail($id); + + $schedule->update(['deleted_by' => $this->apiUserId()]); + $schedule->delete(); + + return [ + 'id' => $schedule->id, + ]; + } + /** * 범용 일정 조회 (본사 공통 + 테넌트 일정) */ diff --git a/app/Services/VatService.php b/app/Services/VatService.php index 9f9b0b2..9e937ef 100644 --- a/app/Services/VatService.php +++ b/app/Services/VatService.php @@ -237,6 +237,139 @@ private function getPeriodLabel(int $year, string $periodType, int $period): str }; } + /** + * 부가세 상세 조회 (모달용) + * + * @param string|null $periodType 기간 타입 (quarter|half|year) + * @param int|null $year 연도 + * @param int|null $period 기간 번호 + * @return array + */ + public function getDetail(?string $periodType = 'quarter', ?int $year = null, ?int $period = null): array + { + $tenantId = $this->tenantId(); + $now = Carbon::now(); + + $year = $year ?? $now->year; + $periodType = $periodType ?? 'quarter'; + $period = $period ?? $this->getCurrentPeriod($periodType, $now); + + [$startDate, $endDate] = $this->getPeriodDateRange($year, $periodType, $period); + $periodLabel = $this->getPeriodLabel($year, $periodType, $period); + + $validStatuses = [TaxInvoice::STATUS_ISSUED, TaxInvoice::STATUS_SENT]; + + // 매출 공급가액 + 세액 + $salesData = TaxInvoice::where('tenant_id', $tenantId) + ->where('direction', TaxInvoice::DIRECTION_SALES) + ->whereIn('status', $validStatuses) + ->whereBetween('issue_date', [$startDate, $endDate]) + ->selectRaw('COALESCE(SUM(supply_amount), 0) as supply_amount, COALESCE(SUM(tax_amount), 0) as tax_amount') + ->first(); + + // 매입 공급가액 + 세액 + $purchasesData = TaxInvoice::where('tenant_id', $tenantId) + ->where('direction', TaxInvoice::DIRECTION_PURCHASES) + ->whereIn('status', $validStatuses) + ->whereBetween('issue_date', [$startDate, $endDate]) + ->selectRaw('COALESCE(SUM(supply_amount), 0) as supply_amount, COALESCE(SUM(tax_amount), 0) as tax_amount') + ->first(); + + $salesSupplyAmount = (int) ($salesData->supply_amount ?? 0); + $salesTaxAmount = (int) ($salesData->tax_amount ?? 0); + $purchasesSupplyAmount = (int) ($purchasesData->supply_amount ?? 0); + $purchasesTaxAmount = (int) ($purchasesData->tax_amount ?? 0); + $estimatedPayment = $salesTaxAmount - $purchasesTaxAmount; + + // 신고기간 옵션 생성 + $periodOptions = $this->generatePeriodOptions($year, $periodType, $period); + + // 부가세 요약 테이블 (direction + invoice_type 별 GROUP BY) + $referenceTable = TaxInvoice::where('tenant_id', $tenantId) + ->whereIn('status', $validStatuses) + ->whereBetween('issue_date', [$startDate, $endDate]) + ->selectRaw(" + direction, + invoice_type, + COALESCE(SUM(supply_amount), 0) as supply_amount, + COALESCE(SUM(tax_amount), 0) as tax_amount + ") + ->groupBy('direction', 'invoice_type') + ->get() + ->map(fn ($row) => [ + 'direction' => $row->direction, + 'direction_label' => $row->direction === TaxInvoice::DIRECTION_SALES ? '매출' : '매입', + 'invoice_type' => $row->invoice_type, + 'invoice_type_label' => match ($row->invoice_type) { + TaxInvoice::TYPE_TAX_INVOICE => '전자세금계산서', + TaxInvoice::TYPE_INVOICE => '계산서', + TaxInvoice::TYPE_MODIFIED_TAX_INVOICE => '수정세금계산서', + default => $row->invoice_type, + }, + 'supply_amount' => (int) $row->supply_amount, + 'tax_amount' => (int) $row->tax_amount, + ]) + ->toArray(); + + // 미발행/미수취 세금계산서 목록 (status=draft) + $unissuedInvoices = TaxInvoice::where('tenant_id', $tenantId) + ->where('status', TaxInvoice::STATUS_DRAFT) + ->orderBy('issue_date', 'desc') + ->limit(100) + ->get() + ->map(fn ($invoice) => [ + 'id' => $invoice->id, + 'direction' => $invoice->direction, + 'direction_label' => $invoice->direction === TaxInvoice::DIRECTION_SALES ? '매출' : '매입', + 'issue_date' => $invoice->issue_date, + 'vendor_name' => $invoice->direction === TaxInvoice::DIRECTION_SALES + ? ($invoice->buyer_corp_name ?? '-') + : ($invoice->supplier_corp_name ?? '-'), + 'tax_amount' => (int) $invoice->tax_amount, + 'status' => $invoice->direction === TaxInvoice::DIRECTION_SALES ? '미발행' : '미수취', + ]) + ->toArray(); + + return [ + 'period_label' => $periodLabel, + 'period_options' => $periodOptions, + 'summary' => [ + 'sales_supply_amount' => $salesSupplyAmount, + 'sales_tax_amount' => $salesTaxAmount, + 'purchases_supply_amount' => $purchasesSupplyAmount, + 'purchases_tax_amount' => $purchasesTaxAmount, + 'estimated_payment' => (int) abs($estimatedPayment), + 'is_refund' => $estimatedPayment < 0, + ], + 'reference_table' => $referenceTable, + 'unissued_invoices' => $unissuedInvoices, + ]; + } + + /** + * 신고기간 드롭다운 옵션 생성 + * 현재 기간 포함 최근 8개 기간 + */ + private function generatePeriodOptions(int $currentYear, string $periodType, int $currentPeriod): array + { + $options = []; + $year = $currentYear; + $period = $currentPeriod; + + for ($i = 0; $i < 8; $i++) { + $label = $this->getPeriodLabel($year, $periodType, $period); + $value = "{$year}-{$periodType}-{$period}"; + $options[] = ['value' => $value, 'label' => $label]; + + // 이전 기간으로 이동 + $prev = $this->getPreviousPeriod($year, $periodType, $period); + $year = $prev['year']; + $period = $prev['period']; + } + + return $options; + } + /** * 이전 기간 계산 * diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index d9627a6..aef25d2 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -200,9 +200,13 @@ // Calendar API (CEO 대시보드 캘린더) Route::get('/calendar/schedules', [CalendarController::class, 'summary'])->name('v1.calendar.schedules'); +Route::post('/calendar/schedules', [CalendarController::class, 'store'])->name('v1.calendar.schedules.store'); +Route::put('/calendar/schedules/{id}', [CalendarController::class, 'update'])->whereNumber('id')->name('v1.calendar.schedules.update'); +Route::delete('/calendar/schedules/{id}', [CalendarController::class, 'destroy'])->whereNumber('id')->name('v1.calendar.schedules.destroy'); // Vat API (CEO 대시보드 부가세 현황) Route::get('/vat/summary', [VatController::class, 'summary'])->name('v1.vat.summary'); +Route::get('/vat/detail', [VatController::class, 'detail'])->name('v1.vat.detail'); // Entertainment API (CEO 대시보드 접대비 현황) Route::get('/entertainment/summary', [EntertainmentController::class, 'summary'])->name('v1.entertainment.summary'); From e0bb19a017490fcf1532f1958000c3f3c9d9710e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 5 Mar 2026 09:16:05 +0900 Subject: [PATCH 051/166] =?UTF-8?q?fix:=20[storage]=20RecordStorageUsage?= =?UTF-8?q?=20=EB=AA=85=EB=A0=B9=EC=96=B4=20tenants=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=BB=AC=EB=9F=BC=EB=AA=85=20=EC=98=A4=EB=A5=98=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 - Tenant::where('status', 'active') → Tenant::active() 스코프 사용 - tenants 테이블에 status 컬럼 없음, tenant_st_code 사용 --- app/Console/Commands/RecordStorageUsage.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Console/Commands/RecordStorageUsage.php b/app/Console/Commands/RecordStorageUsage.php index 1aaf2fc..c77f1eb 100644 --- a/app/Console/Commands/RecordStorageUsage.php +++ b/app/Console/Commands/RecordStorageUsage.php @@ -29,7 +29,7 @@ class RecordStorageUsage extends Command */ public function handle(): int { - $tenants = Tenant::where('status', 'active')->get(); + $tenants = Tenant::active()->get(); $recorded = 0; foreach ($tenants as $tenant) { From e8da2ea1b1f35c69d87d642e3c7b3d20d36ecf8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Thu, 5 Mar 2026 09:35:30 +0900 Subject: [PATCH 052/166] =?UTF-8?q?feat:=20[dashboard-ceo]=20CEO=20?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=84=B9=EC=85=98?= =?UTF-8?q?=EB=B3=84=20API=20=EB=B0=8F=20=EC=9D=BC=EC=9D=BC=EB=B3=B4?= =?UTF-8?q?=EA=B3=A0=EC=84=9C=20=EC=97=91=EC=85=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DashboardCeoController/Service: 6개 섹션 API 신규 (매출/매입/생산/미출고/시공/근태) - DailyReportController/Service: 엑셀 다운로드 API 추가 (GET /daily-report/export) - 라우트: dashboard 하위 6개 + daily-report/export 엔드포인트 추가 Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/DailyReportController.php | 18 + .../Api/V1/DashboardCeoController.php | 92 +++ app/Services/DailyReportService.php | 70 ++ app/Services/DashboardCeoService.php | 736 ++++++++++++++++++ routes/api/v1/common.php | 9 + routes/api/v1/finance.php | 1 + 6 files changed, 926 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/DashboardCeoController.php create mode 100644 app/Services/DashboardCeoService.php diff --git a/app/Http/Controllers/Api/V1/DailyReportController.php b/app/Http/Controllers/Api/V1/DailyReportController.php index 41811ce..e2c1915 100644 --- a/app/Http/Controllers/Api/V1/DailyReportController.php +++ b/app/Http/Controllers/Api/V1/DailyReportController.php @@ -2,11 +2,14 @@ namespace App\Http\Controllers\Api\V1; +use App\Exports\DailyReportExport; use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Services\DailyReportService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Maatwebsite\Excel\Facades\Excel; +use Symfony\Component\HttpFoundation\BinaryFileResponse; /** * 일일 보고서 컨트롤러 @@ -58,4 +61,19 @@ public function summary(Request $request): JsonResponse return $this->service->summary($params); }, __('message.fetched')); } + + /** + * 일일 보고서 엑셀 다운로드 + */ + public function export(Request $request): BinaryFileResponse + { + $params = $request->validate([ + 'date' => 'nullable|date', + ]); + + $reportData = $this->service->exportData($params); + $filename = '일일일보_'.$reportData['date'].'.xlsx'; + + return Excel::download(new DailyReportExport($reportData), $filename); + } } diff --git a/app/Http/Controllers/Api/V1/DashboardCeoController.php b/app/Http/Controllers/Api/V1/DashboardCeoController.php new file mode 100644 index 0000000..e161b9b --- /dev/null +++ b/app/Http/Controllers/Api/V1/DashboardCeoController.php @@ -0,0 +1,92 @@ + $this->service->salesSummary(), + __('message.fetched') + ); + } + + /** + * 매입 현황 요약 + * GET /api/v1/dashboard/purchases/summary + */ + public function purchasesSummary(): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->purchasesSummary(), + __('message.fetched') + ); + } + + /** + * 생산 현황 요약 + * GET /api/v1/dashboard/production/summary + */ + public function productionSummary(): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->productionSummary(), + __('message.fetched') + ); + } + + /** + * 미출고 내역 요약 + * GET /api/v1/dashboard/unshipped/summary + */ + public function unshippedSummary(): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->unshippedSummary(), + __('message.fetched') + ); + } + + /** + * 시공 현황 요약 + * GET /api/v1/dashboard/construction/summary + */ + public function constructionSummary(): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->constructionSummary(), + __('message.fetched') + ); + } + + /** + * 근태 현황 요약 + * GET /api/v1/dashboard/attendance/summary + */ + public function attendanceSummary(): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->attendanceSummary(), + __('message.fetched') + ); + } +} diff --git a/app/Services/DailyReportService.php b/app/Services/DailyReportService.php index 5762d17..d2be5ca 100644 --- a/app/Services/DailyReportService.php +++ b/app/Services/DailyReportService.php @@ -182,6 +182,76 @@ public function summary(array $params): array ]; } + /** + * 엑셀 내보내기용 데이터 조합 + * DailyReportExport가 기대하는 구조로 변환 + */ + public function exportData(array $params): array + { + $tenantId = $this->tenantId(); + $date = isset($params['date']) ? Carbon::parse($params['date']) : Carbon::today(); + $dateStr = $date->format('Y-m-d'); + $startOfDay = $date->copy()->startOfDay(); + $endOfDay = $date->copy()->endOfDay(); + + // 전일 잔액 계산 (전일까지 입금 합계 - 전일까지 출금 합계) + $prevDeposits = Deposit::where('tenant_id', $tenantId) + ->where('deposit_date', '<', $startOfDay) + ->sum('amount'); + $prevWithdrawals = Withdrawal::where('tenant_id', $tenantId) + ->where('withdrawal_date', '<', $startOfDay) + ->sum('amount'); + $previousBalance = (float) ($prevDeposits - $prevWithdrawals); + + // 당일 입금 + $dailyDeposits = Deposit::where('tenant_id', $tenantId) + ->whereBetween('deposit_date', [$startOfDay, $endOfDay]) + ->get(); + $dailyDepositTotal = (float) $dailyDeposits->sum('amount'); + + // 당일 출금 + $dailyWithdrawals = Withdrawal::where('tenant_id', $tenantId) + ->whereBetween('withdrawal_date', [$startOfDay, $endOfDay]) + ->get(); + $dailyWithdrawalTotal = (float) $dailyWithdrawals->sum('amount'); + + $currentBalance = $previousBalance + $dailyDepositTotal - $dailyWithdrawalTotal; + + // 상세 내역 조합 + $details = []; + + foreach ($dailyDeposits as $d) { + $details[] = [ + 'type_label' => '입금', + 'client_name' => $d->client?->name ?? '-', + 'account_code' => $d->account_code ?? '-', + 'deposit_amount' => (float) $d->amount, + 'withdrawal_amount' => 0, + 'description' => $d->description ?? '', + ]; + } + + foreach ($dailyWithdrawals as $w) { + $details[] = [ + 'type_label' => '출금', + 'client_name' => $w->client?->name ?? '-', + 'account_code' => $w->account_code ?? '-', + 'deposit_amount' => 0, + 'withdrawal_amount' => (float) $w->amount, + 'description' => $w->description ?? '', + ]; + } + + return [ + 'date' => $dateStr, + 'previous_balance' => $previousBalance, + 'daily_deposit' => $dailyDepositTotal, + 'daily_withdrawal' => $dailyWithdrawalTotal, + 'current_balance' => $currentBalance, + 'details' => $details, + ]; + } + /** * 미수금 잔액 계산 * = 전체 매출 - 전체 입금 - 전체 수취어음 (기준일까지) diff --git a/app/Services/DashboardCeoService.php b/app/Services/DashboardCeoService.php new file mode 100644 index 0000000..d021e4c --- /dev/null +++ b/app/Services/DashboardCeoService.php @@ -0,0 +1,736 @@ +tenantId(); + $now = Carbon::now(); + $year = $now->year; + $month = $now->month; + $today = $now->format('Y-m-d'); + + // 누적 매출 (연초~오늘) + $cumulativeSales = DB::table('sales') + ->where('tenant_id', $tenantId) + ->whereYear('sale_date', $year) + ->where('sale_date', '<=', $today) + ->whereNull('deleted_at') + ->sum('total_amount') ?: 0; + + // 당월 매출 + $monthlySales = DB::table('sales') + ->where('tenant_id', $tenantId) + ->whereYear('sale_date', $year) + ->whereMonth('sale_date', $month) + ->whereNull('deleted_at') + ->sum('total_amount') ?: 0; + + // 전년 동월 매출 (YoY) + $lastYearMonthlySales = DB::table('sales') + ->where('tenant_id', $tenantId) + ->whereYear('sale_date', $year - 1) + ->whereMonth('sale_date', $month) + ->whereNull('deleted_at') + ->sum('total_amount') ?: 0; + + $yoyChange = $lastYearMonthlySales > 0 + ? round((($monthlySales - $lastYearMonthlySales) / $lastYearMonthlySales) * 100, 1) + : 0; + + // 달성률 (당월 매출 / 전년 동월 매출 * 100) + $achievementRate = $lastYearMonthlySales > 0 + ? round(($monthlySales / $lastYearMonthlySales) * 100, 0) + : 0; + + // 월별 추이 (1~12월) + $monthlyTrend = $this->getSalesMonthlyTrend($tenantId, $year); + + // 거래처별 매출 (상위 5개) + $clientSales = $this->getSalesClientRanking($tenantId, $year); + + // 일별 매출 내역 (최근 10건) + $dailyItems = $this->getSalesDailyItems($tenantId, $today); + + // 일별 합계 + $dailyTotal = DB::table('sales') + ->where('tenant_id', $tenantId) + ->where('sale_date', $today) + ->whereNull('deleted_at') + ->sum('total_amount') ?: 0; + + return [ + 'cumulative_sales' => (int) $cumulativeSales, + 'achievement_rate' => (int) $achievementRate, + 'yoy_change' => $yoyChange, + 'monthly_sales' => (int) $monthlySales, + 'monthly_trend' => $monthlyTrend, + 'client_sales' => $clientSales, + 'daily_items' => $dailyItems, + 'daily_total' => (int) $dailyTotal, + ]; + } + + private function getSalesMonthlyTrend(int $tenantId, int $year): array + { + $monthlyData = DB::table('sales') + ->select(DB::raw('MONTH(sale_date) as month'), DB::raw('COALESCE(SUM(total_amount), 0) as amount')) + ->where('tenant_id', $tenantId) + ->whereYear('sale_date', $year) + ->whereNull('deleted_at') + ->groupBy(DB::raw('MONTH(sale_date)')) + ->orderBy('month') + ->get(); + + $result = []; + for ($i = 1; $i <= 12; $i++) { + $found = $monthlyData->firstWhere('month', $i); + $result[] = [ + 'month' => sprintf('%d-%02d', $year, $i), + 'label' => $i.'월', + 'amount' => $found ? (int) $found->amount : 0, + ]; + } + + return $result; + } + + private function getSalesClientRanking(int $tenantId, int $year): array + { + $clients = DB::table('sales as s') + ->leftJoin('clients as c', 's.client_id', '=', 'c.id') + ->select('c.name', DB::raw('SUM(s.total_amount) as amount')) + ->where('s.tenant_id', $tenantId) + ->whereYear('s.sale_date', $year) + ->whereNull('s.deleted_at') + ->groupBy('s.client_id', 'c.name') + ->orderByDesc('amount') + ->limit(5) + ->get(); + + return $clients->map(fn ($item) => [ + 'name' => $item->name ?? '미지정', + 'amount' => (int) $item->amount, + ])->toArray(); + } + + private function getSalesDailyItems(int $tenantId, string $today): array + { + $items = DB::table('sales as s') + ->leftJoin('clients as c', 's.client_id', '=', 'c.id') + ->select([ + 's.sale_date as date', + 'c.name as client', + 's.description as item', + 's.total_amount as amount', + 's.status', + 's.deposit_id', + ]) + ->where('s.tenant_id', $tenantId) + ->where('s.sale_date', '>=', Carbon::parse($today)->subDays(30)->format('Y-m-d')) + ->whereNull('s.deleted_at') + ->orderByDesc('s.sale_date') + ->limit(10) + ->get(); + + return $items->map(fn ($item) => [ + 'date' => $item->date, + 'client' => $item->client ?? '미지정', + 'item' => $item->item ?? '-', + 'amount' => (int) $item->amount, + 'status' => $item->deposit_id ? 'deposited' : 'unpaid', + ])->toArray(); + } + + // ─── 2. 매입 현황 ─────────────────────────────── + + /** + * 매입 현황 요약 + */ + public function purchasesSummary(): array + { + $tenantId = $this->tenantId(); + $now = Carbon::now(); + $year = $now->year; + $month = $now->month; + $today = $now->format('Y-m-d'); + + // 누적 매입 + $cumulativePurchase = DB::table('purchases') + ->where('tenant_id', $tenantId) + ->whereYear('purchase_date', $year) + ->where('purchase_date', '<=', $today) + ->whereNull('deleted_at') + ->sum('total_amount') ?: 0; + + // 미결제 금액 (withdrawal_id가 없는 것) + $unpaidAmount = DB::table('purchases') + ->where('tenant_id', $tenantId) + ->whereYear('purchase_date', $year) + ->whereNull('withdrawal_id') + ->whereNull('deleted_at') + ->sum('total_amount') ?: 0; + + // 전년 동월 대비 + $thisMonthPurchase = DB::table('purchases') + ->where('tenant_id', $tenantId) + ->whereYear('purchase_date', $year) + ->whereMonth('purchase_date', $month) + ->whereNull('deleted_at') + ->sum('total_amount') ?: 0; + + $lastYearMonthPurchase = DB::table('purchases') + ->where('tenant_id', $tenantId) + ->whereYear('purchase_date', $year - 1) + ->whereMonth('purchase_date', $month) + ->whereNull('deleted_at') + ->sum('total_amount') ?: 0; + + $yoyChange = $lastYearMonthPurchase > 0 + ? round((($thisMonthPurchase - $lastYearMonthPurchase) / $lastYearMonthPurchase) * 100, 1) + : 0; + + // 월별 추이 + $monthlyTrend = $this->getPurchaseMonthlyTrend($tenantId, $year); + + // 자재 구성 비율 (purchase_type별) + $materialRatio = $this->getPurchaseMaterialRatio($tenantId, $year); + + // 일별 매입 내역 + $dailyItems = $this->getPurchaseDailyItems($tenantId, $today); + + // 일별 합계 + $dailyTotal = DB::table('purchases') + ->where('tenant_id', $tenantId) + ->where('purchase_date', $today) + ->whereNull('deleted_at') + ->sum('total_amount') ?: 0; + + return [ + 'cumulative_purchase' => (int) $cumulativePurchase, + 'unpaid_amount' => (int) $unpaidAmount, + 'yoy_change' => $yoyChange, + 'monthly_trend' => $monthlyTrend, + 'material_ratio' => $materialRatio, + 'daily_items' => $dailyItems, + 'daily_total' => (int) $dailyTotal, + ]; + } + + private function getPurchaseMonthlyTrend(int $tenantId, int $year): array + { + $monthlyData = DB::table('purchases') + ->select(DB::raw('MONTH(purchase_date) as month'), DB::raw('COALESCE(SUM(total_amount), 0) as amount')) + ->where('tenant_id', $tenantId) + ->whereYear('purchase_date', $year) + ->whereNull('deleted_at') + ->groupBy(DB::raw('MONTH(purchase_date)')) + ->orderBy('month') + ->get(); + + $result = []; + for ($i = 1; $i <= 12; $i++) { + $found = $monthlyData->firstWhere('month', $i); + $result[] = [ + 'month' => sprintf('%d-%02d', $year, $i), + 'label' => $i.'월', + 'amount' => $found ? (int) $found->amount : 0, + ]; + } + + return $result; + } + + private function getPurchaseMaterialRatio(int $tenantId, int $year): array + { + $colors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899']; + + $ratioData = DB::table('purchases') + ->select('purchase_type', DB::raw('SUM(total_amount) as value')) + ->where('tenant_id', $tenantId) + ->whereYear('purchase_date', $year) + ->whereNull('deleted_at') + ->groupBy('purchase_type') + ->orderByDesc('value') + ->limit(6) + ->get(); + + $total = $ratioData->sum('value'); + $idx = 0; + + return $ratioData->map(function ($item) use ($total, $colors, &$idx) { + $name = $this->getPurchaseTypeName($item->purchase_type); + $result = [ + 'name' => $name, + 'value' => (int) $item->value, + 'percentage' => $total > 0 ? round(($item->value / $total) * 100, 1) : 0, + 'color' => $colors[$idx % count($colors)], + ]; + $idx++; + + return $result; + })->toArray(); + } + + private function getPurchaseTypeName(?string $type): string + { + $map = [ + '원재료매입' => '원자재', + '부재료매입' => '부자재', + '소모품매입' => '소모품', + '외주가공비' => '외주가공', + '접대비' => '접대비', + '복리후생비' => '복리후생', + ]; + + return $map[$type] ?? ($type ?? '기타'); + } + + private function getPurchaseDailyItems(int $tenantId, string $today): array + { + $items = DB::table('purchases as p') + ->leftJoin('clients as c', 'p.client_id', '=', 'c.id') + ->select([ + 'p.purchase_date as date', + 'c.name as supplier', + 'p.description as item', + 'p.total_amount as amount', + 'p.withdrawal_id', + ]) + ->where('p.tenant_id', $tenantId) + ->where('p.purchase_date', '>=', Carbon::parse($today)->subDays(30)->format('Y-m-d')) + ->whereNull('p.deleted_at') + ->orderByDesc('p.purchase_date') + ->limit(10) + ->get(); + + return $items->map(fn ($item) => [ + 'date' => $item->date, + 'supplier' => $item->supplier ?? '미지정', + 'item' => $item->item ?? '-', + 'amount' => (int) $item->amount, + 'status' => $item->withdrawal_id ? 'paid' : 'unpaid', + ])->toArray(); + } + + // ─── 3. 생산 현황 ─────────────────────────────── + + /** + * 생산 현황 요약 + */ + public function productionSummary(): array + { + $tenantId = $this->tenantId(); + $today = Carbon::now(); + $todayStr = $today->format('Y-m-d'); + + $dayOfWeekMap = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일']; + $dayOfWeek = $dayOfWeekMap[$today->dayOfWeek]; + + // 공정별 작업 현황 + $processes = $this->getProductionProcesses($tenantId, $todayStr); + + // 출고 현황 + $shipment = $this->getShipmentSummary($tenantId, $todayStr); + + return [ + 'date' => $todayStr, + 'day_of_week' => $dayOfWeek, + 'processes' => $processes, + 'shipment' => $shipment, + ]; + } + + private function getProductionProcesses(int $tenantId, string $today): array + { + // 공정별 작업 지시 집계 + $processData = DB::table('work_orders as wo') + ->leftJoin('processes as p', 'wo.process_id', '=', 'p.id') + ->select( + 'p.id as process_id', + 'p.name as process_name', + DB::raw('COUNT(*) as total_work'), + DB::raw("SUM(CASE WHEN wo.status = 'pending' OR wo.status = 'unassigned' OR wo.status = 'waiting' THEN 1 ELSE 0 END) as todo"), + DB::raw("SUM(CASE WHEN wo.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress"), + DB::raw("SUM(CASE WHEN wo.status = 'completed' OR wo.status = 'shipped' THEN 1 ELSE 0 END) as completed"), + DB::raw("SUM(CASE WHEN wo.priority = 'urgent' THEN 1 ELSE 0 END) as urgent"), + ) + ->where('wo.tenant_id', $tenantId) + ->where('wo.scheduled_date', $today) + ->where('wo.is_active', true) + ->whereNull('wo.deleted_at') + ->whereNotNull('wo.process_id') + ->groupBy('p.id', 'p.name') + ->orderBy('p.name') + ->get(); + + return $processData->map(function ($process) use ($tenantId, $today) { + $totalWork = (int) $process->total_work; + $todo = (int) $process->todo; + $inProgress = (int) $process->in_progress; + $completed = (int) $process->completed; + + // 작업 아이템 (최대 5건) + $workItems = DB::table('work_orders as wo') + ->leftJoin('orders as o', 'wo.sales_order_id', '=', 'o.id') + ->leftJoin('clients as c', 'o.client_id', '=', 'c.id') + ->select([ + 'wo.id', + 'wo.work_order_no as order_no', + 'c.name as client', + 'wo.project_name as product', + 'wo.status', + ]) + ->where('wo.tenant_id', $tenantId) + ->where('wo.process_id', $process->process_id) + ->where('wo.scheduled_date', $today) + ->where('wo.is_active', true) + ->whereNull('wo.deleted_at') + ->orderByRaw("FIELD(wo.priority, 'urgent', 'normal', 'low')") + ->limit(5) + ->get(); + + // 작업자별 현황 + $workers = DB::table('work_order_assignees as woa') + ->join('work_orders as wo', 'woa.work_order_id', '=', 'wo.id') + ->leftJoin('users as u', 'woa.user_id', '=', 'u.id') + ->select( + 'u.name', + DB::raw('COUNT(*) as assigned'), + DB::raw("SUM(CASE WHEN wo.status IN ('completed', 'shipped') THEN 1 ELSE 0 END) as completed"), + ) + ->where('wo.tenant_id', $tenantId) + ->where('wo.process_id', $process->process_id) + ->where('wo.scheduled_date', $today) + ->where('wo.is_active', true) + ->whereNull('wo.deleted_at') + ->groupBy('woa.user_id', 'u.name') + ->get(); + + return [ + 'process_name' => $process->process_name ?? '미지정', + 'total_work' => $totalWork, + 'todo' => $todo, + 'in_progress' => $inProgress, + 'completed' => $completed, + 'urgent' => (int) $process->urgent, + 'sub_line' => 0, + 'regular' => max(0, $totalWork - (int) $process->urgent), + 'worker_count' => $workers->count(), + 'work_items' => $workItems->map(fn ($wi) => [ + 'id' => 'wo_'.$wi->id, + 'order_no' => $wi->order_no ?? '-', + 'client' => $wi->client ?? '미지정', + 'product' => $wi->product ?? '-', + 'quantity' => 0, + 'status' => $this->mapWorkOrderStatus($wi->status), + ])->toArray(), + 'workers' => $workers->map(fn ($w) => [ + 'name' => $w->name ?? '미지정', + 'assigned' => (int) $w->assigned, + 'completed' => (int) $w->completed, + 'rate' => $w->assigned > 0 ? round(($w->completed / $w->assigned) * 100, 0) : 0, + ])->toArray(), + ]; + })->toArray(); + } + + private function mapWorkOrderStatus(string $status): string + { + return match ($status) { + 'completed', 'shipped' => 'completed', + 'in_progress' => 'in_progress', + default => 'pending', + }; + } + + private function getShipmentSummary(int $tenantId, string $today): array + { + $thisMonth = Carbon::parse($today); + $monthStart = $thisMonth->copy()->startOfMonth()->format('Y-m-d'); + $monthEnd = $thisMonth->copy()->endOfMonth()->format('Y-m-d'); + + // 예정 출고 + $expected = DB::table('shipments') + ->where('tenant_id', $tenantId) + ->whereBetween('scheduled_date', [$monthStart, $monthEnd]) + ->whereIn('status', ['scheduled', 'ready']) + ->whereNull('deleted_at') + ->selectRaw('COUNT(*) as count, COALESCE(SUM(shipping_cost), 0) as amount') + ->first(); + + // 실제 출고 + $actual = DB::table('shipments') + ->where('tenant_id', $tenantId) + ->whereBetween('scheduled_date', [$monthStart, $monthEnd]) + ->whereIn('status', ['shipping', 'completed']) + ->whereNull('deleted_at') + ->selectRaw('COUNT(*) as count, COALESCE(SUM(shipping_cost), 0) as amount') + ->first(); + + return [ + 'expected_amount' => (int) ($expected->amount ?? 0), + 'expected_count' => (int) ($expected->count ?? 0), + 'actual_amount' => (int) ($actual->amount ?? 0), + 'actual_count' => (int) ($actual->count ?? 0), + ]; + } + + // ─── 4. 미출고 내역 ────────────────────────────── + + /** + * 미출고 내역 요약 + */ + public function unshippedSummary(): array + { + $tenantId = $this->tenantId(); + $today = Carbon::now()->format('Y-m-d'); + + $items = DB::table('shipments as s') + ->leftJoin('orders as o', 's.order_id', '=', 'o.id') + ->leftJoin('clients as c', 's.client_id', '=', 'c.id') + ->select([ + 's.id', + 's.lot_no as port_no', + 's.site_name', + 'c.name as order_client', + 's.scheduled_date as due_date', + ]) + ->where('s.tenant_id', $tenantId) + ->whereIn('s.status', ['scheduled', 'ready']) + ->whereNull('s.deleted_at') + ->orderBy('s.scheduled_date') + ->limit(50) + ->get(); + + $result = $items->map(function ($item) use ($today) { + $dueDate = Carbon::parse($item->due_date); + $daysLeft = Carbon::parse($today)->diffInDays($dueDate, false); + + return [ + 'id' => 'us_'.$item->id, + 'port_no' => $item->port_no ?? '-', + 'site_name' => $item->site_name ?? '-', + 'order_client' => $item->order_client ?? '미지정', + 'due_date' => $item->due_date, + 'days_left' => (int) $daysLeft, + ]; + })->toArray(); + + return [ + 'items' => $result, + 'total_count' => count($result), + ]; + } + + // ─── 5. 시공 현황 ─────────────────────────────── + + /** + * 시공 현황 요약 + */ + public function constructionSummary(): array + { + $tenantId = $this->tenantId(); + $now = Carbon::now(); + $monthStart = $now->copy()->startOfMonth()->format('Y-m-d'); + $monthEnd = $now->copy()->endOfMonth()->format('Y-m-d'); + + // 이번 달 시공 건수 + $thisMonthCount = DB::table('contracts') + ->where('tenant_id', $tenantId) + ->where(function ($q) use ($monthStart, $monthEnd) { + $q->whereBetween('contract_start_date', [$monthStart, $monthEnd]) + ->orWhereBetween('contract_end_date', [$monthStart, $monthEnd]) + ->orWhere(function ($q2) use ($monthStart, $monthEnd) { + $q2->where('contract_start_date', '<=', $monthStart) + ->where('contract_end_date', '>=', $monthEnd); + }); + }) + ->where('is_active', true) + ->whereNull('deleted_at') + ->count(); + + // 완료 건수 + $completedCount = DB::table('contracts') + ->where('tenant_id', $tenantId) + ->where('status', 'completed') + ->where(function ($q) use ($monthStart, $monthEnd) { + $q->whereBetween('contract_end_date', [$monthStart, $monthEnd]); + }) + ->where('is_active', true) + ->whereNull('deleted_at') + ->count(); + + // 시공 아이템 목록 + $items = DB::table('contracts as ct') + ->leftJoin('users as u', 'ct.construction_pm_id', '=', 'u.id') + ->select([ + 'ct.id', + 'ct.project_name as site_name', + 'ct.partner_name as client', + 'ct.contract_start_date as start_date', + 'ct.contract_end_date as end_date', + 'ct.status', + 'ct.stage', + ]) + ->where('ct.tenant_id', $tenantId) + ->where('ct.is_active', true) + ->whereNull('ct.deleted_at') + ->where(function ($q) use ($monthStart, $monthEnd) { + $q->whereBetween('ct.contract_start_date', [$monthStart, $monthEnd]) + ->orWhereBetween('ct.contract_end_date', [$monthStart, $monthEnd]) + ->orWhere(function ($q2) use ($monthStart, $monthEnd) { + $q2->where('ct.contract_start_date', '<=', $monthStart) + ->where('ct.contract_end_date', '>=', $monthEnd); + }); + }) + ->orderBy('ct.contract_start_date') + ->limit(20) + ->get(); + + $today = $now->format('Y-m-d'); + + return [ + 'this_month' => $thisMonthCount, + 'completed' => $completedCount, + 'items' => $items->map(function ($item) use ($today) { + $progress = $this->calculateContractProgress($item, $today); + + return [ + 'id' => 'c_'.$item->id, + 'site_name' => $item->site_name ?? '-', + 'client' => $item->client ?? '미지정', + 'start_date' => $item->start_date, + 'end_date' => $item->end_date, + 'progress' => $progress, + 'status' => $this->mapContractStatus($item->status, $item->start_date, $today), + ]; + })->toArray(), + ]; + } + + private function calculateContractProgress(object $contract, string $today): int + { + if ($contract->status === 'completed') { + return 100; + } + + $start = Carbon::parse($contract->start_date); + $end = Carbon::parse($contract->end_date); + $now = Carbon::parse($today); + + if ($now->lt($start)) { + return 0; + } + + $totalDays = $start->diffInDays($end); + if ($totalDays <= 0) { + return 0; + } + + $elapsedDays = $start->diffInDays($now); + $progress = min(99, round(($elapsedDays / $totalDays) * 100)); + + return (int) $progress; + } + + private function mapContractStatus(string $status, ?string $startDate, string $today): string + { + if ($status === 'completed') { + return 'completed'; + } + + if ($startDate && Carbon::parse($startDate)->gt(Carbon::parse($today))) { + return 'scheduled'; + } + + return 'in_progress'; + } + + // ─── 6. 근태 현황 ─────────────────────────────── + + /** + * 근태 현황 요약 + */ + public function attendanceSummary(): array + { + $tenantId = $this->tenantId(); + $today = Carbon::now()->format('Y-m-d'); + + // 오늘 근태 기록 + $attendances = DB::table('attendances as a') + ->leftJoin('users as u', 'a.user_id', '=', 'u.id') + ->leftJoin('departments as d', 'u.department_id', '=', 'd.id') + ->select([ + 'a.id', + 'a.status', + 'u.name', + 'd.name as department', + 'u.position', + ]) + ->where('a.tenant_id', $tenantId) + ->where('a.base_date', $today) + ->whereNull('a.deleted_at') + ->get(); + + $present = 0; + $onLeave = 0; + $late = 0; + $absent = 0; + + $employees = $attendances->map(function ($att) use (&$present, &$onLeave, &$late, &$absent) { + $mappedStatus = $this->mapAttendanceStatus($att->status); + + match ($mappedStatus) { + 'present' => $present++, + 'on_leave' => $onLeave++, + 'late' => $late++, + 'absent' => $absent++, + default => null, + }; + + return [ + 'id' => 'emp_'.$att->id, + 'department' => $att->department ?? '-', + 'position' => $att->position ?? '-', + 'name' => $att->name ?? '-', + 'status' => $mappedStatus, + ]; + })->toArray(); + + return [ + 'present' => $present, + 'on_leave' => $onLeave, + 'late' => $late, + 'absent' => $absent, + 'employees' => $employees, + ]; + } + + private function mapAttendanceStatus(?string $status): string + { + return match ($status) { + 'onTime', 'normal', 'overtime', 'earlyLeave' => 'present', + 'late', 'lateEarlyLeave' => 'late', + 'vacation', 'halfDayVacation', 'sickLeave' => 'on_leave', + 'absent', 'noRecord' => 'absent', + default => 'present', + }; + } +} diff --git a/routes/api/v1/common.php b/routes/api/v1/common.php index ede81c7..c37c1ef 100644 --- a/routes/api/v1/common.php +++ b/routes/api/v1/common.php @@ -18,6 +18,7 @@ use App\Http\Controllers\Api\V1\CategoryTemplateController; use App\Http\Controllers\Api\V1\ClassificationController; use App\Http\Controllers\Api\V1\CommonController; +use App\Http\Controllers\Api\V1\DashboardCeoController; use App\Http\Controllers\Api\V1\DashboardController; use App\Http\Controllers\Api\V1\MenuController; use App\Http\Controllers\Api\V1\NotificationSettingController; @@ -225,4 +226,12 @@ 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'); + + // CEO 대시보드 섹션별 API + Route::get('/sales/summary', [DashboardCeoController::class, 'salesSummary'])->name('v1.dashboard.ceo.sales'); + Route::get('/purchases/summary', [DashboardCeoController::class, 'purchasesSummary'])->name('v1.dashboard.ceo.purchases'); + Route::get('/production/summary', [DashboardCeoController::class, 'productionSummary'])->name('v1.dashboard.ceo.production'); + Route::get('/unshipped/summary', [DashboardCeoController::class, 'unshippedSummary'])->name('v1.dashboard.ceo.unshipped'); + Route::get('/construction/summary', [DashboardCeoController::class, 'constructionSummary'])->name('v1.dashboard.ceo.construction'); + Route::get('/attendance/summary', [DashboardCeoController::class, 'attendanceSummary'])->name('v1.dashboard.ceo.attendance'); }); diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index aef25d2..0ce30b9 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -183,6 +183,7 @@ 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'); + Route::get('/export', [DailyReportController::class, 'export'])->name('v1.daily-report.export'); }); // Comprehensive Analysis API (종합 분석 보고서) From f1a3e0f1649a6f700ef369ae1deaa9d9d7a58afa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Thu, 5 Mar 2026 09:41:54 +0900 Subject: [PATCH 053/166] =?UTF-8?q?fix:=20[dashboard-ceo]=20=EA=B3=B5?= =?UTF-8?q?=EC=A0=95=EB=AA=85=20=EC=BB=AC=EB=9F=BC=20=EB=B0=8F=20=EA=B7=BC?= =?UTF-8?q?=ED=83=9C=20=EB=B6=80=EC=84=9C=20=EC=A1=B0=EC=9D=B8=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 - processes 테이블: p.name → p.process_name 컬럼명 수정 - 근태: users.department_id → tenant_user_profiles 경유 조인으로 변경 - 직급: users.position → tup.position_key 컬럼 수정 Co-Authored-By: Claude Opus 4.6 --- app/Services/DashboardCeoService.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/Services/DashboardCeoService.php b/app/Services/DashboardCeoService.php index d021e4c..1dccd32 100644 --- a/app/Services/DashboardCeoService.php +++ b/app/Services/DashboardCeoService.php @@ -364,7 +364,7 @@ private function getProductionProcesses(int $tenantId, string $today): array ->leftJoin('processes as p', 'wo.process_id', '=', 'p.id') ->select( 'p.id as process_id', - 'p.name as process_name', + 'p.process_name as process_name', DB::raw('COUNT(*) as total_work'), DB::raw("SUM(CASE WHEN wo.status = 'pending' OR wo.status = 'unassigned' OR wo.status = 'waiting' THEN 1 ELSE 0 END) as todo"), DB::raw("SUM(CASE WHEN wo.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress"), @@ -376,8 +376,8 @@ private function getProductionProcesses(int $tenantId, string $today): array ->where('wo.is_active', true) ->whereNull('wo.deleted_at') ->whereNotNull('wo.process_id') - ->groupBy('p.id', 'p.name') - ->orderBy('p.name') + ->groupBy('p.id', 'p.process_name') + ->orderBy('p.process_name') ->get(); return $processData->map(function ($process) use ($tenantId, $today) { @@ -676,13 +676,17 @@ public function attendanceSummary(): array // 오늘 근태 기록 $attendances = DB::table('attendances as a') ->leftJoin('users as u', 'a.user_id', '=', 'u.id') - ->leftJoin('departments as d', 'u.department_id', '=', 'd.id') + ->leftJoin('tenant_user_profiles as tup', function ($join) use ($tenantId) { + $join->on('tup.user_id', '=', 'u.id') + ->where('tup.tenant_id', '=', $tenantId); + }) + ->leftJoin('departments as d', 'tup.department_id', '=', 'd.id') ->select([ 'a.id', 'a.status', 'u.name', 'd.name as department', - 'u.position', + 'tup.position_key as position', ]) ->where('a.tenant_id', $tenantId) ->where('a.base_date', $today) From 1b2363d6616487766972fecebbd12fca09f023e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Thu, 5 Mar 2026 10:06:48 +0900 Subject: [PATCH 054/166] =?UTF-8?q?feat:=20[daily-report]=20=EC=97=91?= =?UTF-8?q?=EC=85=80=20=EB=82=B4=EB=B3=B4=EB=82=B4=EA=B8=B0=EC=97=90=20?= =?UTF-8?q?=EC=96=B4=EC=9D=8C/=EC=99=B8=EC=83=81=EB=A7=A4=EC=B6=9C?= =?UTF-8?q?=EC=B1=84=EA=B6=8C=20=ED=98=84=ED=99=A9=20=EC=84=B9=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 - DailyReportExport: 어음 현황 테이블 + 합계 + 스타일링 추가 - DailyReportService: exportData에 noteReceivables 데이터 포함 Co-Authored-By: Claude Opus 4.6 --- app/Exports/DailyReportExport.php | 61 +++++++++++++++++++++++++++-- app/Services/DailyReportService.php | 4 ++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/app/Exports/DailyReportExport.php b/app/Exports/DailyReportExport.php index f8c4eee..f90c18b 100644 --- a/app/Exports/DailyReportExport.php +++ b/app/Exports/DailyReportExport.php @@ -7,6 +7,7 @@ use Maatwebsite\Excel\Concerns\WithHeadings; use Maatwebsite\Excel\Concerns\WithStyles; use Maatwebsite\Excel\Concerns\WithTitle; +use PhpOffice\PhpSpreadsheet\Style\Fill; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; class DailyReportExport implements FromArray, ShouldAutoSize, WithHeadings, WithStyles, WithTitle @@ -47,6 +48,7 @@ public function array(): array { $rows = []; + // ── 예금 입출금 내역 ── foreach ($this->report['details'] as $detail) { $rows[] = [ $detail['type_label'], @@ -58,7 +60,7 @@ public function array(): array ]; } - // 합계 행 추가 + // 합계 행 $rows[] = []; $rows[] = [ '합계', @@ -69,6 +71,37 @@ public function array(): array '', ]; + // ── 어음 및 외상매출채권 현황 ── + $noteReceivables = $this->report['note_receivables'] ?? []; + + $rows[] = []; + $rows[] = []; + $rows[] = ['어음 및 외상매출채권 현황']; + $rows[] = ['No.', '내용', '금액', '발행일', '만기일']; + + $noteTotal = 0; + $no = 1; + foreach ($noteReceivables as $item) { + $amount = $item['current_balance'] ?? 0; + $noteTotal += $amount; + $rows[] = [ + $no++, + $item['content'] ?? '-', + $amount > 0 ? number_format($amount) : '', + $item['issue_date'] ?? '-', + $item['due_date'] ?? '-', + ]; + } + + // 어음 합계 + $rows[] = [ + '합계', + '', + number_format($noteTotal), + '', + '', + ]; + return $rows; } @@ -77,7 +110,7 @@ public function array(): array */ public function styles(Worksheet $sheet): array { - return [ + $styles = [ 1 => ['font' => ['bold' => true, 'size' => 14]], 3 => ['font' => ['bold' => true]], 4 => ['font' => ['bold' => true]], @@ -86,10 +119,32 @@ public function styles(Worksheet $sheet): array 8 => [ 'font' => ['bold' => true], 'fill' => [ - 'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID, + 'fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'E0E0E0'], ], ], ]; + + // 어음 섹션 헤더 스타일 (동적 행 번호) + // headings 8행 + details 수 + 합계 2행 + 빈 2행 + 어음 제목 1행 + 어음 헤더 1행 + $detailCount = count($this->report['details']); + $noteHeaderTitleRow = 8 + $detailCount + 2 + 2 + 1; // 어음 제목 행 + $noteHeaderRow = $noteHeaderTitleRow + 1; // 어음 컬럼 헤더 행 + + $styles[$noteHeaderTitleRow] = ['font' => ['bold' => true, 'size' => 12]]; + $styles[$noteHeaderRow] = [ + 'font' => ['bold' => true], + 'fill' => [ + 'fillType' => Fill::FILL_SOLID, + 'startColor' => ['rgb' => 'E0E0E0'], + ], + ]; + + // 어음 합계 행 + $noteCount = count($this->report['note_receivables'] ?? []); + $noteTotalRow = $noteHeaderRow + $noteCount + 1; + $styles[$noteTotalRow] = ['font' => ['bold' => true]]; + + return $styles; } } diff --git a/app/Services/DailyReportService.php b/app/Services/DailyReportService.php index d2be5ca..8069ffc 100644 --- a/app/Services/DailyReportService.php +++ b/app/Services/DailyReportService.php @@ -242,6 +242,9 @@ public function exportData(array $params): array ]; } + // 어음 및 외상매출채권 현황 + $noteReceivables = $this->noteReceivables($params); + return [ 'date' => $dateStr, 'previous_balance' => $previousBalance, @@ -249,6 +252,7 @@ public function exportData(array $params): array 'daily_withdrawal' => $dailyWithdrawalTotal, 'current_balance' => $currentBalance, 'details' => $details, + 'note_receivables' => $noteReceivables, ]; } From fefd12979557b52c9bb81a12d1c566c8fd1799c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Thu, 5 Mar 2026 10:44:29 +0900 Subject: [PATCH 055/166] =?UTF-8?q?refactor:=20[daily-report]=20=EC=97=91?= =?UTF-8?q?=EC=85=80=20=EB=82=B4=EB=B3=B4=EB=82=B4=EA=B8=B0=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DailyReportService: exportData를 dailyAccounts() 재사용 구조로 변경 - DailyReportExport: 헤더 라벨 전월이월/당월입금/당월출금/잔액으로 수정 - 화면 합계와 엑셀 합계 일치하도록 개선 Co-Authored-By: Claude Opus 4.6 --- app/Exports/DailyReportExport.php | 8 +-- app/Services/DailyReportService.php | 86 +++++++++++++---------------- 2 files changed, 42 insertions(+), 52 deletions(-) diff --git a/app/Exports/DailyReportExport.php b/app/Exports/DailyReportExport.php index f90c18b..3c7fcaa 100644 --- a/app/Exports/DailyReportExport.php +++ b/app/Exports/DailyReportExport.php @@ -32,10 +32,10 @@ public function headings(): array return [ ['일일 일보 - '.$this->report['date']], [], - ['전일 잔액', number_format($this->report['previous_balance']).'원'], - ['당일 입금액', number_format($this->report['daily_deposit']).'원'], - ['당일 출금액', number_format($this->report['daily_withdrawal']).'원'], - ['당일 잔액', number_format($this->report['current_balance']).'원'], + ['전월 이월', number_format($this->report['previous_balance']).'원'], + ['당월 입금', number_format($this->report['daily_deposit']).'원'], + ['당월 출금', number_format($this->report['daily_withdrawal']).'원'], + ['잔액', number_format($this->report['current_balance']).'원'], [], ['구분', '거래처명', '계정과목', '입금액', '출금액', '적요'], ]; diff --git a/app/Services/DailyReportService.php b/app/Services/DailyReportService.php index 8069ffc..188ea79 100644 --- a/app/Services/DailyReportService.php +++ b/app/Services/DailyReportService.php @@ -188,58 +188,48 @@ public function summary(array $params): array */ public function exportData(array $params): array { - $tenantId = $this->tenantId(); $date = isset($params['date']) ? Carbon::parse($params['date']) : Carbon::today(); $dateStr = $date->format('Y-m-d'); - $startOfDay = $date->copy()->startOfDay(); - $endOfDay = $date->copy()->endOfDay(); - // 전일 잔액 계산 (전일까지 입금 합계 - 전일까지 출금 합계) - $prevDeposits = Deposit::where('tenant_id', $tenantId) - ->where('deposit_date', '<', $startOfDay) - ->sum('amount'); - $prevWithdrawals = Withdrawal::where('tenant_id', $tenantId) - ->where('withdrawal_date', '<', $startOfDay) - ->sum('amount'); - $previousBalance = (float) ($prevDeposits - $prevWithdrawals); + // 화면과 동일한 계좌별 현황 데이터 재사용 + $dailyAccounts = $this->dailyAccounts($params); - // 당일 입금 - $dailyDeposits = Deposit::where('tenant_id', $tenantId) - ->whereBetween('deposit_date', [$startOfDay, $endOfDay]) - ->get(); - $dailyDepositTotal = (float) $dailyDeposits->sum('amount'); + // KRW 계좌 합산 (화면 합계와 동일) + $carryover = 0; + $totalIncome = 0; + $totalExpense = 0; + $totalBalance = 0; - // 당일 출금 - $dailyWithdrawals = Withdrawal::where('tenant_id', $tenantId) - ->whereBetween('withdrawal_date', [$startOfDay, $endOfDay]) - ->get(); - $dailyWithdrawalTotal = (float) $dailyWithdrawals->sum('amount'); - - $currentBalance = $previousBalance + $dailyDepositTotal - $dailyWithdrawalTotal; - - // 상세 내역 조합 $details = []; - foreach ($dailyDeposits as $d) { - $details[] = [ - 'type_label' => '입금', - 'client_name' => $d->client?->name ?? '-', - 'account_code' => $d->account_code ?? '-', - 'deposit_amount' => (float) $d->amount, - 'withdrawal_amount' => 0, - 'description' => $d->description ?? '', - ]; - } + foreach ($dailyAccounts as $account) { + $carryover += $account['carryover']; + $totalIncome += $account['income']; + $totalExpense += $account['expense']; + $totalBalance += $account['balance']; - foreach ($dailyWithdrawals as $w) { - $details[] = [ - 'type_label' => '출금', - 'client_name' => $w->client?->name ?? '-', - 'account_code' => $w->account_code ?? '-', - 'deposit_amount' => 0, - 'withdrawal_amount' => (float) $w->amount, - 'description' => $w->description ?? '', - ]; + // 계좌별 상세 내역 + if ($account['income'] > 0) { + $details[] = [ + 'type_label' => '입금', + 'client_name' => $account['category'], + 'account_code' => '-', + 'deposit_amount' => $account['income'], + 'withdrawal_amount' => 0, + 'description' => '', + ]; + } + + if ($account['expense'] > 0) { + $details[] = [ + 'type_label' => '출금', + 'client_name' => $account['category'], + 'account_code' => '-', + 'deposit_amount' => 0, + 'withdrawal_amount' => $account['expense'], + 'description' => '', + ]; + } } // 어음 및 외상매출채권 현황 @@ -247,10 +237,10 @@ public function exportData(array $params): array return [ 'date' => $dateStr, - 'previous_balance' => $previousBalance, - 'daily_deposit' => $dailyDepositTotal, - 'daily_withdrawal' => $dailyWithdrawalTotal, - 'current_balance' => $currentBalance, + 'previous_balance' => $carryover, + 'daily_deposit' => $totalIncome, + 'daily_withdrawal' => $totalExpense, + 'current_balance' => $totalBalance, 'details' => $details, 'note_receivables' => $noteReceivables, ]; From fc537898fcfd4c7b0f7e5717fdce27b738fc70ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 3 Mar 2026 20:57:44 +0900 Subject: [PATCH 056/166] =?UTF-8?q?fix:=20[production]=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=ED=88=AC=EC=9E=85=20=EB=AA=A8=EB=8B=AC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=E2=80=94=20lot=5Fmanaged=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EB=A7=81,=20BOM=20=EA=B7=B8=EB=A3=B9=ED=82=A4,=20=EC=85=94?= =?UTF-8?q?=ED=84=B0=EB=B0=95=EC=8A=A4=20=EC=88=9C=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getMaterialsForItem: lot_managed===false 품목 자재투입 목록에서 제외 (L-Bar, 보강평철) - getMaterialsForItem: bom_group_key 필드 추가 (category+partType 기반 고유키) - BendingInfoBuilder: shutterPartTypes에서 top_cover/fin_cover 제거 (별도 생성과 중복) - BendingInfoBuilder: 셔터박스 루프 순서 파트→길이로 변경 (작업일지 순서 일치) Co-Authored-By: Claude Opus 4.6 --- .../Production/BendingInfoBuilder.php | 17 ++++++++------- app/Services/WorkOrderService.php | 21 ++++++++++++++++++- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/app/Services/Production/BendingInfoBuilder.php b/app/Services/Production/BendingInfoBuilder.php index cad62df..37ac551 100644 --- a/app/Services/Production/BendingInfoBuilder.php +++ b/app/Services/Production/BendingInfoBuilder.php @@ -220,14 +220,17 @@ public function buildDynamicBomForItem(array $context, int $width, int $height, if ($boxSize) { $isStandard = $boxSize === '500*380'; $dist = $this->shutterBoxDistribution($width); - $shutterPartTypes = ['front', 'lintel', 'inspection', 'rear_corner', 'top_cover', 'fin_cover']; + // 상부덮개(top_cover), 마구리(fin_cover)는 1219mm 기준으로 별도 생성 (아래 256행~) + $shutterPartTypes = ['front', 'lintel', 'inspection', 'rear_corner']; + + // 작업일지와 동일한 순서: 파트 → 길이 + foreach ($shutterPartTypes as $partType) { + foreach ($dist as $length => $count) { + $totalCount = $count * $qty; + if ($totalCount <= 0) { + continue; + } - foreach ($dist as $length => $count) { - $totalCount = $count * $qty; - if ($totalCount <= 0) { - continue; - } - foreach ($shutterPartTypes as $partType) { $prefix = $resolver->resolveShutterBoxPrefix($partType, $isStandard); $itemCode = $resolver->buildItemCode($prefix, $length); if (! $itemCode) { diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index 77d6a73..bb5c5f9 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -3165,6 +3165,12 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array continue; } + // LOT 관리 대상이 아닌 품목은 자재투입 목록에서 제외 + $childOptions = $childItems[$childItemId]->options ?? []; + if (isset($childOptions['lot_managed']) && $childOptions['lot_managed'] === false) { + continue; + } + // dynamic_bom.qty는 아이템 수량이 곱해져 있으므로 나눠서 개소당 수량 산출 // (작업일지 bendingInfo와 동일한 수량) $bomQty = (float) ($bomEntry['qty'] ?? 1); @@ -3202,6 +3208,12 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array continue; } + // LOT 관리 대상이 아닌 품목은 자재투입 목록에서 제외 + $childOptions = $childItem->options ?? []; + if (isset($childOptions['lot_managed']) && $childOptions['lot_managed'] === false) { + continue; + } + $materialItems[] = [ 'item' => $childItem, 'bom_qty' => $bomQty, @@ -3232,11 +3244,16 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array $materials = []; $rank = 1; - foreach ($materialItems as $matInfo) { + foreach ($materialItems as $bomIdx => $matInfo) { $materialItem = $matInfo['item']; $alreadyInputted = (float) ($inputtedQties[$materialItem->id] ?? 0); $remainingRequired = max(0, $matInfo['required_qty'] - $alreadyInputted); + // BOM 엔트리별 고유 그룹키 (같은 item_id라도 category+partType이 다르면 별도 그룹) + $bomGroupKey = $materialItem->id + .'_'.($matInfo['category'] ?? '') + .'_'.($matInfo['part_type'] ?? ''); + $stock = \App\Models\Tenants\Stock::where('tenant_id', $tenantId) ->where('item_id', $materialItem->id) ->first(); @@ -3256,6 +3273,7 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array $materials[] = [ 'stock_lot_id' => $lot->id, 'item_id' => $materialItem->id, + 'bom_group_key' => $bomGroupKey, 'lot_no' => $lot->lot_no, 'material_code' => $materialItem->code, 'material_name' => $materialItem->name, @@ -3282,6 +3300,7 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array $materials[] = [ 'stock_lot_id' => null, 'item_id' => $materialItem->id, + 'bom_group_key' => $bomGroupKey, 'lot_no' => null, 'material_code' => $materialItem->code, 'material_name' => $materialItem->name, From 851862143237b493315b104320075e6efe9af92d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 4 Mar 2026 10:13:16 +0900 Subject: [PATCH 057/166] =?UTF-8?q?feat:=20[shipment]=20=EB=B0=B0=EC=B0=A8?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EB=8B=A4=EC=A4=91=20=ED=96=89=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=E2=80=94=20shipment=5Fvehicle=5Fdispatche?= =?UTF-8?q?s=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 신규 마이그레이션: shipment_vehicle_dispatches 테이블 (seq, logistics_company, arrival_datetime, tonnage, vehicle_no, driver_contact, remarks) - 신규 모델: ShipmentVehicleDispatch (ShipmentItem 패턴 복제) - Shipment 모델: vehicleDispatches() HasMany 관계 추가 - ShipmentService: syncDispatches() 추가, store/update/delete/show/index에서 연동 - FormRequest: Store/Update에 vehicle_dispatches 배열 검증 규칙 추가 - delivery_method 검증에 확장 옵션 추가 Co-Authored-By: Claude Opus 4.6 --- .../Shipment/ShipmentStoreRequest.php | 12 ++++- .../Shipment/ShipmentUpdateRequest.php | 12 ++++- app/Models/Tenants/Shipment.php | 8 +++ .../Tenants/ShipmentVehicleDispatch.php | 50 +++++++++++++++++++ app/Services/ShipmentService.php | 49 ++++++++++++++++-- ...eate_shipment_vehicle_dispatches_table.php | 43 ++++++++++++++++ 6 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 app/Models/Tenants/ShipmentVehicleDispatch.php create mode 100644 database/migrations/2026_03_04_100000_create_shipment_vehicle_dispatches_table.php diff --git a/app/Http/Requests/Shipment/ShipmentStoreRequest.php b/app/Http/Requests/Shipment/ShipmentStoreRequest.php index a00fbc7..c384e41 100644 --- a/app/Http/Requests/Shipment/ShipmentStoreRequest.php +++ b/app/Http/Requests/Shipment/ShipmentStoreRequest.php @@ -21,7 +21,7 @@ public function rules(): array 'scheduled_date' => 'required|date', 'status' => 'nullable|in:scheduled,ready,shipping,completed', 'priority' => 'nullable|in:urgent,normal,low', - 'delivery_method' => 'nullable|in:pickup,direct,logistics', + 'delivery_method' => 'nullable|in:pickup,direct,logistics,direct_dispatch,loading,kyungdong_delivery,daesin_delivery,kyungdong_freight,daesin_freight,self_pickup', // 발주처/배송 정보 'client_id' => 'nullable|integer|exists:clients,id', @@ -55,6 +55,16 @@ public function rules(): array // 기타 'remarks' => 'nullable|string', + // 배차정보 + 'vehicle_dispatches' => 'nullable|array', + 'vehicle_dispatches.*.seq' => 'nullable|integer|min:1', + 'vehicle_dispatches.*.logistics_company' => 'nullable|string|max:100', + 'vehicle_dispatches.*.arrival_datetime' => 'nullable|date', + 'vehicle_dispatches.*.tonnage' => 'nullable|string|max:20', + 'vehicle_dispatches.*.vehicle_no' => 'nullable|string|max:20', + 'vehicle_dispatches.*.driver_contact' => 'nullable|string|max:50', + 'vehicle_dispatches.*.remarks' => 'nullable|string', + // 출하 품목 'items' => 'nullable|array', 'items.*.seq' => 'nullable|integer|min:1', diff --git a/app/Http/Requests/Shipment/ShipmentUpdateRequest.php b/app/Http/Requests/Shipment/ShipmentUpdateRequest.php index f86c4a2..43b4b14 100644 --- a/app/Http/Requests/Shipment/ShipmentUpdateRequest.php +++ b/app/Http/Requests/Shipment/ShipmentUpdateRequest.php @@ -19,7 +19,7 @@ public function rules(): array 'order_id' => 'nullable|integer|exists:orders,id', 'scheduled_date' => 'nullable|date', 'priority' => 'nullable|in:urgent,normal,low', - 'delivery_method' => 'nullable|in:pickup,direct,logistics', + 'delivery_method' => 'nullable|in:pickup,direct,logistics,direct_dispatch,loading,kyungdong_delivery,daesin_delivery,kyungdong_freight,daesin_freight,self_pickup', // 발주처/배송 정보 'client_id' => 'nullable|integer|exists:clients,id', @@ -53,6 +53,16 @@ public function rules(): array // 기타 'remarks' => 'nullable|string', + // 배차정보 + 'vehicle_dispatches' => 'nullable|array', + 'vehicle_dispatches.*.seq' => 'nullable|integer|min:1', + 'vehicle_dispatches.*.logistics_company' => 'nullable|string|max:100', + 'vehicle_dispatches.*.arrival_datetime' => 'nullable|date', + 'vehicle_dispatches.*.tonnage' => 'nullable|string|max:20', + 'vehicle_dispatches.*.vehicle_no' => 'nullable|string|max:20', + 'vehicle_dispatches.*.driver_contact' => 'nullable|string|max:50', + 'vehicle_dispatches.*.remarks' => 'nullable|string', + // 출하 품목 'items' => 'nullable|array', 'items.*.seq' => 'nullable|integer|min:1', diff --git a/app/Models/Tenants/Shipment.php b/app/Models/Tenants/Shipment.php index a02db8c..df96ff5 100644 --- a/app/Models/Tenants/Shipment.php +++ b/app/Models/Tenants/Shipment.php @@ -134,6 +134,14 @@ public function items(): HasMany return $this->hasMany(ShipmentItem::class)->orderBy('seq'); } + /** + * 배차정보 관계 + */ + public function vehicleDispatches(): HasMany + { + return $this->hasMany(ShipmentVehicleDispatch::class)->orderBy('seq'); + } + /** * 거래처 관계 */ diff --git a/app/Models/Tenants/ShipmentVehicleDispatch.php b/app/Models/Tenants/ShipmentVehicleDispatch.php new file mode 100644 index 0000000..7db88a0 --- /dev/null +++ b/app/Models/Tenants/ShipmentVehicleDispatch.php @@ -0,0 +1,50 @@ + 'integer', + 'shipment_id' => 'integer', + 'arrival_datetime' => 'datetime', + ]; + + /** + * 출하 관계 + */ + public function shipment(): BelongsTo + { + return $this->belongsTo(Shipment::class); + } + + /** + * 다음 순번 가져오기 + */ + public static function getNextSeq(int $shipmentId): int + { + $maxSeq = static::where('shipment_id', $shipmentId)->max('seq'); + + return ($maxSeq ?? 0) + 1; + } +} diff --git a/app/Services/ShipmentService.php b/app/Services/ShipmentService.php index 109b45d..8d42603 100644 --- a/app/Services/ShipmentService.php +++ b/app/Services/ShipmentService.php @@ -5,6 +5,7 @@ use App\Models\Orders\Order; use App\Models\Tenants\Shipment; use App\Models\Tenants\ShipmentItem; +use App\Models\Tenants\ShipmentVehicleDispatch; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; @@ -19,7 +20,7 @@ public function index(array $params): LengthAwarePaginator $query = Shipment::query() ->where('tenant_id', $tenantId) - ->with(['items', 'order.client', 'order.writer', 'workOrder']); + ->with(['items', 'vehicleDispatches', 'order.client', 'order.writer', 'workOrder']); // 검색어 필터 if (! empty($params['search'])) { @@ -164,6 +165,7 @@ public function show(int $id): Shipment 'items' => function ($query) { $query->orderBy('seq'); }, + 'vehicleDispatches', 'order.client', 'order.writer', 'workOrder', @@ -228,7 +230,12 @@ public function store(array $data): Shipment $this->syncItems($shipment, $data['items'], $tenantId); } - return $shipment->load('items'); + // 배차정보 추가 + if (! empty($data['vehicle_dispatches'])) { + $this->syncDispatches($shipment, $data['vehicle_dispatches'], $tenantId); + } + + return $shipment->load(['items', 'vehicleDispatches']); }); } @@ -283,7 +290,12 @@ public function update(int $id, array $data): Shipment $this->syncItems($shipment, $data['items'], $tenantId); } - return $shipment->load('items'); + // 배차정보 동기화 + if (isset($data['vehicle_dispatches'])) { + $this->syncDispatches($shipment, $data['vehicle_dispatches'], $tenantId); + } + + return $shipment->load(['items', 'vehicleDispatches']); }); } @@ -340,7 +352,7 @@ public function updateStatus(int $id, string $status, ?array $additionalData = n // 연결된 수주(Order) 상태 동기화 $this->syncOrderStatus($shipment, $tenantId); - return $shipment->load('items'); + return $shipment->load(['items', 'vehicleDispatches']); } /** @@ -439,6 +451,9 @@ public function delete(int $id): bool // 품목 삭제 $shipment->items()->delete(); + // 배차정보 삭제 + $shipment->vehicleDispatches()->delete(); + // 출하 삭제 $shipment->update(['deleted_by' => $userId]); $shipment->delete(); @@ -477,6 +492,32 @@ protected function syncItems(Shipment $shipment, array $items, int $tenantId): v } } + /** + * 배차정보 동기화 + */ + protected function syncDispatches(Shipment $shipment, array $dispatches, int $tenantId): void + { + // 기존 배차정보 삭제 + $shipment->vehicleDispatches()->forceDelete(); + + // 새 배차정보 생성 + $seq = 1; + foreach ($dispatches as $dispatch) { + ShipmentVehicleDispatch::create([ + 'tenant_id' => $tenantId, + 'shipment_id' => $shipment->id, + 'seq' => $dispatch['seq'] ?? $seq, + 'logistics_company' => $dispatch['logistics_company'] ?? null, + 'arrival_datetime' => $dispatch['arrival_datetime'] ?? null, + 'tonnage' => $dispatch['tonnage'] ?? null, + 'vehicle_no' => $dispatch['vehicle_no'] ?? null, + 'driver_contact' => $dispatch['driver_contact'] ?? null, + 'remarks' => $dispatch['remarks'] ?? null, + ]); + $seq++; + } + } + /** * LOT 옵션 조회 (출고 가능한 LOT 목록) */ diff --git a/database/migrations/2026_03_04_100000_create_shipment_vehicle_dispatches_table.php b/database/migrations/2026_03_04_100000_create_shipment_vehicle_dispatches_table.php new file mode 100644 index 0000000..e1393b2 --- /dev/null +++ b/database/migrations/2026_03_04_100000_create_shipment_vehicle_dispatches_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('tenant_id')->comment('테넌트 ID'); + $table->foreignId('shipment_id')->comment('출하 ID'); + $table->integer('seq')->default(1)->comment('순번'); + $table->string('logistics_company', 100)->nullable()->comment('물류사'); + $table->datetime('arrival_datetime')->nullable()->comment('도착일시'); + $table->string('tonnage', 20)->nullable()->comment('톤수'); + $table->string('vehicle_no', 20)->nullable()->comment('차량번호'); + $table->string('driver_contact', 50)->nullable()->comment('운전자 연락처'); + $table->text('remarks')->nullable()->comment('비고'); + $table->timestamps(); + $table->softDeletes(); + + // 인덱스 + $table->index(['shipment_id', 'seq']); + + // 외래키 + $table->foreign('shipment_id')->references('id')->on('shipments')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shipment_vehicle_dispatches'); + } +}; From 5ee97c2d74e99ce5c5edc0c2936fa9e77f22db65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 4 Mar 2026 10:36:38 +0900 Subject: [PATCH 058/166] =?UTF-8?q?fix:=20[production]=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=ED=88=AC=EC=9E=85=20bom=5Fgroup=5Fkey=20=EA=B0=9C?= =?UTF-8?q?=EB=B3=84=20=EC=A0=80=EC=9E=A5=20=E2=80=94=20=EB=8F=99=EC=9D=BC?= =?UTF-8?q?=20=EC=9E=90=EC=9E=AC=20=EB=8B=A4=EC=A4=91=20BOM=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bom_group_key 컬럼 추가 마이그레이션 (work_order_material_inputs) - WorkOrderMaterialInput 모델 fillable에 bom_group_key 추가 - MaterialInputForItemRequest에 bom_group_key 검증 + replace 옵션 추가 - WorkOrderService.getMaterialsForItem: stock_lot_id+bom_group_key 복합키 기투입 조회 (하위호환) - WorkOrderService.registerMaterialInputForItem: bom_group_key 저장 + replace 모드 (기존 삭제→재등록) Co-Authored-By: Claude Opus 4.6 --- .../WorkOrder/MaterialInputForItemRequest.php | 2 + .../Production/WorkOrderMaterialInput.php | 1 + app/Services/WorkOrderService.php | 52 ++++++++++++++++++- ...roup_key_to_work_order_material_inputs.php | 26 ++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 database/migrations/2026_03_04_100000_add_bom_group_key_to_work_order_material_inputs.php diff --git a/app/Http/Requests/WorkOrder/MaterialInputForItemRequest.php b/app/Http/Requests/WorkOrder/MaterialInputForItemRequest.php index d1c7a5f..1af761e 100644 --- a/app/Http/Requests/WorkOrder/MaterialInputForItemRequest.php +++ b/app/Http/Requests/WorkOrder/MaterialInputForItemRequest.php @@ -17,6 +17,8 @@ public function rules(): array 'inputs' => 'required|array|min:1', 'inputs.*.stock_lot_id' => 'required|integer', 'inputs.*.qty' => 'required|numeric|gt:0', + 'inputs.*.bom_group_key' => 'sometimes|nullable|string|max:100', + 'replace' => 'sometimes|boolean', ]; } diff --git a/app/Models/Production/WorkOrderMaterialInput.php b/app/Models/Production/WorkOrderMaterialInput.php index c921d34..92fe21f 100644 --- a/app/Models/Production/WorkOrderMaterialInput.php +++ b/app/Models/Production/WorkOrderMaterialInput.php @@ -27,6 +27,7 @@ class WorkOrderMaterialInput extends Model 'work_order_item_id', 'stock_lot_id', 'item_id', + 'bom_group_key', 'qty', 'input_by', 'input_at', diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index bb5c5f9..e5d8601 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -3240,6 +3240,30 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array ->groupBy('item_id') ->pluck('total_qty', 'item_id'); + // LOT별 기투입 수량 조회 (stock_lot_id + bom_group_key별 SUM) + $lotInputtedRaw = WorkOrderMaterialInput::where('tenant_id', $tenantId) + ->where('work_order_id', $workOrderId) + ->where('work_order_item_id', $itemId) + ->whereNotNull('stock_lot_id') + ->selectRaw('stock_lot_id, bom_group_key, SUM(qty) as total_qty') + ->groupBy('stock_lot_id', 'bom_group_key') + ->get(); + + // bom_group_key 포함 복합키 매핑 + stock_lot_id 단순 매핑 (하위호환) + $lotInputtedByGroup = []; + $lotInputtedByLot = []; + foreach ($lotInputtedRaw as $row) { + $lotId = $row->stock_lot_id; + $groupKey = $row->bom_group_key; + $qty = (float) $row->total_qty; + + if ($groupKey) { + $compositeKey = $lotId.'_'.$groupKey; + $lotInputtedByGroup[$compositeKey] = ($lotInputtedByGroup[$compositeKey] ?? 0) + $qty; + } + $lotInputtedByLot[$lotId] = ($lotInputtedByLot[$lotId] ?? 0) + $qty; + } + // 자재별 LOT 조회 $materials = []; $rank = 1; @@ -3283,6 +3307,7 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array 'required_qty' => $matInfo['required_qty'], 'already_inputted' => $alreadyInputted, 'remaining_required_qty' => $remainingRequired, + 'lot_inputted_qty' => (float) ($lotInputtedByGroup[$lot->id.'_'.$bomGroupKey] ?? $lotInputtedByLot[$lot->id] ?? 0), 'lot_qty' => (float) $lot->qty, 'lot_available_qty' => (float) $lot->available_qty, 'lot_reserved_qty' => (float) $lot->reserved_qty, @@ -3310,6 +3335,7 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array 'required_qty' => $matInfo['required_qty'], 'already_inputted' => $alreadyInputted, 'remaining_required_qty' => $remainingRequired, + 'lot_inputted_qty' => 0, 'lot_qty' => 0, 'lot_available_qty' => 0, 'lot_reserved_qty' => 0, @@ -3328,8 +3354,10 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array /** * 개소별 자재 투입 등록 + * + * @param bool $replace true면 기존 투입 이력을 삭제(재고 복원) 후 새로 등록 */ - public function registerMaterialInputForItem(int $workOrderId, int $itemId, array $inputs): array + public function registerMaterialInputForItem(int $workOrderId, int $itemId, array $inputs, bool $replace = false): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); @@ -3347,13 +3375,32 @@ public function registerMaterialInputForItem(int $workOrderId, int $itemId, arra throw new NotFoundHttpException(__('error.not_found')); } - return DB::transaction(function () use ($inputs, $tenantId, $userId, $workOrderId, $itemId) { + return DB::transaction(function () use ($inputs, $tenantId, $userId, $workOrderId, $itemId, $replace) { $stockService = app(StockService::class); $inputResults = []; + // replace 모드: 기존 투입 이력 삭제 + 재고 복원 + if ($replace) { + $existingInputs = WorkOrderMaterialInput::where('tenant_id', $tenantId) + ->where('work_order_id', $workOrderId) + ->where('work_order_item_id', $itemId) + ->get(); + + foreach ($existingInputs as $existing) { + $stockService->increaseToLot( + stockLotId: $existing->stock_lot_id, + qty: (float) $existing->qty, + reason: 'work_order_input_replace', + referenceId: $workOrderId + ); + $existing->delete(); + } + } + foreach ($inputs as $input) { $stockLotId = $input['stock_lot_id'] ?? null; $qty = (float) ($input['qty'] ?? 0); + $bomGroupKey = $input['bom_group_key'] ?? null; if (! $stockLotId || $qty <= 0) { continue; @@ -3378,6 +3425,7 @@ public function registerMaterialInputForItem(int $workOrderId, int $itemId, arra 'work_order_item_id' => $itemId, 'stock_lot_id' => $stockLotId, 'item_id' => $lotItemId ?? 0, + 'bom_group_key' => $bomGroupKey, 'qty' => $qty, 'input_by' => $userId, 'input_at' => now(), diff --git a/database/migrations/2026_03_04_100000_add_bom_group_key_to_work_order_material_inputs.php b/database/migrations/2026_03_04_100000_add_bom_group_key_to_work_order_material_inputs.php new file mode 100644 index 0000000..8c451c1 --- /dev/null +++ b/database/migrations/2026_03_04_100000_add_bom_group_key_to_work_order_material_inputs.php @@ -0,0 +1,26 @@ +string('bom_group_key')->nullable()->after('item_id') + ->comment('BOM 그룹키 (같은 item_id의 다른 용도 구분, ex: itemId_category_partType)'); + + $table->index(['work_order_item_id', 'bom_group_key'], 'idx_womi_item_bomgroup'); + }); + } + + public function down(): void + { + Schema::table('work_order_material_inputs', function (Blueprint $table) { + $table->dropIndex('idx_womi_item_bomgroup'); + $table->dropColumn('bom_group_key'); + }); + } +}; From 897511cb5582a729727c13c3a5f5dac9179b0d02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 4 Mar 2026 23:28:03 +0900 Subject: [PATCH 059/166] =?UTF-8?q?fix:=20[production]=20=EC=A0=88?= =?UTF-8?q?=EA=B3=A1=20=EA=B2=80=EC=82=AC=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20item=20=EB=B3=B5=EC=A0=9C=20+=20bending=20?= =?UTF-8?q?EAV=20=EB=B3=80=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - storeItemInspection: bending/bending_wip 시 동일 작업지시 모든 item에 복제 저장 - transformBendingProductsToRecords: products 배열 → bending EAV 레코드 변환 - getMaterialInputLots: 품목코드별 그룹핑으로 변경 Co-Authored-By: Claude Opus 4.6 --- app/Services/WorkOrderService.php | 127 +++++++++++++++++++++++++++--- 1 file changed, 117 insertions(+), 10 deletions(-) diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index e5d8601..e35f357 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -1837,25 +1837,25 @@ public function getMaterialInputLots(int $workOrderId): array ->orderBy('created_at') ->get(['id', 'lot_no', 'item_code', 'item_name', 'qty', 'stock_lot_id', 'created_at']); - // LOT 번호별 그룹핑 (동일 LOT에서 여러번 투입 가능) - $lotMap = []; + // 품목코드별 그룹핑 (작업일지에서 item_code → lot_no 매핑에 사용) + $itemMap = []; foreach ($transactions as $tx) { - $lotNo = $tx->lot_no; - if (! isset($lotMap[$lotNo])) { - $lotMap[$lotNo] = [ - 'lot_no' => $lotNo, - 'item_code' => $tx->item_code, + $itemCode = $tx->item_code; + if (! isset($itemMap[$itemCode])) { + $itemMap[$itemCode] = [ + 'item_code' => $itemCode, + 'lot_no' => $tx->lot_no, 'item_name' => $tx->item_name, 'total_qty' => 0, 'input_count' => 0, 'first_input_at' => $tx->created_at, ]; } - $lotMap[$lotNo]['total_qty'] += abs((float) $tx->qty); - $lotMap[$lotNo]['input_count']++; + $itemMap[$itemCode]['total_qty'] += abs((float) $tx->qty); + $itemMap[$itemCode]['input_count']++; } - return array_values($lotMap); + return array_values($itemMap); } // ────────────────────────────────────────────────────────────── @@ -1890,6 +1890,16 @@ public function storeItemInspection(int $workOrderId, int $itemId, array $data): $item->setInspectionData($inspectionData); $item->save(); + // 절곡 공정: 수주 단위 검사 → 동일 작업지시의 모든 item에 검사 데이터 복제 + $processType = $data['process_type'] ?? ''; + if (in_array($processType, ['bending', 'bending_wip'])) { + $otherItems = $workOrder->items()->where('id', '!=', $itemId)->get(); + foreach ($otherItems as $otherItem) { + $otherItem->setInspectionData($inspectionData); + $otherItem->save(); + } + } + // 감사 로그 $this->auditLogger->log( $tenantId, @@ -2492,10 +2502,107 @@ private function transformInspectionDataToDocumentRecords(array $rawItems, int $ ], $rawItems); } + // 절곡 products 배열 감지 → bending 전용 EAV 레코드 생성 + $productsItem = collect($rawItems)->first(fn ($item) => isset($item['products']) && is_array($item['products'])); + if ($productsItem) { + return $this->transformBendingProductsToRecords($productsItem, $templateId); + } + // 레거시 형식: templateValues/values 기반 → 정규화 변환 return $this->normalizeOldFormatRecords($rawItems, $templateId); } + /** + * 절곡 products 배열 → bending 전용 EAV 레코드 변환 + * + * InspectionInputModal이 저장하는 products 형식: + * [{ id, bendingStatus: '양호'|'불량', lengthMeasured, widthMeasured, gapPoints: [{point, designValue, measured}] }] + * + * 프론트엔드 TemplateInspectionContent가 기대하는 EAV field_key 형식: + * b{productIdx}_ok / b{productIdx}_ng, b{productIdx}_n1, b{productIdx}_p{pointIdx}_n1 + */ + private function transformBendingProductsToRecords(array $item, int $templateId): array + { + $template = DocumentTemplate::with(['columns'])->find($templateId); + if (! $template) { + return []; + } + + // 컬럼 식별 (column_type + sort_order 기반) + $checkCol = $template->columns->firstWhere('column_type', 'check'); + $complexCols = $template->columns->where('column_type', 'complex')->sortBy('sort_order')->values(); + // complex 컬럼 순서: 길이(0), 너비(1), 간격(2) + $lengthCol = $complexCols->get(0); + $widthCol = $complexCols->get(1); + $gapCol = $complexCols->get(2); + + $records = []; + $products = $item['products']; + + foreach ($products as $productIdx => $product) { + // 절곡상태 → check column + if ($checkCol) { + if (($product['bendingStatus'] ?? null) === '양호') { + $records[] = [ + 'section_id' => null, 'column_id' => $checkCol->id, + 'row_index' => $productIdx, 'field_key' => "b{$productIdx}_ok", 'field_value' => 'OK', + ]; + } elseif (($product['bendingStatus'] ?? null) === '불량') { + $records[] = [ + 'section_id' => null, 'column_id' => $checkCol->id, + 'row_index' => $productIdx, 'field_key' => "b{$productIdx}_ng", 'field_value' => 'NG', + ]; + } + } + + // 길이 → first complex column + if ($lengthCol && ! empty($product['lengthMeasured'])) { + $records[] = [ + 'section_id' => null, 'column_id' => $lengthCol->id, + 'row_index' => $productIdx, 'field_key' => "b{$productIdx}_n1", 'field_value' => (string) $product['lengthMeasured'], + ]; + } + + // 너비 → second complex column + if ($widthCol && ! empty($product['widthMeasured'])) { + $records[] = [ + 'section_id' => null, 'column_id' => $widthCol->id, + 'row_index' => $productIdx, 'field_key' => "b{$productIdx}_n1", 'field_value' => (string) $product['widthMeasured'], + ]; + } + + // 간격 포인트 → third complex column (gap) + if ($gapCol && ! empty($product['gapPoints'])) { + foreach ($product['gapPoints'] as $pointIdx => $gp) { + if (! empty($gp['measured'])) { + $records[] = [ + 'section_id' => null, 'column_id' => $gapCol->id, + 'row_index' => $productIdx, 'field_key' => "b{$productIdx}_p{$pointIdx}_n1", 'field_value' => (string) $gp['measured'], + ]; + } + } + } + } + + // 전체 판정 + if (isset($item['judgment'])) { + $records[] = [ + 'section_id' => null, 'column_id' => null, + 'row_index' => 0, 'field_key' => 'overall_result', 'field_value' => (string) $item['judgment'], + ]; + } + + // 부적합 내용 + if (! empty($item['nonConformingContent'])) { + $records[] = [ + 'section_id' => null, 'column_id' => null, + 'row_index' => 0, 'field_key' => 'remark', 'field_value' => (string) $item['nonConformingContent'], + ]; + } + + return $records; + } + /** * 레거시 형식(section_X_item_Y 키)을 정규화 레코드로 변환 */ From 1a8bb461375a07b38bbde0009aa6da1a8dd1681e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 4 Mar 2026 23:31:04 +0900 Subject: [PATCH 060/166] =?UTF-8?q?feat:=20[outbound]=20=EB=B0=B0=EC=B0=A8?= =?UTF-8?q?=EC=B0=A8=EB=9F=89=20=EA=B4=80=EB=A6=AC=20API=20=E2=80=94=20CRU?= =?UTF-8?q?D=20+=20options=20JSON=20=EC=A0=95=EC=B1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VehicleDispatchService: index(검색/필터/페이지네이션), stats(선불/착불/합계), show, update - VehicleDispatchController + VehicleDispatchUpdateRequest - options JSON 컬럼 추가 (dispatch_no, status, freight_cost_type, supply_amount, vat, total_amount, writer) - ShipmentService.syncDispatches에 options 필드 지원 추가 - inventory.php에 vehicle-dispatches 라우트 4개 등록 Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/VehicleDispatchController.php | 74 +++++++++ .../VehicleDispatchUpdateRequest.php | 30 ++++ .../Tenants/ShipmentVehicleDispatch.php | 2 + app/Services/ShipmentService.php | 1 + app/Services/VehicleDispatchService.php | 140 ++++++++++++++++++ ...s_to_shipment_vehicle_dispatches_table.php | 23 +++ routes/api/v1/inventory.php | 9 ++ 7 files changed, 279 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/VehicleDispatchController.php create mode 100644 app/Http/Requests/VehicleDispatch/VehicleDispatchUpdateRequest.php create mode 100644 app/Services/VehicleDispatchService.php create mode 100644 database/migrations/2026_03_04_150000_add_options_to_shipment_vehicle_dispatches_table.php diff --git a/app/Http/Controllers/Api/V1/VehicleDispatchController.php b/app/Http/Controllers/Api/V1/VehicleDispatchController.php new file mode 100644 index 0000000..01e1337 --- /dev/null +++ b/app/Http/Controllers/Api/V1/VehicleDispatchController.php @@ -0,0 +1,74 @@ +only([ + 'search', + 'status', + 'start_date', + 'end_date', + 'per_page', + 'page', + ]); + + $dispatches = $this->service->index($params); + + return ApiResponse::success($dispatches, __('message.fetched')); + } + + /** + * 배차차량 통계 조회 + */ + public function stats(): JsonResponse + { + $stats = $this->service->stats(); + + return ApiResponse::success($stats, __('message.fetched')); + } + + /** + * 배차차량 상세 조회 + */ + public function show(int $id): JsonResponse + { + try { + $dispatch = $this->service->show($id); + + return ApiResponse::success($dispatch, __('message.fetched')); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return ApiResponse::error(__('error.not_found'), 404); + } + } + + /** + * 배차차량 수정 + */ + public function update(VehicleDispatchUpdateRequest $request, int $id): JsonResponse + { + try { + $dispatch = $this->service->update($id, $request->validated()); + + return ApiResponse::success($dispatch, __('message.updated')); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return ApiResponse::error(__('error.not_found'), 404); + } + } +} diff --git a/app/Http/Requests/VehicleDispatch/VehicleDispatchUpdateRequest.php b/app/Http/Requests/VehicleDispatch/VehicleDispatchUpdateRequest.php new file mode 100644 index 0000000..9dba8f9 --- /dev/null +++ b/app/Http/Requests/VehicleDispatch/VehicleDispatchUpdateRequest.php @@ -0,0 +1,30 @@ + 'nullable|in:prepaid,collect', + 'logistics_company' => 'nullable|string|max:100', + 'arrival_datetime' => 'nullable|date', + 'tonnage' => 'nullable|string|max:20', + 'vehicle_no' => 'nullable|string|max:20', + 'driver_contact' => 'nullable|string|max:50', + 'remarks' => 'nullable|string', + 'supply_amount' => 'nullable|numeric|min:0', + 'vat' => 'nullable|numeric|min:0', + 'total_amount' => 'nullable|numeric|min:0', + 'status' => 'nullable|in:draft,completed', + ]; + } +} diff --git a/app/Models/Tenants/ShipmentVehicleDispatch.php b/app/Models/Tenants/ShipmentVehicleDispatch.php index 7db88a0..50eb385 100644 --- a/app/Models/Tenants/ShipmentVehicleDispatch.php +++ b/app/Models/Tenants/ShipmentVehicleDispatch.php @@ -22,12 +22,14 @@ class ShipmentVehicleDispatch extends Model 'vehicle_no', 'driver_contact', 'remarks', + 'options', ]; protected $casts = [ 'seq' => 'integer', 'shipment_id' => 'integer', 'arrival_datetime' => 'datetime', + 'options' => 'array', ]; /** diff --git a/app/Services/ShipmentService.php b/app/Services/ShipmentService.php index 8d42603..df8b35a 100644 --- a/app/Services/ShipmentService.php +++ b/app/Services/ShipmentService.php @@ -513,6 +513,7 @@ protected function syncDispatches(Shipment $shipment, array $dispatches, int $te 'vehicle_no' => $dispatch['vehicle_no'] ?? null, 'driver_contact' => $dispatch['driver_contact'] ?? null, 'remarks' => $dispatch['remarks'] ?? null, + 'options' => $dispatch['options'] ?? null, ]); $seq++; } diff --git a/app/Services/VehicleDispatchService.php b/app/Services/VehicleDispatchService.php new file mode 100644 index 0000000..cfae5fb --- /dev/null +++ b/app/Services/VehicleDispatchService.php @@ -0,0 +1,140 @@ +tenantId(); + + $query = ShipmentVehicleDispatch::query() + ->where('tenant_id', $tenantId) + ->with('shipment'); + + // 검색어 필터 + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('vehicle_no', 'like', "%{$search}%") + ->orWhere('options->dispatch_no', 'like', "%{$search}%") + ->orWhereHas('shipment', function ($q3) use ($search) { + $q3->where('lot_no', 'like', "%{$search}%") + ->orWhere('site_name', 'like', "%{$search}%") + ->orWhere('customer_name', 'like', "%{$search}%"); + }); + }); + } + + // 상태 필터 (options JSON) + if (! empty($params['status'])) { + $query->where('options->status', $params['status']); + } + + // 날짜 범위 필터 + if (! empty($params['start_date'])) { + $query->where('arrival_datetime', '>=', $params['start_date']); + } + if (! empty($params['end_date'])) { + $query->where('arrival_datetime', '<=', $params['end_date'].' 23:59:59'); + } + + // 정렬 + $query->orderBy('id', 'desc'); + + // 페이지네이션 + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 배차차량 통계 + */ + public function stats(): array + { + $tenantId = $this->tenantId(); + + $all = ShipmentVehicleDispatch::query() + ->where('tenant_id', $tenantId) + ->get(); + + $prepaid = 0; + $collect = 0; + $total = 0; + + foreach ($all as $dispatch) { + $opts = $dispatch->options ?? []; + $amount = (float) ($opts['total_amount'] ?? 0); + $total += $amount; + + if (($opts['freight_cost_type'] ?? '') === 'prepaid') { + $prepaid += $amount; + } + if (($opts['freight_cost_type'] ?? '') === 'collect') { + $collect += $amount; + } + } + + return [ + 'prepaid_amount' => $prepaid, + 'collect_amount' => $collect, + 'total_amount' => $total, + ]; + } + + /** + * 배차차량 상세 조회 + */ + public function show(int $id): ShipmentVehicleDispatch + { + $tenantId = $this->tenantId(); + + return ShipmentVehicleDispatch::query() + ->where('tenant_id', $tenantId) + ->with('shipment') + ->findOrFail($id); + } + + /** + * 배차차량 수정 + */ + public function update(int $id, array $data): ShipmentVehicleDispatch + { + $tenantId = $this->tenantId(); + + $dispatch = ShipmentVehicleDispatch::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // options에 저장할 필드 분리 + $optionFields = ['freight_cost_type', 'supply_amount', 'vat', 'total_amount', 'status']; + $directFields = ['logistics_company', 'arrival_datetime', 'tonnage', 'vehicle_no', 'driver_contact', 'remarks']; + + // 기존 options 유지하면서 업데이트 + $options = $dispatch->options ?? []; + foreach ($optionFields as $field) { + if (array_key_exists($field, $data)) { + $options[$field] = $data[$field]; + } + } + + // 직접 컬럼 업데이트 + $updateData = ['options' => $options]; + foreach ($directFields as $field) { + if (array_key_exists($field, $data)) { + $updateData[$field] = $data[$field]; + } + } + + $dispatch->update($updateData); + + return $dispatch->load('shipment'); + } +} diff --git a/database/migrations/2026_03_04_150000_add_options_to_shipment_vehicle_dispatches_table.php b/database/migrations/2026_03_04_150000_add_options_to_shipment_vehicle_dispatches_table.php new file mode 100644 index 0000000..0bd8b28 --- /dev/null +++ b/database/migrations/2026_03_04_150000_add_options_to_shipment_vehicle_dispatches_table.php @@ -0,0 +1,23 @@ +json('options')->nullable()->after('remarks') + ->comment('추가 속성 (dispatch_no, status, freight_cost_type, supply_amount, vat, total_amount, writer)'); + }); + } + + public function down(): void + { + Schema::table('shipment_vehicle_dispatches', function (Blueprint $table) { + $table->dropColumn('options'); + }); + } +}; diff --git a/routes/api/v1/inventory.php b/routes/api/v1/inventory.php index 2246754..73b66fd 100644 --- a/routes/api/v1/inventory.php +++ b/routes/api/v1/inventory.php @@ -19,6 +19,7 @@ use App\Http\Controllers\Api\V1\ReceivingController; use App\Http\Controllers\Api\V1\ShipmentController; use App\Http\Controllers\Api\V1\StockController; +use App\Http\Controllers\Api\V1\VehicleDispatchController; use Illuminate\Support\Facades\Route; // Items API (품목 관리) @@ -123,3 +124,11 @@ 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'); }); + +// Vehicle Dispatch API (배차차량 관리) +Route::prefix('vehicle-dispatches')->group(function () { + Route::get('', [VehicleDispatchController::class, 'index'])->name('v1.vehicle-dispatches.index'); + Route::get('/stats', [VehicleDispatchController::class, 'stats'])->name('v1.vehicle-dispatches.stats'); + Route::get('/{id}', [VehicleDispatchController::class, 'show'])->whereNumber('id')->name('v1.vehicle-dispatches.show'); + Route::put('/{id}', [VehicleDispatchController::class, 'update'])->whereNumber('id')->name('v1.vehicle-dispatches.update'); +}); From ef7d9fae240e55fc2fd7660931aa333e7026e70e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 5 Mar 2026 10:39:51 +0900 Subject: [PATCH 061/166] =?UTF-8?q?fix:=20[production]=20=EC=A0=88?= =?UTF-8?q?=EA=B3=A1=20=EA=B2=80=EC=82=AC=20products=20=EB=B0=B0=EC=97=B4?= =?UTF-8?q?=20FormRequest=20=EA=B2=80=EC=A6=9D=20=EA=B7=9C=EC=B9=99=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 - StoreItemInspectionRequest에 inspection_data.products 검증 규칙 누락으로 validated()에서 제거되던 버그 수정 - products.*.id, bendingStatus, lengthMeasured, widthMeasured, gapPoints 규칙 추가 Co-Authored-By: Claude Opus 4.6 --- app/Console/Commands/RecordStorageUsage.php | 2 +- .../Requests/WorkOrder/StoreItemInspectionRequest.php | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/Console/Commands/RecordStorageUsage.php b/app/Console/Commands/RecordStorageUsage.php index c77f1eb..5e94bec 100644 --- a/app/Console/Commands/RecordStorageUsage.php +++ b/app/Console/Commands/RecordStorageUsage.php @@ -29,7 +29,7 @@ class RecordStorageUsage extends Command */ public function handle(): int { - $tenants = Tenant::active()->get(); + $tenants = Tenant::where('tenant_st_code', 'active')->get(); $recorded = 0; foreach ($tenants as $tenant) { diff --git a/app/Http/Requests/WorkOrder/StoreItemInspectionRequest.php b/app/Http/Requests/WorkOrder/StoreItemInspectionRequest.php index b5dba3a..b542fe4 100644 --- a/app/Http/Requests/WorkOrder/StoreItemInspectionRequest.php +++ b/app/Http/Requests/WorkOrder/StoreItemInspectionRequest.php @@ -39,6 +39,16 @@ public function rules(): array 'inspection_data.nonConformingContent' => 'nullable|string|max:1000', 'inspection_data.templateValues' => 'nullable|array', 'inspection_data.templateValues.*' => 'nullable', + // 절곡 제품별 검사 데이터 + 'inspection_data.products' => 'nullable|array', + 'inspection_data.products.*.id' => 'required_with:inspection_data.products|string', + 'inspection_data.products.*.bendingStatus' => ['nullable', Rule::in(['양호', '불량'])], + 'inspection_data.products.*.lengthMeasured' => 'nullable|string|max:50', + 'inspection_data.products.*.widthMeasured' => 'nullable|string|max:50', + 'inspection_data.products.*.gapPoints' => 'nullable|array', + 'inspection_data.products.*.gapPoints.*.point' => 'nullable|string', + 'inspection_data.products.*.gapPoints.*.designValue' => 'nullable|string', + 'inspection_data.products.*.gapPoints.*.measured' => 'nullable|string|max:50', ]; } From cd847e01a0df5793b95832b4710f0af189808177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 5 Mar 2026 11:00:53 +0900 Subject: [PATCH 062/166] =?UTF-8?q?feat:=20[approval]=20Document=20?= =?UTF-8?q?=E2=86=94=20Approval=20=EB=B8=8C=EB=A6=BF=EC=A7=80=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20(Phase=204.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Approval 모델에 linkable morphTo 관계 추가 - DocumentService: 상신 시 Approval 자동 생성 + approval_steps 변환 - ApprovalService: 승인/반려/회수 시 Document 상태 동기화 - approvals 테이블에 linkable_type, linkable_id 컬럼 마이그레이션 Co-Authored-By: Claude Opus 4.6 --- app/Models/Tenants/Approval.php | 11 +++ app/Services/ApprovalService.php | 76 +++++++++++++++++- app/Services/DocumentService.php | 79 +++++++++++++++++++ ...200000_add_linkable_to_approvals_table.php | 32 ++++++++ 4 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_02_27_200000_add_linkable_to_approvals_table.php diff --git a/app/Models/Tenants/Approval.php b/app/Models/Tenants/Approval.php index 9ecc871..373e14f 100644 --- a/app/Models/Tenants/Approval.php +++ b/app/Models/Tenants/Approval.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\SoftDeletes; /** @@ -55,6 +56,8 @@ class Approval extends Model 'completed_at', 'current_step', 'attachments', + 'linkable_type', + 'linkable_id', 'created_by', 'updated_by', 'deleted_by', @@ -135,6 +138,14 @@ public function referenceSteps(): HasMany ->orderBy('step_order'); } + /** + * 연결 대상 (Document 등) + */ + public function linkable(): MorphTo + { + return $this->morphTo(); + } + /** * 생성자 */ diff --git a/app/Services/ApprovalService.php b/app/Services/ApprovalService.php index 4e4ce2a..46ebf16 100644 --- a/app/Services/ApprovalService.php +++ b/app/Services/ApprovalService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\Documents\Document; use App\Models\Tenants\Approval; use App\Models\Tenants\ApprovalForm; use App\Models\Tenants\ApprovalLine; @@ -567,7 +568,7 @@ public function show(int $id): Approval { $tenantId = $this->tenantId(); - return Approval::query() + $approval = Approval::query() ->where('tenant_id', $tenantId) ->with([ 'form:id,name,code,category,template', @@ -579,6 +580,19 @@ public function show(int $id): Approval 'steps.approver.tenantProfile.department:id,name', ]) ->findOrFail($id); + + // Document 브릿지: 연결된 문서 데이터 로딩 + if ($approval->linkable_type === Document::class) { + $approval->load([ + 'linkable.template', + 'linkable.template.approvalLines', + 'linkable.data', + 'linkable.approvals.user:id,name', + 'linkable.attachments', + ]); + } + + return $approval; } /** @@ -842,6 +856,9 @@ public function approve(int $id, ?string $comment = null): Approval $approval->updated_by = $userId; $approval->save(); + // Document 브릿지 동기화 + $this->syncToLinkedDocument($approval); + return $approval->fresh([ 'form:id,name,code,category', 'drafter:id,name', @@ -895,6 +912,9 @@ public function reject(int $id, string $comment): Approval $approval->updated_by = $userId; $approval->save(); + // Document 브릿지 동기화 + $this->syncToLinkedDocument($approval); + return $approval->fresh([ 'form:id,name,code,category', 'drafter:id,name', @@ -934,6 +954,9 @@ public function cancel(int $id): Approval $approval->updated_by = $userId; $approval->save(); + // Document 브릿지 동기화 (steps 삭제 전에 실행) + $this->syncToLinkedDocument($approval); + // 결재 단계들 삭제 $approval->steps()->delete(); @@ -944,6 +967,57 @@ public function cancel(int $id): Approval }); } + /** + * Approval → Document 브릿지 동기화 + * 결재 승인/반려/회수 시 연결된 Document의 상태와 결재란을 동기화 + */ + private function syncToLinkedDocument(Approval $approval): void + { + if ($approval->linkable_type !== Document::class) { + return; + } + + $document = Document::find($approval->linkable_id); + if (! $document) { + return; + } + + // approval_steps → document_approvals 동기화 (승인자 이름/시각 반영) + foreach ($approval->steps as $step) { + if ($step->status === ApprovalStep::STATUS_PENDING) { + continue; + } + + $docApproval = $document->approvals() + ->where('step', $step->step_order) + ->first(); + + if ($docApproval) { + $docApproval->update([ + 'status' => strtoupper($step->status), + 'acted_at' => $step->acted_at, + 'comment' => $step->comment, + ]); + } + } + + // Document 전체 상태 동기화 + $documentStatus = match ($approval->status) { + Approval::STATUS_APPROVED => Document::STATUS_APPROVED, + Approval::STATUS_REJECTED => Document::STATUS_REJECTED, + Approval::STATUS_CANCELLED => Document::STATUS_CANCELLED, + default => Document::STATUS_PENDING, + }; + + $document->update([ + 'status' => $documentStatus, + 'completed_at' => in_array($approval->status, [ + Approval::STATUS_APPROVED, + Approval::STATUS_REJECTED, + ]) ? now() : null, + ]); + } + /** * 참조 열람 처리 */ diff --git a/app/Services/DocumentService.php b/app/Services/DocumentService.php index b4e614c..b939fe9 100644 --- a/app/Services/DocumentService.php +++ b/app/Services/DocumentService.php @@ -7,6 +7,10 @@ use App\Models\Documents\DocumentAttachment; use App\Models\Documents\DocumentData; use App\Models\Documents\DocumentTemplate; +use App\Models\Tenants\Approval; +use App\Models\Tenants\ApprovalForm; +use App\Models\Tenants\ApprovalLine; +use App\Models\Tenants\ApprovalStep; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -275,6 +279,9 @@ public function submit(int $id): Document $document->updated_by = $userId; $document->save(); + // Approval 시스템 브릿지: 결재함(/approval/inbox)에 표시되도록 Approval 자동 생성 + $this->createApprovalBridge($document); + return $document->fresh([ 'template:id,name,category', 'approvals.user:id,name', @@ -283,6 +290,78 @@ public function submit(int $id): Document }); } + /** + * Document → Approval 브릿지 생성 + * Document 상신 시 Approval 레코드를 자동 생성하여 /approval/inbox에 표시 + */ + private function createApprovalBridge(Document $document): void + { + $form = ApprovalForm::where('code', 'document') + ->where('tenant_id', $document->tenant_id) + ->first(); + + if (! $form) { + return; // 문서 결재 양식 미등록 시 스킵 (기존 동작 유지) + } + + // 기존 브릿지가 있으면 스킵 (재상신 방지) + $existingApproval = Approval::where('linkable_type', Document::class) + ->where('linkable_id', $document->id) + ->whereNotIn('status', [Approval::STATUS_CANCELLED]) + ->first(); + + if ($existingApproval) { + return; + } + + // 문서번호 생성 (Approval 체계) + $today = now()->format('Ymd'); + $lastNumber = Approval::where('tenant_id', $document->tenant_id) + ->where('document_number', 'like', "AP-{$today}-%") + ->orderByDesc('document_number') + ->value('document_number'); + + $seq = 1; + if ($lastNumber && preg_match('/AP-\d{8}-(\d{4})/', $lastNumber, $matches)) { + $seq = (int) $matches[1] + 1; + } + $documentNumber = sprintf('AP-%s-%04d', $today, $seq); + + $approval = Approval::create([ + 'tenant_id' => $document->tenant_id, + 'document_number' => $documentNumber, + 'form_id' => $form->id, + 'title' => $document->title, + 'content' => [ + 'document_id' => $document->id, + 'template_id' => $document->template_id, + 'document_no' => $document->document_no, + ], + 'status' => Approval::STATUS_PENDING, + 'drafter_id' => $document->created_by, + 'drafted_at' => now(), + 'current_step' => 1, + 'linkable_type' => Document::class, + 'linkable_id' => $document->id, + 'created_by' => $document->updated_by ?? $document->created_by, + ]); + + // document_approvals → approval_steps 변환 + $docApprovals = $document->approvals() + ->orderBy('step') + ->get(); + + foreach ($docApprovals as $docApproval) { + ApprovalStep::create([ + 'approval_id' => $approval->id, + 'step_order' => $docApproval->step, + 'step_type' => ApprovalLine::STEP_TYPE_APPROVAL, + 'approver_id' => $docApproval->user_id, + 'status' => ApprovalStep::STATUS_PENDING, + ]); + } + } + /** * 결재 승인 */ diff --git a/database/migrations/2026_02_27_200000_add_linkable_to_approvals_table.php b/database/migrations/2026_02_27_200000_add_linkable_to_approvals_table.php new file mode 100644 index 0000000..32901fb --- /dev/null +++ b/database/migrations/2026_02_27_200000_add_linkable_to_approvals_table.php @@ -0,0 +1,32 @@ +string('linkable_type')->nullable()->after('attachments')->comment('연결 대상 모델 (예: App\\Models\\Documents\\Document)'); + $table->unsignedBigInteger('linkable_id')->nullable()->after('linkable_type')->comment('연결 대상 ID'); + $table->index(['linkable_type', 'linkable_id'], 'idx_linkable'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('approvals', function (Blueprint $table) { + $table->dropIndex('idx_linkable'); + $table->dropColumn(['linkable_type', 'linkable_id']); + }); + } +}; From 1f7f45ee608575cccbb92d7326fbde467a2fea55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 5 Mar 2026 11:01:01 +0900 Subject: [PATCH 063/166] =?UTF-8?q?feat:=20[process]=20=EA=B3=B5=EC=A0=95?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20options=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=E2=80=94=20=EA=B2=80=EC=82=AC=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?/=EB=B2=94=EC=9C=84=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProcessStep 모델에 options JSON 컬럼 추가 (fillable, cast) - Store/UpdateProcessStepRequest에 inspection_setting, inspection_scope 검증 규칙 - process_steps 테이블 마이그레이션 Co-Authored-By: Claude Opus 4.6 --- .../ProcessStep/StoreProcessStepRequest.php | 12 ++++++++++ .../ProcessStep/UpdateProcessStepRequest.php | 12 ++++++++++ app/Models/ProcessStep.php | 2 ++ ...000_add_options_to_process_steps_table.php | 23 +++++++++++++++++++ 4 files changed, 49 insertions(+) create mode 100644 database/migrations/2026_03_04_100000_add_options_to_process_steps_table.php diff --git a/app/Http/Requests/V1/ProcessStep/StoreProcessStepRequest.php b/app/Http/Requests/V1/ProcessStep/StoreProcessStepRequest.php index 0c29e8d..a5671df 100644 --- a/app/Http/Requests/V1/ProcessStep/StoreProcessStepRequest.php +++ b/app/Http/Requests/V1/ProcessStep/StoreProcessStepRequest.php @@ -22,6 +22,12 @@ public function rules(): array 'connection_type' => ['nullable', 'string', 'max:20'], 'connection_target' => ['nullable', 'string', 'max:255'], 'completion_type' => ['nullable', 'string', 'in:click_complete,selection_complete,inspection_complete'], + 'options' => ['nullable', 'array'], + 'options.inspection_setting' => ['nullable', 'array'], + 'options.inspection_scope' => ['nullable', 'array'], + 'options.inspection_scope.type' => ['nullable', 'string', 'in:all,sampling,group'], + 'options.inspection_scope.sample_size' => ['nullable', 'integer', 'min:1'], + 'options.inspection_scope.sample_base' => ['nullable', 'string', 'in:order,lot'], ]; } @@ -36,6 +42,12 @@ public function attributes(): array 'connection_type' => '연결유형', 'connection_target' => '연결대상', 'completion_type' => '완료유형', + 'options' => '옵션', + 'options.inspection_setting' => '검사설정', + 'options.inspection_scope' => '검사범위', + 'options.inspection_scope.type' => '검사범위 유형', + 'options.inspection_scope.sample_size' => '샘플 크기', + 'options.inspection_scope.sample_base' => '샘플 기준', ]; } } diff --git a/app/Http/Requests/V1/ProcessStep/UpdateProcessStepRequest.php b/app/Http/Requests/V1/ProcessStep/UpdateProcessStepRequest.php index ac81f87..1ff34e3 100644 --- a/app/Http/Requests/V1/ProcessStep/UpdateProcessStepRequest.php +++ b/app/Http/Requests/V1/ProcessStep/UpdateProcessStepRequest.php @@ -22,6 +22,12 @@ public function rules(): array 'connection_type' => ['nullable', 'string', 'max:20'], 'connection_target' => ['nullable', 'string', 'max:255'], 'completion_type' => ['nullable', 'string', 'in:click_complete,selection_complete,inspection_complete'], + 'options' => ['nullable', 'array'], + 'options.inspection_setting' => ['nullable', 'array'], + 'options.inspection_scope' => ['nullable', 'array'], + 'options.inspection_scope.type' => ['nullable', 'string', 'in:all,sampling,group'], + 'options.inspection_scope.sample_size' => ['nullable', 'integer', 'min:1'], + 'options.inspection_scope.sample_base' => ['nullable', 'string', 'in:order,lot'], ]; } @@ -36,6 +42,12 @@ public function attributes(): array 'connection_type' => '연결유형', 'connection_target' => '연결대상', 'completion_type' => '완료유형', + 'options' => '옵션', + 'options.inspection_setting' => '검사설정', + 'options.inspection_scope' => '검사범위', + 'options.inspection_scope.type' => '검사범위 유형', + 'options.inspection_scope.sample_size' => '샘플 크기', + 'options.inspection_scope.sample_base' => '샘플 기준', ]; } } diff --git a/app/Models/ProcessStep.php b/app/Models/ProcessStep.php index 953fda9..ee1543f 100644 --- a/app/Models/ProcessStep.php +++ b/app/Models/ProcessStep.php @@ -22,6 +22,7 @@ class ProcessStep extends Model 'connection_type', 'connection_target', 'completion_type', + 'options', ]; protected $casts = [ @@ -30,6 +31,7 @@ class ProcessStep extends Model 'needs_inspection' => 'boolean', 'is_active' => 'boolean', 'sort_order' => 'integer', + 'options' => 'array', ]; /** diff --git a/database/migrations/2026_03_04_100000_add_options_to_process_steps_table.php b/database/migrations/2026_03_04_100000_add_options_to_process_steps_table.php new file mode 100644 index 0000000..55ad247 --- /dev/null +++ b/database/migrations/2026_03_04_100000_add_options_to_process_steps_table.php @@ -0,0 +1,23 @@ +json('options')->nullable()->after('completion_type') + ->comment('검사설정, 검사범위 등 추가 옵션 JSON'); + }); + } + + public function down(): void + { + Schema::table('process_steps', function (Blueprint $table) { + $table->dropColumn('options'); + }); + } +}; From d4f21f06d6e134a80049d360e8f952a198ebfaf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 5 Mar 2026 11:01:04 +0900 Subject: [PATCH 064/166] =?UTF-8?q?refactor:=20[production]=20=EC=85=94?= =?UTF-8?q?=ED=84=B0=EB=B0=95=EC=8A=A4=20prefix=20=E2=80=94=20isStandard?= =?UTF-8?q?=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CF/CL/CP/CB 품목이 모든 길이에 등록되어 boxSize 무관하게 적용 - resolveShutterBoxPrefix()에서 불필요한 isStandard 분기 제거 Co-Authored-By: Claude Opus 4.6 --- app/Services/Production/BendingInfoBuilder.php | 7 +++---- app/Services/Production/PrefixResolver.php | 10 ++++------ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/app/Services/Production/BendingInfoBuilder.php b/app/Services/Production/BendingInfoBuilder.php index 37ac551..a35677c 100644 --- a/app/Services/Production/BendingInfoBuilder.php +++ b/app/Services/Production/BendingInfoBuilder.php @@ -218,7 +218,6 @@ public function buildDynamicBomForItem(array $context, int $width, int $height, // ─── 3. 셔터박스 세부품목 ─── if ($boxSize) { - $isStandard = $boxSize === '500*380'; $dist = $this->shutterBoxDistribution($width); // 상부덮개(top_cover), 마구리(fin_cover)는 1219mm 기준으로 별도 생성 (아래 256행~) $shutterPartTypes = ['front', 'lintel', 'inspection', 'rear_corner']; @@ -231,7 +230,7 @@ public function buildDynamicBomForItem(array $context, int $width, int $height, continue; } - $prefix = $resolver->resolveShutterBoxPrefix($partType, $isStandard); + $prefix = $resolver->resolveShutterBoxPrefix($partType); $itemCode = $resolver->buildItemCode($prefix, $length); if (! $itemCode) { continue; @@ -259,7 +258,7 @@ public function buildDynamicBomForItem(array $context, int $width, int $height, // 상부덮개 수량: ceil(width / 1219) × qty (1219mm 단위) $coverQty = (int) ceil($width / 1219) * $qty; if ($coverQty > 0) { - $coverPrefix = $resolver->resolveShutterBoxPrefix('top_cover', $isStandard); + $coverPrefix = $resolver->resolveShutterBoxPrefix('top_cover'); $coverCode = $resolver->buildItemCode($coverPrefix, 1219); if ($coverCode) { $coverId = $resolver->resolveItemId($coverCode, $tenantId); @@ -281,7 +280,7 @@ public function buildDynamicBomForItem(array $context, int $width, int $height, // 마구리 수량: qty × 2 $finQty = $qty * 2; if ($finQty > 0) { - $finPrefix = $resolver->resolveShutterBoxPrefix('fin_cover', $isStandard); + $finPrefix = $resolver->resolveShutterBoxPrefix('fin_cover'); // 마구리는 박스 높이 기준이므로 셔터박스 최소 단위 사용 $finCode = $resolver->buildItemCode($finPrefix, 1219); if ($finCode) { diff --git a/app/Services/Production/PrefixResolver.php b/app/Services/Production/PrefixResolver.php index 772b995..75f915d 100644 --- a/app/Services/Production/PrefixResolver.php +++ b/app/Services/Production/PrefixResolver.php @@ -189,16 +189,14 @@ public function resolveBottomBarPrefix(string $partType, string $productCode, st /** * 셔터박스 세부품목의 prefix 결정 * + * CF/CL/CP/CB 품목은 모든 길이에 등록되어 있으므로 boxSize 무관하게 적용. + * top_cover, fin_cover는 전용 품목 없이 XX(하부BASE/상부/마구리) 공용. + * * @param string $partType 'front', 'lintel', 'inspection', 'rear_corner', 'top_cover', 'fin_cover' - * @param bool $isStandardSize 500*380인지 * @return string prefix */ - public function resolveShutterBoxPrefix(string $partType, bool $isStandardSize): string + public function resolveShutterBoxPrefix(string $partType): string { - if (! $isStandardSize) { - return 'XX'; - } - return self::SHUTTER_STANDARD[$partType] ?? 'XX'; } From 7432fb16aa88bafd9c7bea54e45f0e2aac6e5f7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 5 Mar 2026 11:01:07 +0900 Subject: [PATCH 065/166] =?UTF-8?q?feat:=20[production]=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=ED=88=AC=EC=9E=85=20replace=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registerMaterialInputForItem에 replace 파라미터 추가 - 기존 투입 교체 방식 선택 가능 Co-Authored-By: Claude Opus 4.6 --- app/Http/Controllers/Api/V1/WorkOrderController.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Api/V1/WorkOrderController.php b/app/Http/Controllers/Api/V1/WorkOrderController.php index 61828a4..c7fd77b 100644 --- a/app/Http/Controllers/Api/V1/WorkOrderController.php +++ b/app/Http/Controllers/Api/V1/WorkOrderController.php @@ -320,7 +320,14 @@ public function materialsForItem(int $id, int $itemId) public function registerMaterialInputForItem(MaterialInputForItemRequest $request, int $id, int $itemId) { return ApiResponse::handle(function () use ($request, $id, $itemId) { - return $this->service->registerMaterialInputForItem($id, $itemId, $request->validated()['inputs']); + $validated = $request->validated(); + + return $this->service->registerMaterialInputForItem( + $id, + $itemId, + $validated['inputs'], + (bool) ($validated['replace'] ?? false) + ); }, __('message.work_order.material_input_registered')); } From 9b8cdfa2a507fbe0b655a638a94b434177b61d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 5 Mar 2026 11:01:10 +0900 Subject: [PATCH 066/166] =?UTF-8?q?refactor:=20[core]=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=20=EC=8A=A4=EC=BD=94=ED=94=84=20=EC=A0=81=EC=9A=A9=20=E2=80=94?= =?UTF-8?q?=20Tenant::active()=20=EC=82=AC=EC=9A=A9=20=EB=B0=8F=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EA=B7=9C=EC=B9=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RecordStorageUsage: where 하드코딩 → Tenant::active() 스코프 - CLAUDE.md: 쿼리 수정 시 모델 스코프 우선 규칙 명시 Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 1 + app/Console/Commands/RecordStorageUsage.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2b6924c..402eac7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -509,6 +509,7 @@ ### 2. Multi-tenancy & Models - SoftDeletes by default - Common columns: tenant_id, created_by, updated_by, deleted_by (COMMENT required) - FK constraints: Created during design, minimal in production +- **🔴 쿼리 수정 시 모델 스코프 우선**: `where('컬럼', '값')` 하드코딩 전에 반드시 모델에 정의된 스코프(scopeActive 등)를 먼저 확인하고, 스코프가 있으면 `Model::active()` 형태로 사용할 것 ### 3. Middleware Stack - ApiKeyMiddleware, CheckSwaggerAuth, CorsMiddleware, CheckPermission, PermMapper diff --git a/app/Console/Commands/RecordStorageUsage.php b/app/Console/Commands/RecordStorageUsage.php index 5e94bec..c77f1eb 100644 --- a/app/Console/Commands/RecordStorageUsage.php +++ b/app/Console/Commands/RecordStorageUsage.php @@ -29,7 +29,7 @@ class RecordStorageUsage extends Command */ public function handle(): int { - $tenants = Tenant::where('tenant_st_code', 'active')->get(); + $tenants = Tenant::active()->get(); $recorded = 0; foreach ($tenants as $tenant) { From 2507dcf142d1ee03182d1e96b2f366d127412194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 5 Mar 2026 11:01:24 +0900 Subject: [PATCH 067/166] =?UTF-8?q?chore:=20[docs]=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EA=B4=80=EA=B3=84=20=EB=AC=B8=EC=84=9C=20=EA=B0=B1=EC=8B=A0=20?= =?UTF-8?q?+=20summary=20=ED=94=8C=EB=A0=88=EC=9D=B4=EC=8A=A4=ED=99=80?= =?UTF-8?q?=EB=8D=94=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LOGICAL_RELATIONSHIPS.md: 최신 모델 관계 반영 (inspections, shipment dispatches 등) - stats.php: production/construction/unshipped/attendance summary 플레이스홀더 Co-Authored-By: Claude Opus 4.6 --- .serena/project.yml | 7 +++++++ LOGICAL_RELATIONSHIPS.md | 23 +++++++++++++++-------- routes/api/v1/stats.php | 14 ++++++++++++++ 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/.serena/project.yml b/.serena/project.yml index ddf691b..7a512bc 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -107,3 +107,10 @@ fixed_tools: [] # override of the corresponding setting in serena_config.yml, see the documentation there. # If null or missing, the value from the global config is used. symbol_info_budget: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 3f6f735..1cf50df 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-02-21 16:28:35 +> **자동 생성**: 2026-03-04 22:33:37 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -580,17 +580,10 @@ ### roles **모델**: `App\Models\Permissions\Role` - **tenant()**: belongsTo → `tenants` -- **menuPermissions()**: hasMany → `role_menu_permissions` - **userRoles()**: hasMany → `user_roles` - **users()**: belongsToMany → `users` - **permissions()**: belongsToMany → `permissions` -### role_menu_permissions -**모델**: `App\Models\Permissions\RoleMenuPermission` - -- **role()**: belongsTo → `roles` -- **menu()**: belongsTo → `menus` - ### popups **모델**: `App\Models\Popups\Popup` @@ -637,6 +630,7 @@ ### work_orders - **stepProgress()**: hasMany → `work_order_step_progress` - **materialInputs()**: hasMany → `work_order_material_inputs` - **shipments()**: hasMany → `shipments` +- **inspections()**: hasMany → `inspections` - **bendingDetail()**: hasOne → `work_order_bending_details` - **documents()**: morphMany → `documents` @@ -743,6 +737,7 @@ ### push_notification_settings ### inspections **모델**: `App\Models\Qualitys\Inspection` +- **workOrder()**: belongsTo → `work_orders` - **item()**: belongsTo → `items` - **inspector()**: belongsTo → `users` - **creator()**: belongsTo → `users` @@ -836,6 +831,7 @@ ### approvals - **steps()**: hasMany → `approval_steps` - **approverSteps()**: hasMany → `approval_steps` - **referenceSteps()**: hasMany → `approval_steps` +- **linkable()**: morphTo → `(Polymorphic)` ### approval_forms **모델**: `App\Models\Tenants\ApprovalForm` @@ -961,7 +957,10 @@ ### leave_policys ### loans **모델**: `App\Models\Tenants\Loan` +- **user()**: belongsTo → `users` - **withdrawal()**: belongsTo → `withdrawals` +- **creator()**: belongsTo → `users` +- **updater()**: belongsTo → `users` ### payments **모델**: `App\Models\Tenants\Payment` @@ -1043,6 +1042,7 @@ ### shipments - **creator()**: belongsTo → `users` - **updater()**: belongsTo → `users` - **items()**: hasMany → `shipment_items` +- **vehicleDispatches()**: hasMany → `shipment_vehicle_dispatches` ### shipment_items **모델**: `App\Models\Tenants\ShipmentItem` @@ -1050,6 +1050,11 @@ ### shipment_items - **shipment()**: belongsTo → `shipments` - **stockLot()**: belongsTo → `stock_lots` +### shipment_vehicle_dispatchs +**모델**: `App\Models\Tenants\ShipmentVehicleDispatch` + +- **shipment()**: belongsTo → `shipments` + ### sites **모델**: `App\Models\Tenants\Site` @@ -1147,6 +1152,8 @@ ### tenant_user_profiles ### today_issues **모델**: `App\Models\Tenants\TodayIssue` +- **reader()**: belongsTo → `users` +- **targetUser()**: belongsTo → `users` ### withdrawals **모델**: `App\Models\Tenants\Withdrawal` diff --git a/routes/api/v1/stats.php b/routes/api/v1/stats.php index eef2469..f9f61dc 100644 --- a/routes/api/v1/stats.php +++ b/routes/api/v1/stats.php @@ -17,3 +17,17 @@ Route::get('/monthly', [StatController::class, 'monthly'])->name('v1.stats.monthly'); Route::get('/alerts', [StatController::class, 'alerts'])->name('v1.stats.alerts'); }); + +// 미개발 Summary API (플레이스홀더 - 오류 방지용) +$placeholderSummary = function () { + return response()->json([ + 'success' => true, + 'message' => '해당 기능은 현재 준비 중입니다.', + 'data' => null, + ]); +}; + +Route::get('production/summary', $placeholderSummary)->name('v1.production.summary'); +Route::get('construction/summary', $placeholderSummary)->name('v1.construction.summary'); +Route::get('unshipped/summary', $placeholderSummary)->name('v1.unshipped.summary'); +Route::get('attendance/summary', $placeholderSummary)->name('v1.attendance.summary'); From 3d4dd9f252fb1a2f85c9222ff8ff2bd2c01bc50e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 5 Mar 2026 11:32:16 +0900 Subject: [PATCH 068/166] =?UTF-8?q?chore:=20[infra]=20Slack=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=B1=84=EB=84=90=20=EB=B6=84=EB=A6=AC=20=E2=80=94?= =?UTF-8?q?=20product=5Finfra=20=E2=86=92=20deploy=5Fapi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- Jenkinsfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 0a5af7b..cbbe5e7 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -17,7 +17,7 @@ pipeline { script { env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim() } - slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token', + slackSend channel: '#deploy_api', color: '#439FE0', tokenCredentialId: 'slack-token', message: "🚀 *api* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" } } @@ -109,11 +109,11 @@ pipeline { post { success { - slackSend channel: '#product_infra', color: 'good', tokenCredentialId: 'slack-token', + slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token', message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" } failure { - slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token', + slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token', message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" script { if (env.BRANCH_NAME == 'main') { From ac72487effd6b0b0d6f546bbb4e16c15e20a1138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 5 Mar 2026 11:37:03 +0900 Subject: [PATCH 069/166] =?UTF-8?q?feat:=20[approval]=20approvals=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EC=97=90=20drafter=5Fread=5Fat=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기안자가 완료 결과를 확인했는지 추적하는 타임스탬프 - 완료함 미읽음 뱃지 기능 지원 --- ...add_drafter_read_at_to_approvals_table.php | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 database/migrations/2026_03_05_112000_add_drafter_read_at_to_approvals_table.php diff --git a/database/migrations/2026_03_05_112000_add_drafter_read_at_to_approvals_table.php b/database/migrations/2026_03_05_112000_add_drafter_read_at_to_approvals_table.php new file mode 100644 index 0000000..99a974e --- /dev/null +++ b/database/migrations/2026_03_05_112000_add_drafter_read_at_to_approvals_table.php @@ -0,0 +1,23 @@ +timestamp('drafter_read_at')->nullable()->after('completed_at') + ->comment('기안자가 완료 결과를 확인한 시각'); + }); + } + + public function down(): void + { + Schema::table('approvals', function (Blueprint $table) { + $table->dropColumn('drafter_read_at'); + }); + } +}; From 558a393c8517f271e165e0d893c60a90422569c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 5 Mar 2026 13:06:31 +0900 Subject: [PATCH 070/166] =?UTF-8?q?feat:=20[approval]=20approvals=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EC=97=90=20resubmit=5Fcount=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._add_resubmit_count_to_approvals_table.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 database/migrations/2026_03_05_160000_add_resubmit_count_to_approvals_table.php diff --git a/database/migrations/2026_03_05_160000_add_resubmit_count_to_approvals_table.php b/database/migrations/2026_03_05_160000_add_resubmit_count_to_approvals_table.php new file mode 100644 index 0000000..3297435 --- /dev/null +++ b/database/migrations/2026_03_05_160000_add_resubmit_count_to_approvals_table.php @@ -0,0 +1,22 @@ +unsignedTinyInteger('resubmit_count')->default(0)->after('current_step'); + }); + } + + public function down(): void + { + Schema::table('approvals', function (Blueprint $table) { + $table->dropColumn('resubmit_count'); + }); + } +}; From ce1f91074e5495cfec6c2c37a68ee26de8d55d73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 5 Mar 2026 13:50:46 +0900 Subject: [PATCH 071/166] =?UTF-8?q?feat:=20[approval]=20approvals=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EC=97=90=20rejection=5Fhistory=20JS?= =?UTF-8?q?ON=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...d_rejection_history_to_approvals_table.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 database/migrations/2026_03_05_163000_add_rejection_history_to_approvals_table.php diff --git a/database/migrations/2026_03_05_163000_add_rejection_history_to_approvals_table.php b/database/migrations/2026_03_05_163000_add_rejection_history_to_approvals_table.php new file mode 100644 index 0000000..54abe41 --- /dev/null +++ b/database/migrations/2026_03_05_163000_add_rejection_history_to_approvals_table.php @@ -0,0 +1,22 @@ +json('rejection_history')->nullable()->after('resubmit_count'); + }); + } + + public function down(): void + { + Schema::table('approvals', function (Blueprint $table) { + $table->dropColumn('rejection_history'); + }); + } +}; From 66d1004bc2ffe393499177efe97f14f57fcec0b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 5 Mar 2026 14:36:47 +0900 Subject: [PATCH 072/166] =?UTF-8?q?feat:=20[rd]=20CM=EC=86=A1=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=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 - cm_songs 테이블: tenant_id, user_id, company_name, industry, lyrics, audio_path, options --- ...026_03_05_170000_create_cm_songs_table.php | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 database/migrations/2026_03_05_170000_create_cm_songs_table.php diff --git a/database/migrations/2026_03_05_170000_create_cm_songs_table.php b/database/migrations/2026_03_05_170000_create_cm_songs_table.php new file mode 100644 index 0000000..370b0c5 --- /dev/null +++ b/database/migrations/2026_03_05_170000_create_cm_songs_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->unsignedBigInteger('user_id')->index(); + $table->string('company_name', 100); + $table->string('industry', 200); + $table->text('lyrics'); + $table->string('audio_path')->nullable(); + $table->json('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('cm_songs'); + } +}; From f41605ca7304b568e18cab4685eb4624075c7cce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 5 Mar 2026 18:54:25 +0900 Subject: [PATCH 073/166] =?UTF-8?q?feat:=20[approval]=20=EC=9E=AC=EC=A7=81?= =?UTF-8?q?=EC=A6=9D=EB=AA=85=EC=84=9C=20=EC=96=91=EC=8B=9D=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 - approval_forms 테이블에 employment_cert 폼 삽입 --- ..._03_05_184507_add_employment_cert_form.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 database/migrations/2026_03_05_184507_add_employment_cert_form.php diff --git a/database/migrations/2026_03_05_184507_add_employment_cert_form.php b/database/migrations/2026_03_05_184507_add_employment_cert_form.php new file mode 100644 index 0000000..67806b7 --- /dev/null +++ b/database/migrations/2026_03_05_184507_add_employment_cert_form.php @@ -0,0 +1,38 @@ +whereNull('deleted_at')->pluck('id'); + + foreach ($tenants as $tenantId) { + $exists = DB::table('approval_forms') + ->where('tenant_id', $tenantId) + ->where('code', 'employment_cert') + ->exists(); + + if (! $exists) { + DB::table('approval_forms')->insert([ + 'tenant_id' => $tenantId, + 'name' => '재직증명서', + 'code' => 'employment_cert', + 'category' => 'certificate', + 'template' => '[]', + 'body_template' => '', + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + } + + public function down(): void + { + DB::table('approval_forms')->where('code', 'employment_cert')->delete(); + } +}; From 8c9f2fcfb5b2a9605b07c7444850ca0ee99bb81e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Thu, 5 Mar 2026 20:45:54 +0900 Subject: [PATCH 074/166] =?UTF-8?q?feat:=20[bill,loan]=20=EC=96=B4?= =?UTF-8?q?=EC=9D=8C=20V8=20=ED=99=95=EC=9E=A5=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B0=80=EC=A7=80=EA=B8=89=EA=B8=88=20=EC=83=81?= =?UTF-8?q?=ED=92=88=EA=B6=8C=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bill 모델: V8 확장 필드 54개 추가 (증권종류, 할인, 배서, 추심, 개서, 부도 등) - Bill 상태: 수취/발행 어음·수표별 세분화된 상태 체계 - BillService: assignV8Fields/syncInstallments 헬퍼 추출, instrument_type/medium 필터 - BillInstallment: type/counterparty 필드 추가 - Loan 모델: holding/used/disposed 상태 + metadata(JSON) 필드 추가 - LoanService: 상품권 카테고리 지원 (summary 상태별 집계, store 기본상태 holding) - FormRequest: V8 확장 필드 검증 규칙 추가 - 마이그레이션: bills V8 필드 + loans metadata 컬럼 Co-Authored-By: Claude Opus 4.6 --- .../Controllers/Api/V1/LoanController.php | 6 +- app/Http/Requests/Loan/LoanIndexRequest.php | 1 + app/Http/Requests/Loan/LoanStoreRequest.php | 19 +- app/Http/Requests/Loan/LoanUpdateRequest.php | 16 ++ .../Requests/V1/Bill/StoreBillRequest.php | 88 +++++++- .../Requests/V1/Bill/UpdateBillRequest.php | 60 +++++- app/Models/Tenants/Bill.php | 163 ++++++++++++++- app/Models/Tenants/BillInstallment.php | 2 + app/Models/Tenants/Loan.php | 26 ++- app/Services/BillService.php | 116 ++++++++--- app/Services/LoanService.php | 82 +++++++- ...05_100000_add_v8_fields_to_bills_table.php | 195 ++++++++++++++++++ ..._05_200000_add_metadata_to_loans_table.php | 28 +++ 13 files changed, 743 insertions(+), 59 deletions(-) create mode 100644 database/migrations/2026_03_05_100000_add_v8_fields_to_bills_table.php create mode 100644 database/migrations/2026_03_05_200000_add_metadata_to_loans_table.php diff --git a/app/Http/Controllers/Api/V1/LoanController.php b/app/Http/Controllers/Api/V1/LoanController.php index 34ff161..276902e 100644 --- a/app/Http/Controllers/Api/V1/LoanController.php +++ b/app/Http/Controllers/Api/V1/LoanController.php @@ -34,8 +34,10 @@ public function index(LoanIndexRequest $request): JsonResponse */ public function summary(LoanIndexRequest $request): JsonResponse { - $userId = $request->validated()['user_id'] ?? null; - $result = $this->loanService->summary($userId); + $validated = $request->validated(); + $userId = $validated['user_id'] ?? null; + $category = $validated['category'] ?? null; + $result = $this->loanService->summary($userId, $category); return ApiResponse::success($result, __('message.fetched')); } diff --git a/app/Http/Requests/Loan/LoanIndexRequest.php b/app/Http/Requests/Loan/LoanIndexRequest.php index c493b1b..26282ef 100644 --- a/app/Http/Requests/Loan/LoanIndexRequest.php +++ b/app/Http/Requests/Loan/LoanIndexRequest.php @@ -29,6 +29,7 @@ public function rules(): array return [ 'user_id' => ['nullable', 'integer', 'exists:users,id'], 'status' => ['nullable', 'string', Rule::in(Loan::STATUSES)], + 'category' => ['nullable', 'string', Rule::in(Loan::CATEGORIES)], 'start_date' => ['nullable', 'date', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:start_date'], 'search' => ['nullable', 'string', 'max:100'], diff --git a/app/Http/Requests/Loan/LoanStoreRequest.php b/app/Http/Requests/Loan/LoanStoreRequest.php index 345baac..096c827 100644 --- a/app/Http/Requests/Loan/LoanStoreRequest.php +++ b/app/Http/Requests/Loan/LoanStoreRequest.php @@ -2,7 +2,9 @@ namespace App\Http\Requests\Loan; +use App\Models\Tenants\Loan; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class LoanStoreRequest extends FormRequest { @@ -21,12 +23,27 @@ public function authorize(): bool */ public function rules(): array { + $isGiftCertificate = $this->input('category') === Loan::CATEGORY_GIFT_CERTIFICATE; + return [ - 'user_id' => ['required', 'integer', 'exists:users,id'], + 'user_id' => [$isGiftCertificate ? 'nullable' : 'required', 'integer', 'exists:users,id'], 'loan_date' => ['required', 'date', 'date_format:Y-m-d'], 'amount' => ['required', 'numeric', 'min:0', 'max:999999999999.99'], 'purpose' => ['nullable', 'string', 'max:1000'], 'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'], + 'category' => ['nullable', 'string', Rule::in(Loan::CATEGORIES)], + 'status' => ['nullable', 'string', Rule::in(Loan::STATUSES)], + 'metadata' => ['nullable', 'array'], + 'metadata.serial_number' => ['nullable', 'string', 'max:100'], + 'metadata.cert_name' => ['nullable', 'string', 'max:200'], + 'metadata.vendor_id' => ['nullable', 'string', 'max:50'], + 'metadata.vendor_name' => ['nullable', 'string', 'max:200'], + 'metadata.purchase_purpose' => ['nullable', 'string', 'max:50'], + 'metadata.entertainment_expense' => ['nullable', 'string', 'max:50'], + 'metadata.recipient_name' => ['nullable', 'string', 'max:100'], + 'metadata.recipient_organization' => ['nullable', 'string', 'max:200'], + 'metadata.usage_description' => ['nullable', 'string', 'max:1000'], + 'metadata.memo' => ['nullable', 'string', 'max:2000'], ]; } diff --git a/app/Http/Requests/Loan/LoanUpdateRequest.php b/app/Http/Requests/Loan/LoanUpdateRequest.php index ba8449c..510089f 100644 --- a/app/Http/Requests/Loan/LoanUpdateRequest.php +++ b/app/Http/Requests/Loan/LoanUpdateRequest.php @@ -2,7 +2,9 @@ namespace App\Http\Requests\Loan; +use App\Models\Tenants\Loan; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class LoanUpdateRequest extends FormRequest { @@ -27,6 +29,20 @@ public function rules(): array 'amount' => ['sometimes', 'numeric', 'min:0', 'max:999999999999.99'], 'purpose' => ['nullable', 'string', 'max:1000'], 'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'], + 'category' => ['sometimes', 'string', Rule::in(Loan::CATEGORIES)], + 'status' => ['sometimes', 'string', Rule::in(Loan::STATUSES)], + 'settlement_date' => ['nullable', 'date', 'date_format:Y-m-d'], + 'metadata' => ['nullable', 'array'], + 'metadata.serial_number' => ['nullable', 'string', 'max:100'], + 'metadata.cert_name' => ['nullable', 'string', 'max:200'], + 'metadata.vendor_id' => ['nullable', 'string', 'max:50'], + 'metadata.vendor_name' => ['nullable', 'string', 'max:200'], + 'metadata.purchase_purpose' => ['nullable', 'string', 'max:50'], + 'metadata.entertainment_expense' => ['nullable', 'string', 'max:50'], + 'metadata.recipient_name' => ['nullable', 'string', 'max:100'], + 'metadata.recipient_organization' => ['nullable', 'string', 'max:200'], + 'metadata.usage_description' => ['nullable', 'string', 'max:1000'], + 'metadata.memo' => ['nullable', 'string', 'max:2000'], ]; } diff --git a/app/Http/Requests/V1/Bill/StoreBillRequest.php b/app/Http/Requests/V1/Bill/StoreBillRequest.php index 562dc98..3aaed47 100644 --- a/app/Http/Requests/V1/Bill/StoreBillRequest.php +++ b/app/Http/Requests/V1/Bill/StoreBillRequest.php @@ -17,6 +17,7 @@ public function rules(): array $tenantId = app('tenant_id') ?? 0; return [ + // === 기존 필드 === 'bill_number' => [ 'nullable', 'string', @@ -30,16 +31,99 @@ public function rules(): array 'client_name' => ['nullable', 'string', 'max:100'], 'amount' => ['required', 'numeric', 'min:0'], 'issue_date' => ['required', 'date'], - 'maturity_date' => ['required', 'date', 'after_or_equal:issue_date'], - 'status' => ['nullable', 'string', 'in:stored,maturityAlert,maturityResult,paymentComplete,dishonored,collectionRequest,collectionComplete,suing'], + 'maturity_date' => ['nullable', 'date', 'after_or_equal:issue_date'], + 'status' => ['nullable', 'string', 'max:30'], 'reason' => ['nullable', 'string', 'max:255'], 'installment_count' => ['nullable', 'integer', 'min:0'], 'note' => ['nullable', 'string', 'max:1000'], 'is_electronic' => ['nullable', 'boolean'], 'bank_account_id' => ['nullable', 'integer', 'exists:bank_accounts,id'], + + // === V8 증권종류/매체/구분 === + 'instrument_type' => ['nullable', 'string', 'in:promissory,exchange,cashierCheck,currentCheck'], + 'medium' => ['nullable', 'string', 'in:electronic,paper'], + 'bill_category' => ['nullable', 'string', 'in:commercial,other'], + + // === 전자어음 === + 'electronic_bill_no' => ['nullable', 'string', 'max:100'], + 'registration_org' => ['nullable', 'string', 'in:kftc,bank'], + + // === 환어음 === + 'drawee' => ['nullable', 'string', 'max:100'], + 'acceptance_status' => ['nullable', 'string', 'in:accepted,pending,refused'], + 'acceptance_date' => ['nullable', 'date'], + 'acceptance_refusal_date' => ['nullable', 'date'], + 'acceptance_refusal_reason' => ['nullable', 'string', 'max:50'], + + // === 받을어음 전용 === + 'endorsement' => ['nullable', 'string', 'in:endorsable,nonEndorsable'], + 'endorsement_order' => ['nullable', 'string', 'max:5'], + 'storage_place' => ['nullable', 'string', 'in:safe,bank,other'], + 'issuer_bank' => ['nullable', 'string', 'max:100'], + + // 할인 + 'is_discounted' => ['nullable', 'boolean'], + 'discount_date' => ['nullable', 'date'], + 'discount_bank' => ['nullable', 'string', 'max:100'], + 'discount_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'discount_amount' => ['nullable', 'numeric', 'min:0'], + + // 배서양도 + 'endorsement_date' => ['nullable', 'date'], + 'endorsee' => ['nullable', 'string', 'max:100'], + 'endorsement_reason' => ['nullable', 'string', 'in:payment,guarantee,collection,other'], + + // 추심 + 'collection_bank' => ['nullable', 'string', 'max:100'], + 'collection_request_date' => ['nullable', 'date'], + 'collection_fee' => ['nullable', 'numeric', 'min:0'], + 'collection_complete_date' => ['nullable', 'date'], + 'collection_result' => ['nullable', 'string', 'in:success,partial,failed,pending'], + 'collection_deposit_date' => ['nullable', 'date'], + 'collection_deposit_amount' => ['nullable', 'numeric', 'min:0'], + + // === 지급어음 전용 === + 'settlement_bank' => ['nullable', 'string', 'max:100'], + 'payment_method' => ['nullable', 'string', 'in:autoTransfer,currentAccount,other'], + 'actual_payment_date' => ['nullable', 'date'], + + // === 공통 === + 'payment_place' => ['nullable', 'string', 'max:30'], + 'payment_place_detail' => ['nullable', 'string', 'max:200'], + + // 개서 + 'renewal_date' => ['nullable', 'date'], + 'renewal_new_bill_no' => ['nullable', 'string', 'max:50'], + 'renewal_reason' => ['nullable', 'string', 'in:maturityExtension,amountChange,conditionChange,other'], + + // 소구 + 'recourse_date' => ['nullable', 'date'], + 'recourse_amount' => ['nullable', 'numeric', 'min:0'], + 'recourse_target' => ['nullable', 'string', 'max:100'], + 'recourse_reason' => ['nullable', 'string', 'in:endorsedDishonor,discountDishonor,other'], + + // 환매 + 'buyback_date' => ['nullable', 'date'], + 'buyback_amount' => ['nullable', 'numeric', 'min:0'], + 'buyback_bank' => ['nullable', 'string', 'max:100'], + + // 부도/법적절차 + 'dishonored_date' => ['nullable', 'date'], + 'dishonored_reason' => ['nullable', 'string', 'max:30'], + 'has_protest' => ['nullable', 'boolean'], + 'protest_date' => ['nullable', 'date'], + 'recourse_notice_date' => ['nullable', 'date'], + 'recourse_notice_deadline' => ['nullable', 'date'], + + // 분할배서 + 'is_split' => ['nullable', 'boolean'], + + // === 차수 관리 === 'installments' => ['nullable', 'array'], 'installments.*.date' => ['required_with:installments', 'date'], 'installments.*.amount' => ['required_with:installments', 'numeric', 'min:0'], + 'installments.*.type' => ['nullable', 'string', 'max:30'], + 'installments.*.counterparty' => ['nullable', 'string', 'max:100'], 'installments.*.note' => ['nullable', 'string', 'max:255'], ]; } diff --git a/app/Http/Requests/V1/Bill/UpdateBillRequest.php b/app/Http/Requests/V1/Bill/UpdateBillRequest.php index 029da50..dc0a3c9 100644 --- a/app/Http/Requests/V1/Bill/UpdateBillRequest.php +++ b/app/Http/Requests/V1/Bill/UpdateBillRequest.php @@ -14,6 +14,7 @@ public function authorize(): bool public function rules(): array { return [ + // === 기존 필드 === 'bill_number' => ['nullable', 'string', 'max:50'], 'bill_type' => ['nullable', 'string', 'in:received,issued'], 'client_id' => ['nullable', 'integer', 'exists:clients,id'], @@ -21,15 +22,72 @@ public function rules(): array 'amount' => ['nullable', 'numeric', 'min:0'], 'issue_date' => ['nullable', 'date'], 'maturity_date' => ['nullable', 'date', 'after_or_equal:issue_date'], - 'status' => ['nullable', 'string', 'in:stored,maturityAlert,maturityResult,paymentComplete,dishonored,collectionRequest,collectionComplete,suing'], + 'status' => ['nullable', 'string', 'max:30'], 'reason' => ['nullable', 'string', 'max:255'], 'installment_count' => ['nullable', 'integer', 'min:0'], 'note' => ['nullable', 'string', 'max:1000'], 'is_electronic' => ['nullable', 'boolean'], 'bank_account_id' => ['nullable', 'integer', 'exists:bank_accounts,id'], + + // === V8 확장 === + 'instrument_type' => ['nullable', 'string', 'in:promissory,exchange,cashierCheck,currentCheck'], + 'medium' => ['nullable', 'string', 'in:electronic,paper'], + 'bill_category' => ['nullable', 'string', 'in:commercial,other'], + 'electronic_bill_no' => ['nullable', 'string', 'max:100'], + 'registration_org' => ['nullable', 'string', 'in:kftc,bank'], + 'drawee' => ['nullable', 'string', 'max:100'], + 'acceptance_status' => ['nullable', 'string', 'in:accepted,pending,refused'], + 'acceptance_date' => ['nullable', 'date'], + 'acceptance_refusal_date' => ['nullable', 'date'], + 'acceptance_refusal_reason' => ['nullable', 'string', 'max:50'], + 'endorsement' => ['nullable', 'string', 'in:endorsable,nonEndorsable'], + 'endorsement_order' => ['nullable', 'string', 'max:5'], + 'storage_place' => ['nullable', 'string', 'in:safe,bank,other'], + 'issuer_bank' => ['nullable', 'string', 'max:100'], + 'is_discounted' => ['nullable', 'boolean'], + 'discount_date' => ['nullable', 'date'], + 'discount_bank' => ['nullable', 'string', 'max:100'], + 'discount_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'discount_amount' => ['nullable', 'numeric', 'min:0'], + 'endorsement_date' => ['nullable', 'date'], + 'endorsee' => ['nullable', 'string', 'max:100'], + 'endorsement_reason' => ['nullable', 'string', 'in:payment,guarantee,collection,other'], + 'collection_bank' => ['nullable', 'string', 'max:100'], + 'collection_request_date' => ['nullable', 'date'], + 'collection_fee' => ['nullable', 'numeric', 'min:0'], + 'collection_complete_date' => ['nullable', 'date'], + 'collection_result' => ['nullable', 'string', 'in:success,partial,failed,pending'], + 'collection_deposit_date' => ['nullable', 'date'], + 'collection_deposit_amount' => ['nullable', 'numeric', 'min:0'], + 'settlement_bank' => ['nullable', 'string', 'max:100'], + 'payment_method' => ['nullable', 'string', 'in:autoTransfer,currentAccount,other'], + 'actual_payment_date' => ['nullable', 'date'], + 'payment_place' => ['nullable', 'string', 'max:30'], + 'payment_place_detail' => ['nullable', 'string', 'max:200'], + 'renewal_date' => ['nullable', 'date'], + 'renewal_new_bill_no' => ['nullable', 'string', 'max:50'], + 'renewal_reason' => ['nullable', 'string', 'in:maturityExtension,amountChange,conditionChange,other'], + 'recourse_date' => ['nullable', 'date'], + 'recourse_amount' => ['nullable', 'numeric', 'min:0'], + 'recourse_target' => ['nullable', 'string', 'max:100'], + 'recourse_reason' => ['nullable', 'string', 'in:endorsedDishonor,discountDishonor,other'], + 'buyback_date' => ['nullable', 'date'], + 'buyback_amount' => ['nullable', 'numeric', 'min:0'], + 'buyback_bank' => ['nullable', 'string', 'max:100'], + 'dishonored_date' => ['nullable', 'date'], + 'dishonored_reason' => ['nullable', 'string', 'max:30'], + 'has_protest' => ['nullable', 'boolean'], + 'protest_date' => ['nullable', 'date'], + 'recourse_notice_date' => ['nullable', 'date'], + 'recourse_notice_deadline' => ['nullable', 'date'], + 'is_split' => ['nullable', 'boolean'], + + // === 차수 관리 === 'installments' => ['nullable', 'array'], 'installments.*.date' => ['required_with:installments', 'date'], 'installments.*.amount' => ['required_with:installments', 'numeric', 'min:0'], + 'installments.*.type' => ['nullable', 'string', 'max:30'], + 'installments.*.counterparty' => ['nullable', 'string', 'max:100'], 'installments.*.note' => ['nullable', 'string', 'max:255'], ]; } diff --git a/app/Models/Tenants/Bill.php b/app/Models/Tenants/Bill.php index 1200fa7..e14d7b8 100644 --- a/app/Models/Tenants/Bill.php +++ b/app/Models/Tenants/Bill.php @@ -31,6 +31,58 @@ class Bill extends Model 'created_by', 'updated_by', 'deleted_by', + // V8 확장 필드 + 'instrument_type', + 'medium', + 'bill_category', + 'electronic_bill_no', + 'registration_org', + 'drawee', + 'acceptance_status', + 'acceptance_date', + 'acceptance_refusal_date', + 'acceptance_refusal_reason', + 'endorsement', + 'endorsement_order', + 'storage_place', + 'issuer_bank', + 'is_discounted', + 'discount_date', + 'discount_bank', + 'discount_rate', + 'discount_amount', + 'endorsement_date', + 'endorsee', + 'endorsement_reason', + 'collection_bank', + 'collection_request_date', + 'collection_fee', + 'collection_complete_date', + 'collection_result', + 'collection_deposit_date', + 'collection_deposit_amount', + 'settlement_bank', + 'payment_method', + 'actual_payment_date', + 'payment_place', + 'payment_place_detail', + 'renewal_date', + 'renewal_new_bill_no', + 'renewal_reason', + 'recourse_date', + 'recourse_amount', + 'recourse_target', + 'recourse_reason', + 'buyback_date', + 'buyback_amount', + 'buyback_bank', + 'dishonored_date', + 'dishonored_reason', + 'has_protest', + 'protest_date', + 'recourse_notice_date', + 'recourse_notice_deadline', + 'is_split', ]; protected $casts = [ @@ -41,21 +93,57 @@ class Bill extends Model 'bank_account_id' => 'integer', 'installment_count' => 'integer', 'is_electronic' => 'boolean', + // V8 확장 casts + 'acceptance_date' => 'date', + 'acceptance_refusal_date' => 'date', + 'discount_date' => 'date', + 'discount_rate' => 'decimal:2', + 'discount_amount' => 'decimal:2', + 'endorsement_date' => 'date', + 'collection_request_date' => 'date', + 'collection_fee' => 'decimal:2', + 'collection_complete_date' => 'date', + 'collection_deposit_date' => 'date', + 'collection_deposit_amount' => 'decimal:2', + 'actual_payment_date' => 'date', + 'renewal_date' => 'date', + 'recourse_date' => 'date', + 'recourse_amount' => 'decimal:2', + 'buyback_date' => 'date', + 'buyback_amount' => 'decimal:2', + 'dishonored_date' => 'date', + 'protest_date' => 'date', + 'recourse_notice_date' => 'date', + 'recourse_notice_deadline' => 'date', + 'is_discounted' => 'boolean', + 'has_protest' => 'boolean', + 'is_split' => 'boolean', ]; /** * 배열/JSON 변환 시 날짜 형식 지정 */ + /** + * 날짜 cast 필드 목록 (toArray에서 Y-m-d 형식 변환용) + */ + private const DATE_FIELDS = [ + 'issue_date', 'maturity_date', + 'acceptance_date', 'acceptance_refusal_date', + 'discount_date', 'endorsement_date', + 'collection_request_date', 'collection_complete_date', 'collection_deposit_date', + 'actual_payment_date', + 'renewal_date', 'recourse_date', 'buyback_date', + 'dishonored_date', 'protest_date', 'recourse_notice_date', 'recourse_notice_deadline', + ]; + public function toArray(): array { $array = parent::toArray(); - // 날짜 필드를 Y-m-d 형식으로 변환 - if (isset($array['issue_date']) && $this->issue_date) { - $array['issue_date'] = $this->issue_date->format('Y-m-d'); - } - if (isset($array['maturity_date']) && $this->maturity_date) { - $array['maturity_date'] = $this->maturity_date->format('Y-m-d'); + foreach (self::DATE_FIELDS as $field) { + if (isset($array[$field]) && $this->{$field}) { + $array[$field] = $this->{$field}->format('Y-m-d'); + } } return $array; @@ -69,14 +157,42 @@ public function toArray(): array 'issued' => '발행', ]; + /** + * 증권종류 + */ + public const INSTRUMENT_TYPES = [ + 'promissory' => '약속어음', + 'exchange' => '환어음', + 'cashierCheck' => '자기앞수표', + 'currentCheck' => '당좌수표', + ]; + /** * 수취 어음 상태 목록 */ public const RECEIVED_STATUSES = [ 'stored' => '보관중', + 'endorsed' => '배서양도', + 'discounted' => '할인', + 'collectionRequest' => '추심의뢰', + 'collectionComplete' => '추심완료', + 'maturityDeposit' => '만기입금', + 'paymentComplete' => '결제완료', + 'dishonored' => '부도', + 'renewed' => '개서', + 'buyback' => '환매', + // 하위호환 'maturityAlert' => '만기입금(7일전)', 'maturityResult' => '만기결과', - 'paymentComplete' => '결제완료', + ]; + + /** + * 수취 수표 상태 목록 + */ + public const RECEIVED_CHECK_STATUSES = [ + 'stored' => '보관중', + 'endorsed' => '배서양도', + 'deposited' => '입금', 'dishonored' => '부도', ]; @@ -85,10 +201,25 @@ public function toArray(): array */ public const ISSUED_STATUSES = [ 'stored' => '보관중', + 'issued' => '지급대기', + 'maturityPayment' => '만기결제', + 'paymentComplete' => '결제완료', + 'dishonored' => '부도', + 'renewed' => '개서', + // 하위호환 'maturityAlert' => '만기입금(7일전)', 'collectionRequest' => '추심의뢰', 'collectionComplete' => '추심완료', 'suing' => '추소중', + ]; + + /** + * 발행 수표 상태 목록 + */ + public const ISSUED_CHECK_STATUSES = [ + 'stored' => '보관중', + 'issued' => '지급대기', + 'cashed' => '현금화', 'dishonored' => '부도', ]; @@ -149,11 +280,25 @@ public function getBillTypeLabelAttribute(): string */ public function getStatusLabelAttribute(): string { + $isCheck = in_array($this->instrument_type, ['cashierCheck', 'currentCheck']); + if ($this->bill_type === 'received') { - return self::RECEIVED_STATUSES[$this->status] ?? $this->status; + $statuses = $isCheck ? self::RECEIVED_CHECK_STATUSES : self::RECEIVED_STATUSES; + + return $statuses[$this->status] ?? self::RECEIVED_STATUSES[$this->status] ?? $this->status; } - return self::ISSUED_STATUSES[$this->status] ?? $this->status; + $statuses = $isCheck ? self::ISSUED_CHECK_STATUSES : self::ISSUED_STATUSES; + + return $statuses[$this->status] ?? self::ISSUED_STATUSES[$this->status] ?? $this->status; + } + + /** + * 증권종류 라벨 + */ + public function getInstrumentTypeLabelAttribute(): string + { + return self::INSTRUMENT_TYPES[$this->instrument_type] ?? $this->instrument_type ?? '약속어음'; } /** diff --git a/app/Models/Tenants/BillInstallment.php b/app/Models/Tenants/BillInstallment.php index 41656e2..926efc1 100644 --- a/app/Models/Tenants/BillInstallment.php +++ b/app/Models/Tenants/BillInstallment.php @@ -12,8 +12,10 @@ class BillInstallment extends Model protected $fillable = [ 'bill_id', + 'type', 'installment_date', 'amount', + 'counterparty', 'note', 'created_by', ]; diff --git a/app/Models/Tenants/Loan.php b/app/Models/Tenants/Loan.php index d2a88db..c769300 100644 --- a/app/Models/Tenants/Loan.php +++ b/app/Models/Tenants/Loan.php @@ -28,6 +28,12 @@ class Loan extends Model public const STATUS_PARTIAL = 'partial'; // 부분정산 + public const STATUS_HOLDING = 'holding'; // 보유 (상품권) + + public const STATUS_USED = 'used'; // 사용 (상품권) + + public const STATUS_DISPOSED = 'disposed'; // 폐기 (상품권) + /** * 상태 목록 */ @@ -35,6 +41,9 @@ class Loan extends Model self::STATUS_OUTSTANDING, self::STATUS_SETTLED, self::STATUS_PARTIAL, + self::STATUS_HOLDING, + self::STATUS_USED, + self::STATUS_DISPOSED, ]; /** @@ -104,6 +113,7 @@ class Loan extends Model 'settlement_amount', 'status', 'category', + 'metadata', 'withdrawal_id', 'created_by', 'updated_by', @@ -115,6 +125,7 @@ class Loan extends Model 'settlement_date' => 'date', 'amount' => 'decimal:2', 'settlement_amount' => 'decimal:2', + 'metadata' => 'array', ]; // ========================================================================= @@ -166,6 +177,9 @@ public function getStatusLabelAttribute(): string self::STATUS_OUTSTANDING => '미정산', self::STATUS_SETTLED => '정산완료', self::STATUS_PARTIAL => '부분정산', + self::STATUS_HOLDING => '보유', + self::STATUS_USED => '사용', + self::STATUS_DISPOSED => '폐기', default => $this->status, }; } @@ -209,15 +223,21 @@ public function getElapsedDaysAttribute(): int */ public function isEditable(): bool { - return $this->status === self::STATUS_OUTSTANDING; + return in_array($this->status, [ + self::STATUS_OUTSTANDING, + self::STATUS_HOLDING, + ]); } /** - * 삭제 가능 여부 (미정산 상태만) + * 삭제 가능 여부 (미정산/보유 상태만) */ public function isDeletable(): bool { - return $this->status === self::STATUS_OUTSTANDING; + return in_array($this->status, [ + self::STATUS_OUTSTANDING, + self::STATUS_HOLDING, + ]); } /** diff --git a/app/Services/BillService.php b/app/Services/BillService.php index b2d1512..e6a451a 100644 --- a/app/Services/BillService.php +++ b/app/Services/BillService.php @@ -48,6 +48,16 @@ public function index(array $params): LengthAwarePaginator $query->where('client_id', $params['client_id']); } + // 증권종류 필터 + if (! empty($params['instrument_type'])) { + $query->where('instrument_type', $params['instrument_type']); + } + + // 매체 필터 + if (! empty($params['medium'])) { + $query->where('medium', $params['medium']); + } + // 전자어음 필터 if (isset($params['is_electronic']) && $params['is_electronic'] !== '') { $query->where('is_electronic', (bool) $params['is_electronic']); @@ -113,32 +123,23 @@ public function store(array $data): Bill $bill->client_name = $data['client_name'] ?? null; $bill->amount = $data['amount']; $bill->issue_date = $data['issue_date']; - $bill->maturity_date = $data['maturity_date']; + $bill->maturity_date = $data['maturity_date'] ?? null; $bill->status = $data['status'] ?? 'stored'; $bill->reason = $data['reason'] ?? null; $bill->installment_count = $data['installment_count'] ?? 0; $bill->note = $data['note'] ?? null; $bill->is_electronic = $data['is_electronic'] ?? false; $bill->bank_account_id = $data['bank_account_id'] ?? null; + + // V8 확장 필드 + $this->assignV8Fields($bill, $data); + $bill->created_by = $userId; $bill->updated_by = $userId; $bill->save(); // 차수 관리 저장 - if (! empty($data['installments'])) { - foreach ($data['installments'] as $installment) { - BillInstallment::create([ - 'bill_id' => $bill->id, - 'installment_date' => $installment['date'], - 'amount' => $installment['amount'], - 'note' => $installment['note'] ?? null, - 'created_by' => $userId, - ]); - } - // 차수 카운트 업데이트 - $bill->installment_count = count($data['installments']); - $bill->save(); - } + $this->syncInstallments($bill, $data['installments'] ?? [], $userId); return $bill->load(['client:id,name', 'bankAccount:id,bank_name,account_name', 'installments']); }); @@ -157,6 +158,7 @@ public function update(int $id, array $data): Bill ->where('tenant_id', $tenantId) ->findOrFail($id); + // 기존 필드 if (isset($data['bill_number'])) { $bill->bill_number = $data['bill_number']; } @@ -175,7 +177,7 @@ public function update(int $id, array $data): Bill if (isset($data['issue_date'])) { $bill->issue_date = $data['issue_date']; } - if (isset($data['maturity_date'])) { + if (array_key_exists('maturity_date', $data)) { $bill->maturity_date = $data['maturity_date']; } if (isset($data['status'])) { @@ -194,27 +196,15 @@ public function update(int $id, array $data): Bill $bill->bank_account_id = $data['bank_account_id']; } + // V8 확장 필드 + $this->assignV8Fields($bill, $data); + $bill->updated_by = $userId; $bill->save(); // 차수 관리 업데이트 (전체 교체) if (isset($data['installments'])) { - // 기존 차수 삭제 - $bill->installments()->delete(); - - // 새 차수 추가 - foreach ($data['installments'] as $installment) { - BillInstallment::create([ - 'bill_id' => $bill->id, - 'installment_date' => $installment['date'], - 'amount' => $installment['amount'], - 'note' => $installment['note'] ?? null, - 'created_by' => $userId, - ]); - } - // 차수 카운트 업데이트 - $bill->installment_count = count($data['installments']); - $bill->save(); + $this->syncInstallments($bill, $data['installments'], $userId); } return $bill->fresh(['client:id,name', 'bankAccount:id,bank_name,account_name', 'installments']); @@ -440,6 +430,68 @@ public function dashboardDetail(): array ]; } + /** + * V8 확장 필드를 Bill 모델에 할당 + */ + private function assignV8Fields(Bill $bill, array $data): void + { + $v8Fields = [ + 'instrument_type', 'medium', 'bill_category', + 'electronic_bill_no', 'registration_org', + 'drawee', 'acceptance_status', 'acceptance_date', + 'acceptance_refusal_date', 'acceptance_refusal_reason', + 'endorsement', 'endorsement_order', 'storage_place', 'issuer_bank', + 'is_discounted', 'discount_date', 'discount_bank', 'discount_rate', 'discount_amount', + 'endorsement_date', 'endorsee', 'endorsement_reason', + 'collection_bank', 'collection_request_date', 'collection_fee', + 'collection_complete_date', 'collection_result', 'collection_deposit_date', 'collection_deposit_amount', + 'settlement_bank', 'payment_method', 'actual_payment_date', + 'payment_place', 'payment_place_detail', + 'renewal_date', 'renewal_new_bill_no', 'renewal_reason', + 'recourse_date', 'recourse_amount', 'recourse_target', 'recourse_reason', + 'buyback_date', 'buyback_amount', 'buyback_bank', + 'dishonored_date', 'dishonored_reason', 'has_protest', 'protest_date', + 'recourse_notice_date', 'recourse_notice_deadline', + 'is_split', + ]; + + foreach ($v8Fields as $field) { + if (array_key_exists($field, $data)) { + $bill->{$field} = $data[$field]; + } + } + } + + /** + * 차수(이력) 동기화 — 기존 삭제 후 새로 생성 + */ + private function syncInstallments(Bill $bill, array $installments, int $userId): void + { + if (empty($installments)) { + return; + } + + // 기존 차수 삭제 + $bill->installments()->delete(); + + // 새 차수 추가 + foreach ($installments as $installment) { + BillInstallment::create([ + 'bill_id' => $bill->id, + 'type' => $installment['type'] ?? 'other', + 'installment_date' => $installment['date'], + 'amount' => $installment['amount'], + 'counterparty' => $installment['counterparty'] ?? null, + 'note' => $installment['note'] ?? null, + 'created_by' => $userId, + ]); + } + + // 차수 카운트 업데이트 + $bill->installment_count = count($installments); + $bill->save(); + } + /** * 어음번호 자동 생성 */ diff --git a/app/Services/LoanService.php b/app/Services/LoanService.php index 40e290c..d674e11 100644 --- a/app/Services/LoanService.php +++ b/app/Services/LoanService.php @@ -25,6 +25,11 @@ public function index(array $params): LengthAwarePaginator ->where('tenant_id', $tenantId) ->with(['user:id,name,email', 'creator:id,name']); + // 카테고리 필터 + if (! empty($params['category'])) { + $query->where('category', $params['category']); + } + // 사용자 필터 if (! empty($params['user_id'])) { $query->where('user_id', $params['user_id']); @@ -84,7 +89,7 @@ public function show(int $id): Loan /** * 가지급금 요약 (특정 사용자 또는 전체) */ - public function summary(?int $userId = null): array + public function summary(?int $userId = null, ?string $category = null): array { $tenantId = $this->tenantId(); @@ -95,7 +100,14 @@ public function summary(?int $userId = null): array $query->where('user_id', $userId); } - $stats = $query->selectRaw(' + if ($category) { + $query->where('category', $category); + } + + // 상품권 카테고리: holding/used/disposed 상태별 집계 추가 + $isGiftCertificate = $category === Loan::CATEGORY_GIFT_CERTIFICATE; + + $selectRaw = ' COUNT(*) as total_count, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as outstanding_count, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as settled_count, @@ -103,10 +115,27 @@ public function summary(?int $userId = null): array SUM(amount) as total_amount, SUM(COALESCE(settlement_amount, 0)) as total_settled, SUM(amount - COALESCE(settlement_amount, 0)) as total_outstanding - ', [Loan::STATUS_OUTSTANDING, Loan::STATUS_SETTLED, Loan::STATUS_PARTIAL]) - ->first(); + '; + $bindings = [Loan::STATUS_OUTSTANDING, Loan::STATUS_SETTLED, Loan::STATUS_PARTIAL]; - return [ + if ($isGiftCertificate) { + $selectRaw .= ', + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as holding_count, + SUM(CASE WHEN status = ? THEN amount ELSE 0 END) as holding_amount, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as used_count, + SUM(CASE WHEN status = ? THEN amount ELSE 0 END) as used_amount, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as disposed_count + '; + $bindings = array_merge($bindings, [ + Loan::STATUS_HOLDING, Loan::STATUS_HOLDING, + Loan::STATUS_USED, Loan::STATUS_USED, + Loan::STATUS_DISPOSED, + ]); + } + + $stats = $query->selectRaw($selectRaw, $bindings)->first(); + + $result = [ 'total_count' => (int) $stats->total_count, 'outstanding_count' => (int) $stats->outstanding_count, 'settled_count' => (int) $stats->settled_count, @@ -115,6 +144,16 @@ public function summary(?int $userId = null): array 'total_settled' => (float) $stats->total_settled, 'total_outstanding' => (float) $stats->total_outstanding, ]; + + if ($isGiftCertificate) { + $result['holding_count'] = (int) $stats->holding_count; + $result['holding_amount'] = (float) $stats->holding_amount; + $result['used_count'] = (int) $stats->used_count; + $result['used_amount'] = (float) $stats->used_amount; + $result['disposed_count'] = (int) $stats->disposed_count; + } + + return $result; } // ========================================================================= @@ -144,13 +183,23 @@ public function store(array $data): Loan $withdrawalId = $withdrawal->id; } + // 상품권: user_id 미지정 시 현재 사용자로 대체 + $loanUserId = $data['user_id'] ?? $userId; + + // 상태 결정: 상품권은 holding, 그 외는 outstanding + $category = $data['category'] ?? null; + $status = $data['status'] + ?? ($category === Loan::CATEGORY_GIFT_CERTIFICATE ? Loan::STATUS_HOLDING : Loan::STATUS_OUTSTANDING); + return Loan::create([ 'tenant_id' => $tenantId, - 'user_id' => $data['user_id'], + 'user_id' => $loanUserId, 'loan_date' => $data['loan_date'], 'amount' => $data['amount'], 'purpose' => $data['purpose'] ?? null, - 'status' => Loan::STATUS_OUTSTANDING, + 'status' => $status, + 'category' => $category, + 'metadata' => $data['metadata'] ?? null, 'withdrawal_id' => $withdrawalId, 'created_by' => $userId, 'updated_by' => $userId, @@ -186,14 +235,29 @@ public function update(int $id, array $data): Loan } } - $loan->fill([ + $fillData = [ 'user_id' => $data['user_id'] ?? $loan->user_id, 'loan_date' => $data['loan_date'] ?? $loan->loan_date, 'amount' => $data['amount'] ?? $loan->amount, 'purpose' => $data['purpose'] ?? $loan->purpose, 'withdrawal_id' => $data['withdrawal_id'] ?? $loan->withdrawal_id, 'updated_by' => $userId, - ]); + ]; + + if (isset($data['category'])) { + $fillData['category'] = $data['category']; + } + if (array_key_exists('metadata', $data)) { + $fillData['metadata'] = $data['metadata']; + } + if (isset($data['status'])) { + $fillData['status'] = $data['status']; + } + if (array_key_exists('settlement_date', $data)) { + $fillData['settlement_date'] = $data['settlement_date']; + } + + $loan->fill($fillData); $loan->save(); diff --git a/database/migrations/2026_03_05_100000_add_v8_fields_to_bills_table.php b/database/migrations/2026_03_05_100000_add_v8_fields_to_bills_table.php new file mode 100644 index 0000000..91bdad1 --- /dev/null +++ b/database/migrations/2026_03_05_100000_add_v8_fields_to_bills_table.php @@ -0,0 +1,195 @@ +string('instrument_type', 30)->default('promissory')->after('bill_type') + ->comment('증권종류: promissory/exchange/cashierCheck/currentCheck'); + $table->string('medium', 20)->default('paper')->after('instrument_type') + ->comment('매체: electronic/paper'); + $table->string('bill_category', 30)->nullable()->after('medium') + ->comment('어음구분: commercial/other'); + + // === 전자어음 정보 === + $table->string('electronic_bill_no', 100)->nullable()->after('is_electronic') + ->comment('전자어음 관리번호'); + $table->string('registration_org', 30)->nullable()->after('electronic_bill_no') + ->comment('등록기관: kftc/bank'); + + // === 환어음 정보 === + $table->string('drawee', 100)->nullable()->after('registration_org') + ->comment('환어음 지급인 (Drawee)'); + $table->string('acceptance_status', 20)->nullable()->after('drawee') + ->comment('인수여부: accepted/pending/refused'); + $table->date('acceptance_date')->nullable()->after('acceptance_status') + ->comment('인수일자'); + $table->date('acceptance_refusal_date')->nullable()->after('acceptance_date') + ->comment('인수거절일'); + $table->string('acceptance_refusal_reason', 50)->nullable()->after('acceptance_refusal_date') + ->comment('인수거절사유'); + + // === 받을어음 전용 === + $table->string('endorsement', 30)->nullable()->after('acceptance_refusal_reason') + ->comment('배서여부: endorsable/nonEndorsable'); + $table->string('endorsement_order', 5)->nullable()->after('endorsement') + ->comment('배서차수: 1~20'); + $table->string('storage_place', 30)->nullable()->after('endorsement_order') + ->comment('보관장소: safe/bank/other'); + $table->string('issuer_bank', 100)->nullable()->after('storage_place') + ->comment('발행은행'); + + // 할인 정보 + $table->boolean('is_discounted')->default(false)->after('issuer_bank') + ->comment('할인여부'); + $table->date('discount_date')->nullable()->after('is_discounted') + ->comment('할인일자'); + $table->string('discount_bank', 100)->nullable()->after('discount_date') + ->comment('할인처 (은행)'); + $table->decimal('discount_rate', 5, 2)->nullable()->after('discount_bank') + ->comment('할인율 (%)'); + $table->decimal('discount_amount', 15, 2)->nullable()->after('discount_rate') + ->comment('할인금액'); + + // 배서양도 정보 + $table->date('endorsement_date')->nullable()->after('discount_amount') + ->comment('배서일자'); + $table->string('endorsee', 100)->nullable()->after('endorsement_date') + ->comment('피배서인 (양수인)'); + $table->string('endorsement_reason', 30)->nullable()->after('endorsee') + ->comment('배서사유: payment/guarantee/collection/other'); + + // 추심 정보 + $table->string('collection_bank', 100)->nullable()->after('endorsement_reason') + ->comment('추심은행'); + $table->date('collection_request_date')->nullable()->after('collection_bank') + ->comment('추심의뢰일'); + $table->decimal('collection_fee', 15, 2)->nullable()->after('collection_request_date') + ->comment('추심수수료'); + $table->date('collection_complete_date')->nullable()->after('collection_fee') + ->comment('추심완료일'); + $table->string('collection_result', 20)->nullable()->after('collection_complete_date') + ->comment('추심결과: success/partial/failed/pending'); + $table->date('collection_deposit_date')->nullable()->after('collection_result') + ->comment('추심입금일'); + $table->decimal('collection_deposit_amount', 15, 2)->nullable()->after('collection_deposit_date') + ->comment('추심입금액 (수수료 차감후)'); + + // === 지급어음 전용 === + $table->string('settlement_bank', 100)->nullable()->after('collection_deposit_amount') + ->comment('결제은행'); + $table->string('payment_method', 30)->nullable()->after('settlement_bank') + ->comment('결제방법: autoTransfer/currentAccount/other'); + $table->date('actual_payment_date')->nullable()->after('payment_method') + ->comment('실제결제일'); + + // === 공통 === + $table->string('payment_place', 30)->nullable()->after('actual_payment_date') + ->comment('지급장소: issuerBank/issuerBankBranch/payerAddress/designatedBank/other'); + $table->string('payment_place_detail', 200)->nullable()->after('payment_place') + ->comment('지급장소 상세 (기타 선택 시)'); + + // === 개서 정보 === + $table->date('renewal_date')->nullable()->after('payment_place_detail') + ->comment('개서일자'); + $table->string('renewal_new_bill_no', 50)->nullable()->after('renewal_date') + ->comment('신어음번호'); + $table->string('renewal_reason', 30)->nullable()->after('renewal_new_bill_no') + ->comment('개서사유: maturityExtension/amountChange/conditionChange/other'); + + // === 소구 정보 === + $table->date('recourse_date')->nullable()->after('renewal_reason') + ->comment('소구일자'); + $table->decimal('recourse_amount', 15, 2)->nullable()->after('recourse_date') + ->comment('소구금액'); + $table->string('recourse_target', 100)->nullable()->after('recourse_amount') + ->comment('소구대상 (청구인)'); + $table->string('recourse_reason', 30)->nullable()->after('recourse_target') + ->comment('소구사유: endorsedDishonor/discountDishonor/other'); + + // === 환매 정보 === + $table->date('buyback_date')->nullable()->after('recourse_reason') + ->comment('환매일자'); + $table->decimal('buyback_amount', 15, 2)->nullable()->after('buyback_date') + ->comment('환매금액'); + $table->string('buyback_bank', 100)->nullable()->after('buyback_amount') + ->comment('환매요청 은행'); + + // === 부도/법적절차 === + $table->date('dishonored_date')->nullable()->after('buyback_bank') + ->comment('부도일자'); + $table->string('dishonored_reason', 30)->nullable()->after('dishonored_date') + ->comment('부도사유'); + $table->boolean('has_protest')->default(false)->after('dishonored_reason') + ->comment('거절증서 작성 여부'); + $table->date('protest_date')->nullable()->after('has_protest') + ->comment('거절증서 작성일'); + $table->date('recourse_notice_date')->nullable()->after('protest_date') + ->comment('소구 통지일'); + $table->date('recourse_notice_deadline')->nullable()->after('recourse_notice_date') + ->comment('소구 통지 기한 (부도일+4영업일)'); + + // === 분할배서 === + $table->boolean('is_split')->default(false)->after('recourse_notice_deadline') + ->comment('분할배서 허용 여부'); + }); + + // bill_installments 에 처리구분, 상대처 추가 + Schema::table('bill_installments', function (Blueprint $table) { + $table->string('type', 30)->default('other')->after('bill_id') + ->comment('처리구분: received/endorsement/splitEndorsement/collection/...'); + $table->string('counterparty', 100)->nullable()->after('amount') + ->comment('상대처 (거래처/은행)'); + }); + } + + public function down(): void + { + Schema::table('bill_installments', function (Blueprint $table) { + $table->dropColumn(['type', 'counterparty']); + }); + + Schema::table('bills', function (Blueprint $table) { + $table->dropColumn([ + 'instrument_type', 'medium', 'bill_category', + 'electronic_bill_no', 'registration_org', + 'drawee', 'acceptance_status', 'acceptance_date', + 'acceptance_refusal_date', 'acceptance_refusal_reason', + 'endorsement', 'endorsement_order', 'storage_place', 'issuer_bank', + 'is_discounted', 'discount_date', 'discount_bank', 'discount_rate', 'discount_amount', + 'endorsement_date', 'endorsee', 'endorsement_reason', + 'collection_bank', 'collection_request_date', 'collection_fee', + 'collection_complete_date', 'collection_result', 'collection_deposit_date', 'collection_deposit_amount', + 'settlement_bank', 'payment_method', 'actual_payment_date', + 'payment_place', 'payment_place_detail', + 'renewal_date', 'renewal_new_bill_no', 'renewal_reason', + 'recourse_date', 'recourse_amount', 'recourse_target', 'recourse_reason', + 'buyback_date', 'buyback_amount', 'buyback_bank', + 'dishonored_date', 'dishonored_reason', 'has_protest', 'protest_date', + 'recourse_notice_date', 'recourse_notice_deadline', + 'is_split', + ]); + }); + } +}; diff --git a/database/migrations/2026_03_05_200000_add_metadata_to_loans_table.php b/database/migrations/2026_03_05_200000_add_metadata_to_loans_table.php new file mode 100644 index 0000000..ea8deab --- /dev/null +++ b/database/migrations/2026_03_05_200000_add_metadata_to_loans_table.php @@ -0,0 +1,28 @@ +json('metadata')->nullable()->after('category'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('loans', function (Blueprint $table) { + $table->dropColumn('metadata'); + }); + } +}; From 31d2f08dd89859c2902a71122ef5423d99c50307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Thu, 5 Mar 2026 21:22:44 +0900 Subject: [PATCH 075/166] =?UTF-8?q?feat:=20[loan]=20=EC=83=81=ED=92=88?= =?UTF-8?q?=EA=B6=8C=20=EC=A0=91=EB=8C=80=EB=B9=84=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=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 - ExpenseAccount: loan_id 필드 + SUB_TYPE_GIFT_CERTIFICATE 상수 추가 - LoanService: 상품권 used+접대비해당 시 expense_accounts 자동 upsert/삭제 - 마이그레이션: expense_accounts에 loan_id 컬럼 추가 Co-Authored-By: Claude Opus 4.6 --- app/Models/Tenants/ExpenseAccount.php | 4 ++ app/Services/LoanService.php | 56 +++++++++++++++++++ ..._add_loan_id_to_expense_accounts_table.php | 32 +++++++++++ 3 files changed, 92 insertions(+) create mode 100644 database/migrations/2026_03_05_210000_add_loan_id_to_expense_accounts_table.php diff --git a/app/Models/Tenants/ExpenseAccount.php b/app/Models/Tenants/ExpenseAccount.php index b57c677..c9fe540 100644 --- a/app/Models/Tenants/ExpenseAccount.php +++ b/app/Models/Tenants/ExpenseAccount.php @@ -34,6 +34,7 @@ class ExpenseAccount extends Model 'vendor_name', 'payment_method', 'card_no', + 'loan_id', 'created_by', 'updated_by', 'deleted_by', @@ -53,6 +54,9 @@ class ExpenseAccount extends Model public const TYPE_OFFICE = 'office'; + // 세부 유형 상수 (접대비) + public const SUB_TYPE_GIFT_CERTIFICATE = 'gift_certificate'; + // 세부 유형 상수 (복리후생) public const SUB_TYPE_MEAL = 'meal'; diff --git a/app/Services/LoanService.php b/app/Services/LoanService.php index d674e11..ac242ec 100644 --- a/app/Services/LoanService.php +++ b/app/Services/LoanService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\Tenants\ExpenseAccount; use App\Models\Tenants\Loan; use App\Models\Tenants\Withdrawal; use Illuminate\Contracts\Pagination\LengthAwarePaginator; @@ -261,9 +262,56 @@ public function update(int $id, array $data): Loan $loan->save(); + // 상품권 → 접대비 자동 연동 + if ($loan->category === Loan::CATEGORY_GIFT_CERTIFICATE) { + $this->syncGiftCertificateExpense($loan); + } + return $loan->fresh(['user:id,name,email', 'creator:id,name']); } + /** + * 상품권 → 접대비 자동 연동 + * + * 상태가 'used' + entertainment_expense='applicable' → expense_accounts에 INSERT + * 그 외 → 기존 연결된 expense_accounts 삭제 + */ + private function syncGiftCertificateExpense(Loan $loan): void + { + $metadata = $loan->metadata ?? []; + $isEntertainment = ($loan->status === Loan::STATUS_USED) + && ($metadata['entertainment_expense'] ?? '') === 'applicable'; + + if ($isEntertainment) { + // upsert: loan_id 기준으로 있으면 업데이트, 없으면 생성 + ExpenseAccount::query() + ->updateOrCreate( + [ + 'tenant_id' => $loan->tenant_id, + 'loan_id' => $loan->id, + ], + [ + 'account_type' => ExpenseAccount::TYPE_ENTERTAINMENT, + 'sub_type' => ExpenseAccount::SUB_TYPE_GIFT_CERTIFICATE, + 'expense_date' => $loan->settlement_date ?? $loan->loan_date, + 'amount' => $loan->amount, + 'description' => ($metadata['cert_name'] ?? '상품권') . ' 접대비 전환', + 'vendor_name' => $metadata['vendor_name'] ?? null, + 'vendor_id' => ! empty($metadata['vendor_id']) ? (int) $metadata['vendor_id'] : null, + 'payment_method' => ExpenseAccount::PAYMENT_CASH, + 'created_by' => $loan->updated_by ?? $loan->created_by, + 'updated_by' => $loan->updated_by ?? $loan->created_by, + ] + ); + } else { + // 접대비 해당이 아니면 연결된 레코드 삭제 + ExpenseAccount::query() + ->where('tenant_id', $loan->tenant_id) + ->where('loan_id', $loan->id) + ->delete(); + } + } + /** * 가지급금 삭제 */ @@ -280,6 +328,14 @@ public function destroy(int $id): bool throw new BadRequestHttpException(__('error.loan.not_deletable')); } + // 상품권 연결 접대비 레코드도 삭제 + if ($loan->category === Loan::CATEGORY_GIFT_CERTIFICATE) { + ExpenseAccount::query() + ->where('tenant_id', $tenantId) + ->where('loan_id', $loan->id) + ->delete(); + } + $loan->deleted_by = $userId; $loan->save(); $loan->delete(); diff --git a/database/migrations/2026_03_05_210000_add_loan_id_to_expense_accounts_table.php b/database/migrations/2026_03_05_210000_add_loan_id_to_expense_accounts_table.php new file mode 100644 index 0000000..10d4a43 --- /dev/null +++ b/database/migrations/2026_03_05_210000_add_loan_id_to_expense_accounts_table.php @@ -0,0 +1,32 @@ +unsignedBigInteger('loan_id')->nullable()->after('card_no') + ->comment('연결된 가지급금 ID (상품권→접대비 전환 시)'); + + $table->index('loan_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('expense_accounts', function (Blueprint $table) { + $table->dropIndex(['loan_id']); + $table->dropColumn('loan_id'); + }); + } +}; From 03f86f375e6c6c83375b24f83594dd92c39491bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Thu, 5 Mar 2026 21:32:36 +0900 Subject: [PATCH 076/166] =?UTF-8?q?fix:=20[loan]=20=EC=83=81=ED=92=88?= =?UTF-8?q?=EA=B6=8C=20store=20=EC=8B=9C=20=EC=A0=91=EB=8C=80=EB=B9=84=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20+=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC?= =?UTF-8?q?=20=EC=A7=91=EA=B3=84=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9/?= =?UTF-8?q?=ED=8F=90=EA=B8=B0=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - store()에서도 상품권 접대비 자동 연동 호출 - getCategoryBreakdown: used/disposed 상품권은 가지급금 집계에서 제외 Co-Authored-By: Claude Opus 4.6 --- app/Services/LoanService.php | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/app/Services/LoanService.php b/app/Services/LoanService.php index ac242ec..0db4fc3 100644 --- a/app/Services/LoanService.php +++ b/app/Services/LoanService.php @@ -192,7 +192,7 @@ public function store(array $data): Loan $status = $data['status'] ?? ($category === Loan::CATEGORY_GIFT_CERTIFICATE ? Loan::STATUS_HOLDING : Loan::STATUS_OUTSTANDING); - return Loan::create([ + $loan = Loan::create([ 'tenant_id' => $tenantId, 'user_id' => $loanUserId, 'loan_date' => $data['loan_date'], @@ -205,6 +205,13 @@ public function store(array $data): Loan 'created_by' => $userId, 'updated_by' => $userId, ]); + + // 상품권 → 접대비 자동 연동 + if ($category === Loan::CATEGORY_GIFT_CERTIFICATE) { + $this->syncGiftCertificateExpense($loan); + } + + return $loan; }); } @@ -592,9 +599,14 @@ private function getCategoryBreakdown(int $tenantId, ?string $startDate = null, ]; } - // 카테고리별 집계 (summary와 동일하게 전체 대상, 날짜 필터 적용) + // 카테고리별 집계 (날짜 필터 적용) + // 상품권 중 used/disposed는 접대비로 전환되므로 가지급금 집계에서 제외 $query = Loan::query() - ->where('tenant_id', $tenantId); + ->where('tenant_id', $tenantId) + ->whereNot(function ($q) { + $q->where('category', Loan::CATEGORY_GIFT_CERTIFICATE) + ->whereIn('status', [Loan::STATUS_USED, Loan::STATUS_DISPOSED]); + }); if ($startDate) { $query->where('loan_date', '>=', $startDate); From 652ac3d1ecc1ec41aa8bf7e267fb76fda87b1e97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Thu, 5 Mar 2026 21:39:17 +0900 Subject: [PATCH 077/166] =?UTF-8?q?fix:=20[loan]=20dashboard=20summary/?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=97=90=EC=84=9C=EB=8F=84=20used/disposed?= =?UTF-8?q?=20=EC=83=81=ED=92=88=EA=B6=8C=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dashboard summary 쿼리에 excludeUsedGiftCert 조건 적용 - 가지급금 목록 쿼리에도 동일 조건 적용 Co-Authored-By: Claude Opus 4.6 --- app/Services/LoanService.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/Services/LoanService.php b/app/Services/LoanService.php index 0db4fc3..f30c666 100644 --- a/app/Services/LoanService.php +++ b/app/Services/LoanService.php @@ -525,9 +525,18 @@ public function dashboard(?string $startDate = null, ?string $endDate = null): a return $query; }; + // 상품권 중 used/disposed 제외 조건 (접대비로 전환됨) + $excludeUsedGiftCert = function ($query) { + $query->whereNot(function ($q) { + $q->where('category', Loan::CATEGORY_GIFT_CERTIFICATE) + ->whereIn('status', [Loan::STATUS_USED, Loan::STATUS_DISPOSED]); + }); + }; + // 1. Summary 데이터 (날짜 필터 적용) $summaryQuery = Loan::query()->where('tenant_id', $tenantId); $applyDateFilter($summaryQuery); + $excludeUsedGiftCert($summaryQuery); $stats = $summaryQuery->selectRaw(' COUNT(*) as total_count, @@ -544,11 +553,12 @@ public function dashboard(?string $startDate = null, ?string $endDate = null): a // 3. 카테고리별 집계 (날짜 필터 적용) $categoryBreakdown = $this->getCategoryBreakdown($tenantId, $startDate, $endDate); - // 4. 가지급금 목록 (미정산 우선, 날짜 필터 적용) + // 4. 가지급금 목록 (미정산 우선, 날짜 필터 적용, used/disposed 상품권 제외) $loansQuery = Loan::query() ->where('tenant_id', $tenantId) ->with(['user:id,name,email', 'withdrawal']); $applyDateFilter($loansQuery); + $excludeUsedGiftCert($loansQuery); $loans = $loansQuery ->orderByRaw('CASE WHEN status = ? THEN 0 WHEN status = ? THEN 1 ELSE 2 END', [ From 7fe856f3b7910e4f7071f7524dbe6aa7f53f657f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Thu, 5 Mar 2026 21:42:00 +0900 Subject: [PATCH 078/166] =?UTF-8?q?fix:=20[loan]=20=EC=83=81=ED=92=88?= =?UTF-8?q?=EA=B6=8C=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=EB=8A=94=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=AC=B4=EA=B4=80=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95/=EC=82=AD=EC=A0=9C=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isEditable(): 상품권이면 상태와 무관하게 수정 허용 - isDeletable(): 상품권이면 상태와 무관하게 삭제 허용 Co-Authored-By: Claude Opus 4.6 --- app/Models/Tenants/Loan.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/Models/Tenants/Loan.php b/app/Models/Tenants/Loan.php index c769300..cd10527 100644 --- a/app/Models/Tenants/Loan.php +++ b/app/Models/Tenants/Loan.php @@ -219,10 +219,14 @@ public function getElapsedDaysAttribute(): int // ========================================================================= /** - * 수정 가능 여부 (미정산 상태만) + * 수정 가능 여부 (미정산 상태 또는 상품권) */ public function isEditable(): bool { + if ($this->category === self::CATEGORY_GIFT_CERTIFICATE) { + return true; + } + return in_array($this->status, [ self::STATUS_OUTSTANDING, self::STATUS_HOLDING, @@ -230,10 +234,14 @@ public function isEditable(): bool } /** - * 삭제 가능 여부 (미정산/보유 상태만) + * 삭제 가능 여부 (미정산/보유 상태 또는 상품권) */ public function isDeletable(): bool { + if ($this->category === self::CATEGORY_GIFT_CERTIFICATE) { + return true; + } + return in_array($this->status, [ self::STATUS_OUTSTANDING, self::STATUS_HOLDING, From c57e768b8775a32b882899ddd4c2d7f140a0451f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Thu, 5 Mar 2026 22:12:46 +0900 Subject: [PATCH 079/166] =?UTF-8?q?fix:=20[loan]=20=EC=83=81=ED=92=88?= =?UTF-8?q?=EA=B6=8C=20=EC=A0=91=EB=8C=80=EB=B9=84=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EC=8B=9C=20receipt=5Fno=EC=97=90=20=EC=8B=9C=EB=A6=AC=EC=96=BC?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EB=A7=A4=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- app/Services/LoanService.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Services/LoanService.php b/app/Services/LoanService.php index f30c666..f04739f 100644 --- a/app/Services/LoanService.php +++ b/app/Services/LoanService.php @@ -303,6 +303,7 @@ private function syncGiftCertificateExpense(Loan $loan): void 'expense_date' => $loan->settlement_date ?? $loan->loan_date, 'amount' => $loan->amount, 'description' => ($metadata['cert_name'] ?? '상품권') . ' 접대비 전환', + 'receipt_no' => $metadata['serial_number'] ?? null, 'vendor_name' => $metadata['vendor_name'] ?? null, 'vendor_id' => ! empty($metadata['vendor_id']) ? (int) $metadata['vendor_id'] : null, 'payment_method' => ExpenseAccount::PAYMENT_CASH, From 0f25a5d4e1afcd5f1889256e391578727d9eee44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 5 Mar 2026 23:41:21 +0900 Subject: [PATCH 080/166] =?UTF-8?q?feat:=20[approval]=20=EA=B2=BD=EB=A0=A5?= =?UTF-8?q?=EC=A6=9D=EB=AA=85=EC=84=9C=20=EC=96=91=EC=8B=9D=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...2026_03_05_230000_add_career_cert_form.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 database/migrations/2026_03_05_230000_add_career_cert_form.php diff --git a/database/migrations/2026_03_05_230000_add_career_cert_form.php b/database/migrations/2026_03_05_230000_add_career_cert_form.php new file mode 100644 index 0000000..070b2d1 --- /dev/null +++ b/database/migrations/2026_03_05_230000_add_career_cert_form.php @@ -0,0 +1,38 @@ +whereNull('deleted_at')->pluck('id'); + + foreach ($tenants as $tenantId) { + $exists = DB::table('approval_forms') + ->where('tenant_id', $tenantId) + ->where('code', 'career_cert') + ->exists(); + + if (! $exists) { + DB::table('approval_forms')->insert([ + 'tenant_id' => $tenantId, + 'name' => '경력증명서', + 'code' => 'career_cert', + 'category' => 'certificate', + 'template' => '[]', + 'body_template' => '', + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + } + + public function down(): void + { + DB::table('approval_forms')->where('code', 'career_cert')->delete(); + } +}; From 846ced3ead95bb4a12f18904f49ccd0733fd3128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 5 Mar 2026 23:57:45 +0900 Subject: [PATCH 081/166] =?UTF-8?q?feat:=20[approval]=20=EC=9C=84=EC=B4=89?= =?UTF-8?q?=EC=A6=9D=EB=AA=85=EC=84=9C=20=EC=96=91=EC=8B=9D=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...03_05_234000_add_appointment_cert_form.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 database/migrations/2026_03_05_234000_add_appointment_cert_form.php diff --git a/database/migrations/2026_03_05_234000_add_appointment_cert_form.php b/database/migrations/2026_03_05_234000_add_appointment_cert_form.php new file mode 100644 index 0000000..d2bc1ad --- /dev/null +++ b/database/migrations/2026_03_05_234000_add_appointment_cert_form.php @@ -0,0 +1,38 @@ +whereNull('deleted_at')->pluck('id'); + + foreach ($tenants as $tenantId) { + $exists = DB::table('approval_forms') + ->where('tenant_id', $tenantId) + ->where('code', 'appointment_cert') + ->exists(); + + if (! $exists) { + DB::table('approval_forms')->insert([ + 'tenant_id' => $tenantId, + 'name' => '위촉증명서', + 'code' => 'appointment_cert', + 'category' => 'certificate', + 'template' => '[]', + 'body_template' => '', + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + } + + public function down(): void + { + DB::table('approval_forms')->where('code', 'appointment_cert')->delete(); + } +}; From 96def0d71e683f4af020be068c35e992b47b6586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 6 Mar 2026 00:13:18 +0900 Subject: [PATCH 082/166] =?UTF-8?q?feat:=20[approval]=20=EC=82=AC=EC=A7=81?= =?UTF-8?q?=EC=84=9C=20=EC=96=91=EC=8B=9D=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 --- ...2026_03_06_100000_add_resignation_form.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 database/migrations/2026_03_06_100000_add_resignation_form.php diff --git a/database/migrations/2026_03_06_100000_add_resignation_form.php b/database/migrations/2026_03_06_100000_add_resignation_form.php new file mode 100644 index 0000000..680d681 --- /dev/null +++ b/database/migrations/2026_03_06_100000_add_resignation_form.php @@ -0,0 +1,38 @@ +whereNull('deleted_at')->pluck('id'); + + foreach ($tenants as $tenantId) { + $exists = DB::table('approval_forms') + ->where('tenant_id', $tenantId) + ->where('code', 'resignation') + ->exists(); + + if (! $exists) { + DB::table('approval_forms')->insert([ + 'tenant_id' => $tenantId, + 'name' => '사직서', + 'code' => 'resignation', + 'category' => 'certificate', + 'template' => '[]', + 'body_template' => '', + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + } + + public function down(): void + { + DB::table('approval_forms')->where('code', 'resignation')->delete(); + } +}; From a7973bb555406d1f1abddc7ddf881e39fc2f1648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Fri, 6 Mar 2026 10:37:57 +0900 Subject: [PATCH 083/166] =?UTF-8?q?feat:=20[loan]=20=EC=83=81=ED=92=88?= =?UTF-8?q?=EA=B6=8C=20summary=EC=97=90=20=EC=A0=91=EB=8C=80=EB=B9=84=20?= =?UTF-8?q?=ED=95=B4=EB=8B=B9=20=EC=A7=91=EA=B3=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - expense_accounts 테이블에서 접대비(상품권) 건수/금액 조회 - entertainment_count, entertainment_amount 응답 필드 추가 Co-Authored-By: Claude Opus 4.6 --- app/Services/LoanService.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/Services/LoanService.php b/app/Services/LoanService.php index f04739f..87cb509 100644 --- a/app/Services/LoanService.php +++ b/app/Services/LoanService.php @@ -152,6 +152,17 @@ public function summary(?int $userId = null, ?string $category = null): array $result['used_count'] = (int) $stats->used_count; $result['used_amount'] = (float) $stats->used_amount; $result['disposed_count'] = (int) $stats->disposed_count; + + // 접대비 해당 집계 (expense_accounts 테이블에서 조회) + $entertainmentStats = ExpenseAccount::query() + ->where('tenant_id', $tenantId) + ->where('account_type', ExpenseAccount::TYPE_ENTERTAINMENT) + ->where('sub_type', ExpenseAccount::SUB_TYPE_GIFT_CERTIFICATE) + ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as amount') + ->first(); + + $result['entertainment_count'] = (int) ($entertainmentStats->count ?? 0); + $result['entertainment_amount'] = (float) ($entertainmentStats->amount ?? 0); } return $result; From be9c1baa34b4ea472d9f28247807203f66cc4b8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Fri, 6 Mar 2026 10:59:16 +0900 Subject: [PATCH 084/166] =?UTF-8?q?fix:=20[receivables]=20=EC=83=81?= =?UTF-8?q?=EC=9C=84=20=EA=B1=B0=EB=9E=98=EC=B2=98=20=EC=A7=91=EA=B3=84?= =?UTF-8?q?=EC=97=90=EC=84=9C=20soft=20delete=20=EB=A0=88=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - orders, deposits, bills 서브쿼리에 whereNull('deleted_at') 추가 Co-Authored-By: Claude Opus 4.6 --- app/Services/ReceivablesService.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/Services/ReceivablesService.php b/app/Services/ReceivablesService.php index 9eaf56d..3ebb046 100644 --- a/app/Services/ReceivablesService.php +++ b/app/Services/ReceivablesService.php @@ -270,18 +270,21 @@ private function getTopReceivableVendors(int $tenantId, int $limit = 3): array ->select('client_id', \DB::raw('SUM(total_amount) as total')) ->where('tenant_id', $tenantId) ->whereNotNull('client_id') + ->whereNull('deleted_at') ->groupBy('client_id'); $depositsSub = \DB::table('deposits') ->select('client_id', \DB::raw('SUM(amount) as total')) ->where('tenant_id', $tenantId) ->whereNotNull('client_id') + ->whereNull('deleted_at') ->groupBy('client_id'); $billsSub = \DB::table('bills') ->select('client_id', \DB::raw('SUM(amount) as total')) ->where('tenant_id', $tenantId) ->whereNotNull('client_id') + ->whereNull('deleted_at') ->where('bill_type', 'received') ->groupBy('client_id'); From 12d172e4c33a53039336da73c2c2bac4d23d4abf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Fri, 6 Mar 2026 13:10:45 +0900 Subject: [PATCH 085/166] =?UTF-8?q?feat:=20[finance]=20=EA=B3=84=EC=A0=95?= =?UTF-8?q?=EA=B3=BC=EB=AA=A9=20=EB=B0=8F=20=EC=9D=BC=EB=B0=98=EC=A0=84?= =?UTF-8?q?=ED=91=9C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AccountCode 모델/서비스/컨트롤러 구현 - JournalEntry, JournalEntryLine 모델 구현 - GeneralJournalEntry 서비스/컨트롤러 구현 - FormRequest 검증 클래스 추가 - finance 라우트 등록 - i18n 메시지 키 추가 (message.php, error.php) Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/AccountSubjectController.php | 60 ++ .../Api/V1/GeneralJournalEntryController.php | 85 +++ .../StoreAccountSubjectRequest.php | 31 + .../StoreManualJournalRequest.php | 42 ++ .../UpdateJournalRequest.php | 38 ++ app/Models/Tenants/AccountCode.php | 49 ++ app/Models/Tenants/JournalEntry.php | 53 ++ app/Models/Tenants/JournalEntryLine.php | 45 ++ app/Services/AccountCodeService.php | 109 ++++ app/Services/GeneralJournalEntryService.php | 578 ++++++++++++++++++ lang/ko/error.php | 11 + lang/ko/message.php | 16 + routes/api/v1/finance.php | 20 + 13 files changed, 1137 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/AccountSubjectController.php create mode 100644 app/Http/Controllers/Api/V1/GeneralJournalEntryController.php create mode 100644 app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php create mode 100644 app/Http/Requests/V1/GeneralJournalEntry/StoreManualJournalRequest.php create mode 100644 app/Http/Requests/V1/GeneralJournalEntry/UpdateJournalRequest.php create mode 100644 app/Models/Tenants/AccountCode.php create mode 100644 app/Models/Tenants/JournalEntry.php create mode 100644 app/Models/Tenants/JournalEntryLine.php create mode 100644 app/Services/AccountCodeService.php create mode 100644 app/Services/GeneralJournalEntryService.php diff --git a/app/Http/Controllers/Api/V1/AccountSubjectController.php b/app/Http/Controllers/Api/V1/AccountSubjectController.php new file mode 100644 index 0000000..20af5a2 --- /dev/null +++ b/app/Http/Controllers/Api/V1/AccountSubjectController.php @@ -0,0 +1,60 @@ +only(['search', 'category']); + + $subjects = $this->service->index($params); + + return ApiResponse::success($subjects, __('message.fetched')); + } + + /** + * 계정과목 등록 + */ + public function store(StoreAccountSubjectRequest $request) + { + $subject = $this->service->store($request->validated()); + + return ApiResponse::success($subject, __('message.created'), [], 201); + } + + /** + * 계정과목 활성/비활성 토글 + */ + public function toggleStatus(int $id, Request $request) + { + $isActive = (bool) $request->input('is_active', true); + + $subject = $this->service->toggleStatus($id, $isActive); + + return ApiResponse::success($subject, __('message.toggled')); + } + + /** + * 계정과목 삭제 + */ + public function destroy(int $id) + { + $this->service->destroy($id); + + return ApiResponse::success(null, __('message.deleted')); + } +} diff --git a/app/Http/Controllers/Api/V1/GeneralJournalEntryController.php b/app/Http/Controllers/Api/V1/GeneralJournalEntryController.php new file mode 100644 index 0000000..4979da9 --- /dev/null +++ b/app/Http/Controllers/Api/V1/GeneralJournalEntryController.php @@ -0,0 +1,85 @@ +only([ + 'start_date', 'end_date', 'search', 'page', 'per_page', + ]); + + $result = $this->service->index($params); + + return ApiResponse::success($result, __('message.fetched')); + } + + /** + * 요약 통계 + */ + public function summary(Request $request) + { + $params = $request->only([ + 'start_date', 'end_date', 'search', + ]); + + $summary = $this->service->summary($params); + + return ApiResponse::success($summary, __('message.fetched')); + } + + /** + * 수기전표 등록 + */ + public function store(StoreManualJournalRequest $request) + { + $entry = $this->service->store($request->validated()); + + return ApiResponse::success($entry, __('message.created'), [], 201); + } + + /** + * 전표 상세 조회 (분개 수정 모달용) + */ + public function show(int $id) + { + $detail = $this->service->show($id); + + return ApiResponse::success($detail, __('message.fetched')); + } + + /** + * 분개 수정 + */ + public function updateJournal(int $id, UpdateJournalRequest $request) + { + $entry = $this->service->updateJournal($id, $request->validated()); + + return ApiResponse::success($entry, __('message.updated')); + } + + /** + * 분개 삭제 + */ + public function destroyJournal(int $id) + { + $this->service->destroyJournal($id); + + return ApiResponse::success(null, __('message.deleted')); + } +} diff --git a/app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php b/app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php new file mode 100644 index 0000000..74316ab --- /dev/null +++ b/app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php @@ -0,0 +1,31 @@ + ['required', 'string', 'max:10'], + 'name' => ['required', 'string', 'max:100'], + 'category' => ['nullable', 'string', 'in:asset,liability,capital,revenue,expense'], + ]; + } + + public function messages(): array + { + return [ + 'code.required' => '계정과목 코드를 입력하세요.', + 'name.required' => '계정과목명을 입력하세요.', + 'category.in' => '유효한 분류를 선택하세요.', + ]; + } +} diff --git a/app/Http/Requests/V1/GeneralJournalEntry/StoreManualJournalRequest.php b/app/Http/Requests/V1/GeneralJournalEntry/StoreManualJournalRequest.php new file mode 100644 index 0000000..4508680 --- /dev/null +++ b/app/Http/Requests/V1/GeneralJournalEntry/StoreManualJournalRequest.php @@ -0,0 +1,42 @@ + ['required', 'date'], + 'description' => ['nullable', 'string', 'max:500'], + 'rows' => ['required', 'array', 'min:2'], + 'rows.*.side' => ['required', 'in:debit,credit'], + 'rows.*.account_subject_id' => ['required', 'string', 'max:10'], + 'rows.*.vendor_id' => ['nullable', 'integer'], + 'rows.*.debit_amount' => ['required', 'integer', 'min:0'], + 'rows.*.credit_amount' => ['required', 'integer', 'min:0'], + 'rows.*.memo' => ['nullable', 'string', 'max:300'], + ]; + } + + public function messages(): array + { + return [ + 'journal_date.required' => '전표일자를 입력하세요.', + 'rows.required' => '분개 행을 입력하세요.', + 'rows.min' => '최소 2개 이상의 분개 행이 필요합니다.', + 'rows.*.side.required' => '차/대 구분을 선택하세요.', + 'rows.*.side.in' => '차/대 구분이 올바르지 않습니다.', + 'rows.*.account_subject_id.required' => '계정과목을 선택하세요.', + 'rows.*.debit_amount.required' => '차변 금액을 입력하세요.', + 'rows.*.credit_amount.required' => '대변 금액을 입력하세요.', + ]; + } +} diff --git a/app/Http/Requests/V1/GeneralJournalEntry/UpdateJournalRequest.php b/app/Http/Requests/V1/GeneralJournalEntry/UpdateJournalRequest.php new file mode 100644 index 0000000..cf3d364 --- /dev/null +++ b/app/Http/Requests/V1/GeneralJournalEntry/UpdateJournalRequest.php @@ -0,0 +1,38 @@ + ['sometimes', 'nullable', 'string', 'max:1000'], + 'rows' => ['sometimes', 'array', 'min:1'], + 'rows.*.side' => ['required_with:rows', 'in:debit,credit'], + 'rows.*.account_subject_id' => ['required_with:rows', 'string', 'max:10'], + 'rows.*.vendor_id' => ['nullable', 'integer'], + 'rows.*.debit_amount' => ['required_with:rows', 'integer', 'min:0'], + 'rows.*.credit_amount' => ['required_with:rows', 'integer', 'min:0'], + 'rows.*.memo' => ['nullable', 'string', 'max:300'], + ]; + } + + public function messages(): array + { + return [ + 'rows.*.side.required_with' => '차/대 구분을 선택하세요.', + 'rows.*.side.in' => '차/대 구분이 올바르지 않습니다.', + 'rows.*.account_subject_id.required_with' => '계정과목을 선택하세요.', + 'rows.*.debit_amount.required_with' => '차변 금액을 입력하세요.', + 'rows.*.credit_amount.required_with' => '대변 금액을 입력하세요.', + ]; + } +} diff --git a/app/Models/Tenants/AccountCode.php b/app/Models/Tenants/AccountCode.php new file mode 100644 index 0000000..7eb465a --- /dev/null +++ b/app/Models/Tenants/AccountCode.php @@ -0,0 +1,49 @@ + 'integer', + 'is_active' => 'boolean', + ]; + + // Categories + public const CATEGORY_ASSET = 'asset'; + public const CATEGORY_LIABILITY = 'liability'; + public const CATEGORY_CAPITAL = 'capital'; + public const CATEGORY_REVENUE = 'revenue'; + public const CATEGORY_EXPENSE = 'expense'; + + public const CATEGORIES = [ + self::CATEGORY_ASSET => '자산', + self::CATEGORY_LIABILITY => '부채', + self::CATEGORY_CAPITAL => '자본', + self::CATEGORY_REVENUE => '수익', + self::CATEGORY_EXPENSE => '비용', + ]; + + /** + * 활성 계정과목만 조회 + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } +} diff --git a/app/Models/Tenants/JournalEntry.php b/app/Models/Tenants/JournalEntry.php new file mode 100644 index 0000000..17cdd6f --- /dev/null +++ b/app/Models/Tenants/JournalEntry.php @@ -0,0 +1,53 @@ + 'date', + 'total_debit' => 'integer', + 'total_credit' => 'integer', + ]; + + // Status + public const STATUS_DRAFT = 'draft'; + public const STATUS_CONFIRMED = 'confirmed'; + + // Source type + public const SOURCE_MANUAL = 'manual'; + public const SOURCE_BANK_TRANSACTION = 'bank_transaction'; + + // Entry type + public const TYPE_GENERAL = 'general'; + + /** + * 분개 행 관계 + */ + public function lines(): HasMany + { + return $this->hasMany(JournalEntryLine::class)->orderBy('line_no'); + } +} diff --git a/app/Models/Tenants/JournalEntryLine.php b/app/Models/Tenants/JournalEntryLine.php new file mode 100644 index 0000000..b906306 --- /dev/null +++ b/app/Models/Tenants/JournalEntryLine.php @@ -0,0 +1,45 @@ + 'integer', + 'debit_amount' => 'integer', + 'credit_amount' => 'integer', + 'trading_partner_id' => 'integer', + ]; + + // DC Type + public const DC_DEBIT = 'debit'; + public const DC_CREDIT = 'credit'; + + /** + * 전표 관계 + */ + public function journalEntry(): BelongsTo + { + return $this->belongsTo(JournalEntry::class); + } +} diff --git a/app/Services/AccountCodeService.php b/app/Services/AccountCodeService.php new file mode 100644 index 0000000..c6342db --- /dev/null +++ b/app/Services/AccountCodeService.php @@ -0,0 +1,109 @@ +tenantId(); + + $query = AccountCode::query() + ->where('tenant_id', $tenantId); + + // 검색 (코드/이름) + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('code', 'like', "%{$search}%") + ->orWhere('name', 'like', "%{$search}%"); + }); + } + + // 분류 필터 + if (! empty($params['category'])) { + $query->where('category', $params['category']); + } + + return $query->orderBy('sort_order')->orderBy('code')->get()->toArray(); + } + + /** + * 계정과목 등록 + */ + public function store(array $data): AccountCode + { + $tenantId = $this->tenantId(); + + // 중복 코드 체크 + $exists = AccountCode::query() + ->where('tenant_id', $tenantId) + ->where('code', $data['code']) + ->exists(); + + if ($exists) { + throw new BadRequestHttpException(__('error.account_subject.duplicate_code')); + } + + $accountCode = new AccountCode; + $accountCode->tenant_id = $tenantId; + $accountCode->code = $data['code']; + $accountCode->name = $data['name']; + $accountCode->category = $data['category'] ?? null; + $accountCode->sort_order = $data['sort_order'] ?? 0; + $accountCode->is_active = true; + $accountCode->save(); + + return $accountCode; + } + + /** + * 계정과목 활성/비활성 토글 + */ + public function toggleStatus(int $id, bool $isActive): AccountCode + { + $tenantId = $this->tenantId(); + + $accountCode = AccountCode::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + $accountCode->is_active = $isActive; + $accountCode->save(); + + return $accountCode; + } + + /** + * 계정과목 삭제 (사용 중이면 차단) + */ + public function destroy(int $id): bool + { + $tenantId = $this->tenantId(); + + $accountCode = AccountCode::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // 전표에서 사용 중인지 확인 + $inUse = JournalEntryLine::query() + ->where('tenant_id', $tenantId) + ->where('account_code', $accountCode->code) + ->exists(); + + if ($inUse) { + throw new BadRequestHttpException(__('error.account_subject.in_use')); + } + + $accountCode->delete(); + + return true; + } +} diff --git a/app/Services/GeneralJournalEntryService.php b/app/Services/GeneralJournalEntryService.php new file mode 100644 index 0000000..3e2d1f9 --- /dev/null +++ b/app/Services/GeneralJournalEntryService.php @@ -0,0 +1,578 @@ +tenantId(); + + $startDate = $params['start_date'] ?? null; + $endDate = $params['end_date'] ?? null; + $search = $params['search'] ?? null; + $perPage = (int) ($params['per_page'] ?? 20); + $page = (int) ($params['page'] ?? 1); + + // 1) 입금(transfer) UNION 출금(transfer) UNION 수기전표 + $depositsQuery = DB::table('deposits') + ->leftJoin('journal_entries', function ($join) use ($tenantId) { + $join->on('journal_entries.source_key', '=', DB::raw("CONCAT('deposit_', deposits.id)")) + ->where('journal_entries.tenant_id', $tenantId) + ->where('journal_entries.source_type', JournalEntry::SOURCE_BANK_TRANSACTION) + ->whereNull('journal_entries.deleted_at'); + }) + ->where('deposits.tenant_id', $tenantId) + ->where('deposits.payment_method', 'transfer') + ->whereNull('deposits.deleted_at') + ->select([ + 'deposits.id', + 'deposits.deposit_date as date', + DB::raw("'deposit' as division"), + 'deposits.amount', + 'deposits.description', + DB::raw('COALESCE(journal_entries.description, NULL) as journal_description'), + 'deposits.amount as deposit_amount', + DB::raw('0 as withdrawal_amount'), + DB::raw('COALESCE(journal_entries.total_debit, 0) as debit_amount'), + DB::raw('COALESCE(journal_entries.total_credit, 0) as credit_amount'), + DB::raw("CASE WHEN journal_entries.id IS NOT NULL THEN 'linked' ELSE 'manual' END as source"), + 'deposits.created_at', + 'deposits.updated_at', + DB::raw('journal_entries.id as journal_entry_id'), + ]); + + $withdrawalsQuery = DB::table('withdrawals') + ->leftJoin('journal_entries', function ($join) use ($tenantId) { + $join->on('journal_entries.source_key', '=', DB::raw("CONCAT('withdrawal_', withdrawals.id)")) + ->where('journal_entries.tenant_id', $tenantId) + ->where('journal_entries.source_type', JournalEntry::SOURCE_BANK_TRANSACTION) + ->whereNull('journal_entries.deleted_at'); + }) + ->where('withdrawals.tenant_id', $tenantId) + ->where('withdrawals.payment_method', 'transfer') + ->whereNull('withdrawals.deleted_at') + ->select([ + 'withdrawals.id', + 'withdrawals.withdrawal_date as date', + DB::raw("'withdrawal' as division"), + 'withdrawals.amount', + 'withdrawals.description', + DB::raw('COALESCE(journal_entries.description, NULL) as journal_description'), + DB::raw('0 as deposit_amount'), + 'withdrawals.amount as withdrawal_amount', + DB::raw('COALESCE(journal_entries.total_debit, 0) as debit_amount'), + DB::raw('COALESCE(journal_entries.total_credit, 0) as credit_amount'), + DB::raw("CASE WHEN journal_entries.id IS NOT NULL THEN 'linked' ELSE 'manual' END as source"), + 'withdrawals.created_at', + 'withdrawals.updated_at', + DB::raw('journal_entries.id as journal_entry_id'), + ]); + + $manualQuery = DB::table('journal_entries') + ->where('journal_entries.tenant_id', $tenantId) + ->where('journal_entries.source_type', JournalEntry::SOURCE_MANUAL) + ->whereNull('journal_entries.deleted_at') + ->select([ + 'journal_entries.id', + 'journal_entries.entry_date as date', + DB::raw("'transfer' as division"), + 'journal_entries.total_debit as amount', + 'journal_entries.description', + 'journal_entries.description as journal_description', + DB::raw('0 as deposit_amount'), + DB::raw('0 as withdrawal_amount'), + 'journal_entries.total_debit as debit_amount', + 'journal_entries.total_credit as credit_amount', + DB::raw("'manual' as source"), + 'journal_entries.created_at', + 'journal_entries.updated_at', + DB::raw('journal_entries.id as journal_entry_id'), + ]); + + // 날짜 필터 + if ($startDate) { + $depositsQuery->where('deposits.deposit_date', '>=', $startDate); + $withdrawalsQuery->where('withdrawals.withdrawal_date', '>=', $startDate); + $manualQuery->where('journal_entries.entry_date', '>=', $startDate); + } + if ($endDate) { + $depositsQuery->where('deposits.deposit_date', '<=', $endDate); + $withdrawalsQuery->where('withdrawals.withdrawal_date', '<=', $endDate); + $manualQuery->where('journal_entries.entry_date', '<=', $endDate); + } + + // 검색 필터 + if ($search) { + $depositsQuery->where(function ($q) use ($search) { + $q->where('deposits.description', 'like', "%{$search}%") + ->orWhere('deposits.client_name', 'like', "%{$search}%"); + }); + $withdrawalsQuery->where(function ($q) use ($search) { + $q->where('withdrawals.description', 'like', "%{$search}%") + ->orWhere('withdrawals.client_name', 'like', "%{$search}%"); + }); + $manualQuery->where('journal_entries.description', 'like', "%{$search}%"); + } + + // UNION + $unionQuery = $depositsQuery + ->unionAll($withdrawalsQuery) + ->unionAll($manualQuery); + + // 전체 건수 + $totalCount = DB::table(DB::raw("({$unionQuery->toSql()}) as union_table")) + ->mergeBindings($unionQuery) + ->count(); + + // 날짜순 정렬 + 페이지네이션 + $items = DB::table(DB::raw("({$unionQuery->toSql()}) as union_table")) + ->mergeBindings($unionQuery) + ->orderBy('date', 'desc') + ->orderBy('created_at', 'desc') + ->offset(($page - 1) * $perPage) + ->limit($perPage) + ->get(); + + // 누적잔액 계산 (해당 기간 전체 기준) + $allForBalance = DB::table(DB::raw("({$unionQuery->toSql()}) as union_table")) + ->mergeBindings($unionQuery) + ->orderBy('date', 'asc') + ->orderBy('created_at', 'asc') + ->get(['deposit_amount', 'withdrawal_amount']); + + $runningBalance = 0; + $balanceMap = []; + foreach ($allForBalance as $idx => $row) { + $runningBalance += (int) $row->deposit_amount - (int) $row->withdrawal_amount; + $balanceMap[$idx] = $runningBalance; + } + + // 역순이므로 현재 페이지에 해당하는 잔액을 매핑 + $totalItems = count($allForBalance); + $items = $items->map(function ($item, $index) use ($balanceMap, $totalItems, $page, $perPage) { + // 역순 인덱스 → 정순 인덱스 + $reverseIdx = $totalItems - 1 - (($page - 1) * $perPage + $index); + $item->balance = $reverseIdx >= 0 ? ($balanceMap[$reverseIdx] ?? 0) : 0; + + return $item; + }); + + return [ + 'data' => $items->toArray(), + 'meta' => [ + 'current_page' => $page, + 'last_page' => (int) ceil($totalCount / $perPage), + 'per_page' => $perPage, + 'total' => $totalCount, + ], + ]; + } + + /** + * 요약 통계 + */ + public function summary(array $params): array + { + $tenantId = $this->tenantId(); + + $startDate = $params['start_date'] ?? null; + $endDate = $params['end_date'] ?? null; + $search = $params['search'] ?? null; + + // 입금 통계 + $depositQuery = DB::table('deposits') + ->where('tenant_id', $tenantId) + ->where('payment_method', 'transfer') + ->whereNull('deleted_at'); + + // 출금 통계 + $withdrawalQuery = DB::table('withdrawals') + ->where('tenant_id', $tenantId) + ->where('payment_method', 'transfer') + ->whereNull('deleted_at'); + + if ($startDate) { + $depositQuery->where('deposit_date', '>=', $startDate); + $withdrawalQuery->where('withdrawal_date', '>=', $startDate); + } + if ($endDate) { + $depositQuery->where('deposit_date', '<=', $endDate); + $withdrawalQuery->where('withdrawal_date', '<=', $endDate); + } + if ($search) { + $depositQuery->where(function ($q) use ($search) { + $q->where('description', 'like', "%{$search}%") + ->orWhere('client_name', 'like', "%{$search}%"); + }); + $withdrawalQuery->where(function ($q) use ($search) { + $q->where('description', 'like', "%{$search}%") + ->orWhere('client_name', 'like', "%{$search}%"); + }); + } + + $depositCount = (clone $depositQuery)->count(); + $depositAmount = (int) (clone $depositQuery)->sum('amount'); + $withdrawalCount = (clone $withdrawalQuery)->count(); + $withdrawalAmount = (int) (clone $withdrawalQuery)->sum('amount'); + + // 분개 완료/미완료 건수 (journal_entries가 연결된 입출금 수) + $journalCompleteCount = DB::table('journal_entries') + ->where('tenant_id', $tenantId) + ->where('source_type', JournalEntry::SOURCE_BANK_TRANSACTION) + ->whereNull('deleted_at') + ->when($startDate, fn ($q) => $q->where('entry_date', '>=', $startDate)) + ->when($endDate, fn ($q) => $q->where('entry_date', '<=', $endDate)) + ->count(); + + $totalCount = $depositCount + $withdrawalCount; + $journalIncompleteCount = max(0, $totalCount - $journalCompleteCount); + + return [ + 'total_count' => $totalCount, + 'deposit_count' => $depositCount, + 'deposit_amount' => $depositAmount, + 'withdrawal_count' => $withdrawalCount, + 'withdrawal_amount' => $withdrawalAmount, + 'journal_complete_count' => $journalCompleteCount, + 'journal_incomplete_count' => $journalIncompleteCount, + ]; + } + + /** + * 전표 상세 조회 (분개 수정 모달용) + */ + public function show(int $id): array + { + $tenantId = $this->tenantId(); + + $entry = JournalEntry::query() + ->where('tenant_id', $tenantId) + ->with('lines') + ->findOrFail($id); + + // source_type에 따라 원본 거래 정보 조회 + $sourceInfo = $this->getSourceInfo($entry); + + return [ + 'id' => $entry->id, + 'date' => $entry->entry_date->format('Y-m-d'), + 'division' => $sourceInfo['division'], + 'amount' => $sourceInfo['amount'], + 'description' => $sourceInfo['description'] ?? $entry->description, + 'bank_name' => $sourceInfo['bank_name'] ?? '', + 'account_number' => $sourceInfo['account_number'] ?? '', + 'journal_memo' => $entry->description, + 'rows' => $entry->lines->map(function ($line) { + return [ + 'id' => $line->id, + 'side' => $line->dc_type, + 'account_subject_id' => $line->account_code, + 'account_subject_name' => $line->account_name, + 'vendor_id' => $line->trading_partner_id, + 'vendor_name' => $line->trading_partner_name ?? '', + 'debit_amount' => (int) $line->debit_amount, + 'credit_amount' => (int) $line->credit_amount, + 'memo' => $line->description ?? '', + ]; + })->toArray(), + ]; + } + + /** + * 수기전표 등록 + */ + public function store(array $data): JournalEntry + { + $tenantId = $this->tenantId(); + + return DB::transaction(function () use ($data, $tenantId) { + // 차대 균형 검증 + $this->validateDebitCreditBalance($data['rows']); + + // 전표번호 생성 + $entryNo = $this->generateEntryNo($tenantId, $data['journal_date']); + + // 합계 계산 + $totalDebit = 0; + $totalCredit = 0; + foreach ($data['rows'] as $row) { + $totalDebit += (int) ($row['debit_amount'] ?? 0); + $totalCredit += (int) ($row['credit_amount'] ?? 0); + } + + // 전표 생성 + $entry = new JournalEntry; + $entry->tenant_id = $tenantId; + $entry->entry_no = $entryNo; + $entry->entry_date = $data['journal_date']; + $entry->entry_type = JournalEntry::TYPE_GENERAL; + $entry->description = $data['description'] ?? null; + $entry->total_debit = $totalDebit; + $entry->total_credit = $totalCredit; + $entry->status = JournalEntry::STATUS_CONFIRMED; + $entry->source_type = JournalEntry::SOURCE_MANUAL; + $entry->source_key = null; + $entry->save(); + + // 분개 행 생성 + $this->createLines($entry, $data['rows'], $tenantId); + + return $entry->load('lines'); + }); + } + + /** + * 분개 수정 (lines 전체 교체) + */ + public function updateJournal(int $id, array $data): JournalEntry + { + $tenantId = $this->tenantId(); + + return DB::transaction(function () use ($id, $data, $tenantId) { + $entry = JournalEntry::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // 메모 업데이트 + if (array_key_exists('journal_memo', $data)) { + $entry->description = $data['journal_memo']; + } + + // rows가 있으면 lines 교체 + if (isset($data['rows']) && ! empty($data['rows'])) { + $this->validateDebitCreditBalance($data['rows']); + + // 기존 lines 삭제 + JournalEntryLine::query() + ->where('journal_entry_id', $entry->id) + ->delete(); + + // 새 lines 생성 + $this->createLines($entry, $data['rows'], $tenantId); + + // 합계 재계산 + $totalDebit = 0; + $totalCredit = 0; + foreach ($data['rows'] as $row) { + $totalDebit += (int) ($row['debit_amount'] ?? 0); + $totalCredit += (int) ($row['credit_amount'] ?? 0); + } + + $entry->total_debit = $totalDebit; + $entry->total_credit = $totalCredit; + } + + $entry->save(); + + return $entry->load('lines'); + }); + } + + /** + * 전표 삭제 (soft delete, lines는 FK CASCADE) + */ + public function destroyJournal(int $id): bool + { + $tenantId = $this->tenantId(); + + return DB::transaction(function () use ($id, $tenantId) { + $entry = JournalEntry::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // lines 먼저 삭제 (soft delete가 아니므로 물리 삭제) + JournalEntryLine::query() + ->where('journal_entry_id', $entry->id) + ->delete(); + + $entry->delete(); // soft delete + + return true; + }); + } + + /** + * 전표번호 생성: JE-YYYYMMDD-NNN (동시성 안전) + */ + private function generateEntryNo(int $tenantId, string $date): string + { + $dateStr = str_replace('-', '', substr($date, 0, 10)); + $prefix = "JE-{$dateStr}-"; + + // SELECT ... FOR UPDATE 락으로 동시성 안전 보장 + $lastEntry = DB::table('journal_entries') + ->where('tenant_id', $tenantId) + ->where('entry_no', 'like', "{$prefix}%") + ->lockForUpdate() + ->orderBy('entry_no', 'desc') + ->first(['entry_no']); + + if ($lastEntry) { + $lastSeq = (int) substr($lastEntry->entry_no, -3); + $nextSeq = $lastSeq + 1; + } else { + $nextSeq = 1; + } + + return $prefix . str_pad($nextSeq, 3, '0', STR_PAD_LEFT); + } + + /** + * 차대 균형 검증 + */ + private function validateDebitCreditBalance(array $rows): void + { + $totalDebit = 0; + $totalCredit = 0; + + foreach ($rows as $row) { + $totalDebit += (int) ($row['debit_amount'] ?? 0); + $totalCredit += (int) ($row['credit_amount'] ?? 0); + } + + if ($totalDebit !== $totalCredit) { + throw new BadRequestHttpException(__('error.journal_entry.debit_credit_mismatch')); + } + } + + /** + * 분개 행 생성 + */ + private function createLines(JournalEntry $entry, array $rows, int $tenantId): void + { + foreach ($rows as $index => $row) { + $accountCode = $row['account_subject_id'] ?? ''; + $accountName = $this->resolveAccountName($tenantId, $accountCode); + $vendorName = $this->resolveVendorName($row['vendor_id'] ?? null); + + $line = new JournalEntryLine; + $line->tenant_id = $tenantId; + $line->journal_entry_id = $entry->id; + $line->line_no = $index + 1; + $line->dc_type = $row['side']; + $line->account_code = $accountCode; + $line->account_name = $accountName; + $line->trading_partner_id = ! empty($row['vendor_id']) ? (int) $row['vendor_id'] : null; + $line->trading_partner_name = $vendorName; + $line->debit_amount = (int) ($row['debit_amount'] ?? 0); + $line->credit_amount = (int) ($row['credit_amount'] ?? 0); + $line->description = $row['memo'] ?? null; + $line->save(); + } + } + + /** + * 계정과목 코드 → 이름 조회 + */ + private function resolveAccountName(int $tenantId, string $code): string + { + if (empty($code)) { + return ''; + } + + $account = AccountCode::query() + ->where('tenant_id', $tenantId) + ->where('code', $code) + ->first(['name']); + + return $account ? $account->name : $code; + } + + /** + * 거래처 ID → 이름 조회 + */ + private function resolveVendorName(?int $vendorId): string + { + if (! $vendorId) { + return ''; + } + + $vendor = DB::table('clients') + ->where('id', $vendorId) + ->first(['name']); + + return $vendor ? $vendor->name : ''; + } + + /** + * 원본 거래 정보 조회 (입금/출금) + */ + private function getSourceInfo(JournalEntry $entry): array + { + if ($entry->source_type === JournalEntry::SOURCE_MANUAL) { + return [ + 'division' => 'transfer', + 'amount' => $entry->total_debit, + 'description' => $entry->description, + 'bank_name' => '', + 'account_number' => '', + ]; + } + + // bank_transaction → deposit_123 / withdrawal_456 + if ($entry->source_key && str_starts_with($entry->source_key, 'deposit_')) { + $sourceId = (int) str_replace('deposit_', '', $entry->source_key); + $deposit = DB::table('deposits') + ->leftJoin('bank_accounts', 'deposits.bank_account_id', '=', 'bank_accounts.id') + ->where('deposits.id', $sourceId) + ->first([ + 'deposits.amount', + 'deposits.description', + 'bank_accounts.bank_name', + 'bank_accounts.account_number', + ]); + + if ($deposit) { + return [ + 'division' => 'deposit', + 'amount' => (int) $deposit->amount, + 'description' => $deposit->description, + 'bank_name' => $deposit->bank_name ?? '', + 'account_number' => $deposit->account_number ?? '', + ]; + } + } + + if ($entry->source_key && str_starts_with($entry->source_key, 'withdrawal_')) { + $sourceId = (int) str_replace('withdrawal_', '', $entry->source_key); + $withdrawal = DB::table('withdrawals') + ->leftJoin('bank_accounts', 'withdrawals.bank_account_id', '=', 'bank_accounts.id') + ->where('withdrawals.id', $sourceId) + ->first([ + 'withdrawals.amount', + 'withdrawals.description', + 'bank_accounts.bank_name', + 'bank_accounts.account_number', + ]); + + if ($withdrawal) { + return [ + 'division' => 'withdrawal', + 'amount' => (int) $withdrawal->amount, + 'description' => $withdrawal->description, + 'bank_name' => $withdrawal->bank_name ?? '', + 'account_number' => $withdrawal->account_number ?? '', + ]; + } + } + + return [ + 'division' => 'transfer', + 'amount' => $entry->total_debit, + 'description' => $entry->description, + 'bank_name' => '', + 'account_number' => '', + ]; + } +} diff --git a/lang/ko/error.php b/lang/ko/error.php index 54e11df..bdad200 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -481,4 +481,15 @@ 'cannot_delete' => '해당 계약은 삭제할 수 없습니다.', 'invalid_status' => '유효하지 않은 계약 상태입니다.', ], + + // 일반전표입력 + 'journal_entry' => [ + 'debit_credit_mismatch' => '차변 합계와 대변 합계가 일치해야 합니다.', + ], + + // 계정과목 + 'account_subject' => [ + 'duplicate_code' => '이미 존재하는 계정과목 코드입니다.', + 'in_use' => '전표에서 사용 중인 계정과목은 삭제할 수 없습니다.', + ], ]; diff --git a/lang/ko/message.php b/lang/ko/message.php index 086008e..5f20784 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -567,6 +567,22 @@ 'downloaded' => '문서가 다운로드되었습니다.', ], + // 일반전표입력 + 'journal_entry' => [ + 'fetched' => '전표 조회 성공', + 'created' => '전표가 등록되었습니다.', + 'updated' => '분개가 수정되었습니다.', + 'deleted' => '분개가 삭제되었습니다.', + ], + + // 계정과목 + 'account_subject' => [ + 'fetched' => '계정과목 조회 성공', + 'created' => '계정과목이 등록되었습니다.', + 'toggled' => '계정과목 상태가 변경되었습니다.', + 'deleted' => '계정과목이 삭제되었습니다.', + ], + // CEO 대시보드 부가세 현황 'vat' => [ 'sales_tax' => '매출세액', diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index 0ce30b9..64924f9 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -12,6 +12,7 @@ * - 대시보드/보고서 */ +use App\Http\Controllers\Api\V1\AccountSubjectController; use App\Http\Controllers\Api\V1\BadDebtController; use App\Http\Controllers\Api\V1\BankAccountController; use App\Http\Controllers\Api\V1\BankTransactionController; @@ -26,6 +27,7 @@ use App\Http\Controllers\Api\V1\DepositController; use App\Http\Controllers\Api\V1\EntertainmentController; use App\Http\Controllers\Api\V1\ExpectedExpenseController; +use App\Http\Controllers\Api\V1\GeneralJournalEntryController; use App\Http\Controllers\Api\V1\LoanController; use App\Http\Controllers\Api\V1\PaymentController; use App\Http\Controllers\Api\V1\PayrollController; @@ -304,6 +306,24 @@ Route::delete('/{id}/memos/{memoId}', [BadDebtController::class, 'removeMemo'])->whereNumber(['id', 'memoId'])->name('v1.bad-debts.memos.destroy'); }); +// General Journal Entry API (일반전표입력) +Route::prefix('general-journal-entries')->group(function () { + Route::get('', [GeneralJournalEntryController::class, 'index'])->name('v1.general-journal-entries.index'); + Route::get('/summary', [GeneralJournalEntryController::class, 'summary'])->name('v1.general-journal-entries.summary'); + Route::post('', [GeneralJournalEntryController::class, 'store'])->name('v1.general-journal-entries.store'); + Route::get('/{id}', [GeneralJournalEntryController::class, 'show'])->whereNumber('id')->name('v1.general-journal-entries.show'); + Route::put('/{id}/journal', [GeneralJournalEntryController::class, 'updateJournal'])->whereNumber('id')->name('v1.general-journal-entries.update-journal'); + Route::delete('/{id}/journal', [GeneralJournalEntryController::class, 'destroyJournal'])->whereNumber('id')->name('v1.general-journal-entries.destroy-journal'); +}); + +// Account Subject API (계정과목) +Route::prefix('account-subjects')->group(function () { + Route::get('', [AccountSubjectController::class, 'index'])->name('v1.account-subjects.index'); + Route::post('', [AccountSubjectController::class, 'store'])->name('v1.account-subjects.store'); + Route::patch('/{id}/status', [AccountSubjectController::class, 'toggleStatus'])->whereNumber('id')->name('v1.account-subjects.toggle-status'); + Route::delete('/{id}', [AccountSubjectController::class, 'destroy'])->whereNumber('id')->name('v1.account-subjects.destroy'); +}); + // Bill API (어음관리) Route::prefix('bills')->group(function () { Route::get('', [BillController::class, 'index'])->name('v1.bills.index'); From 816c25a6313ced3cbbd75adaa6068135253f9ce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Fri, 6 Mar 2026 13:26:58 +0900 Subject: [PATCH 086/166] =?UTF-8?q?fix:=20[finance]=20=EC=9D=BC=EB=B0=98?= =?UTF-8?q?=EC=A0=84=ED=91=9C=20=EB=AA=A9=EB=A1=9D=20source=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EB=B0=8F=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EA=B5=AC=EC=A1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deposits/withdrawals 조회 시 source를 항상 'linked'로 고정 - 페이지네이션 meta 래핑 제거하여 플랫 구조로 변경 Co-Authored-By: Claude Opus 4.6 --- app/Services/GeneralJournalEntryService.php | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/app/Services/GeneralJournalEntryService.php b/app/Services/GeneralJournalEntryService.php index 3e2d1f9..8056c0c 100644 --- a/app/Services/GeneralJournalEntryService.php +++ b/app/Services/GeneralJournalEntryService.php @@ -46,7 +46,7 @@ public function index(array $params): array DB::raw('0 as withdrawal_amount'), DB::raw('COALESCE(journal_entries.total_debit, 0) as debit_amount'), DB::raw('COALESCE(journal_entries.total_credit, 0) as credit_amount'), - DB::raw("CASE WHEN journal_entries.id IS NOT NULL THEN 'linked' ELSE 'manual' END as source"), + DB::raw("'linked' as source"), 'deposits.created_at', 'deposits.updated_at', DB::raw('journal_entries.id as journal_entry_id'), @@ -73,7 +73,7 @@ public function index(array $params): array 'withdrawals.amount as withdrawal_amount', DB::raw('COALESCE(journal_entries.total_debit, 0) as debit_amount'), DB::raw('COALESCE(journal_entries.total_credit, 0) as credit_amount'), - DB::raw("CASE WHEN journal_entries.id IS NOT NULL THEN 'linked' ELSE 'manual' END as source"), + DB::raw("'linked' as source"), 'withdrawals.created_at', 'withdrawals.updated_at', DB::raw('journal_entries.id as journal_entry_id'), @@ -170,12 +170,10 @@ public function index(array $params): array return [ 'data' => $items->toArray(), - 'meta' => [ - 'current_page' => $page, - 'last_page' => (int) ceil($totalCount / $perPage), - 'per_page' => $perPage, - 'total' => $totalCount, - ], + 'current_page' => $page, + 'last_page' => (int) ceil($totalCount / $perPage), + 'per_page' => $perPage, + 'total' => $totalCount, ]; } From a67c5d9fca5b42f0f2c29bfb6b2f81ff1ec86e21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 6 Mar 2026 14:34:20 +0900 Subject: [PATCH 087/166] =?UTF-8?q?feat:=20[menu]=20menu=5Ffavorites=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 - tenant_id, user_id, menu_id, sort_order 컬럼 - unique 제약: (tenant_id, user_id, menu_id) - FK cascade delete: users, menus --- ..._06_143037_create_menu_favorites_table.php | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 database/migrations/2026_03_06_143037_create_menu_favorites_table.php diff --git a/database/migrations/2026_03_06_143037_create_menu_favorites_table.php b/database/migrations/2026_03_06_143037_create_menu_favorites_table.php new file mode 100644 index 0000000..e87f8a4 --- /dev/null +++ b/database/migrations/2026_03_06_143037_create_menu_favorites_table.php @@ -0,0 +1,37 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedBigInteger('user_id')->comment('사용자 ID'); + $table->unsignedBigInteger('menu_id')->comment('메뉴 ID'); + $table->integer('sort_order')->default(0)->comment('표시 순서'); + $table->timestamps(); + + $table->unique(['tenant_id', 'user_id', 'menu_id']); + $table->index(['tenant_id', 'user_id', 'sort_order']); + + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + $table->foreign('menu_id')->references('id')->on('menus')->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('menu_favorites'); + } +}; From 56e71642437b2e5044ec45a9694572397a37b087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 6 Mar 2026 20:24:56 +0900 Subject: [PATCH 088/166] =?UTF-8?q?feat:=20[departments]=20options=20JSON?= =?UTF-8?q?=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 조직도 숨기기 등 확장 속성 저장용 --- ...01500_add_options_to_departments_table.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 database/migrations/2026_03_06_201500_add_options_to_departments_table.php diff --git a/database/migrations/2026_03_06_201500_add_options_to_departments_table.php b/database/migrations/2026_03_06_201500_add_options_to_departments_table.php new file mode 100644 index 0000000..7cde6bf --- /dev/null +++ b/database/migrations/2026_03_06_201500_add_options_to_departments_table.php @@ -0,0 +1,22 @@ +json('options')->nullable()->after('sort_order'); + }); + } + + public function down(): void + { + Schema::table('departments', function (Blueprint $table) { + $table->dropColumn('options'); + }); + } +}; From 58fedb0d43457e5246c98f86a67aa4c8f1adb29b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 6 Mar 2026 20:51:38 +0900 Subject: [PATCH 089/166] =?UTF-8?q?feat:=20[approvals]=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9D=B8=EA=B0=90=EA=B3=84=20=EC=96=91=EC=8B=9D=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=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 - 모든 테넌트에 seal_usage 양식 자동 등록 --- .../2026_03_06_210000_add_seal_usage_form.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 database/migrations/2026_03_06_210000_add_seal_usage_form.php diff --git a/database/migrations/2026_03_06_210000_add_seal_usage_form.php b/database/migrations/2026_03_06_210000_add_seal_usage_form.php new file mode 100644 index 0000000..dac0400 --- /dev/null +++ b/database/migrations/2026_03_06_210000_add_seal_usage_form.php @@ -0,0 +1,38 @@ +whereNull('deleted_at')->pluck('id'); + + foreach ($tenants as $tenantId) { + $exists = DB::table('approval_forms') + ->where('tenant_id', $tenantId) + ->where('code', 'seal_usage') + ->exists(); + + if (! $exists) { + DB::table('approval_forms')->insert([ + 'tenant_id' => $tenantId, + 'name' => '사용인감계', + 'code' => 'seal_usage', + 'category' => 'certificate', + 'template' => '[]', + 'body_template' => '', + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + } + + public function down(): void + { + DB::table('approval_forms')->where('code', 'seal_usage')->delete(); + } +}; From 0ea5fa5eb9a296a0db5c2d259fd8c01a50581603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 6 Mar 2026 21:39:02 +0900 Subject: [PATCH 090/166] =?UTF-8?q?feat:=20[database]=20=EA=B2=BD=EC=A1=B0?= =?UTF-8?q?=EC=82=AC=EB=B9=84=20=EA=B4=80=EB=A6=AC=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - condolence_expenses 테이블: 거래처 경조사비 관리대장 - 경조사일자, 지출일자, 거래처명, 내역, 구분(축의/부조) - 부조금(여부/지출방법/금액), 선물(여부/종류/금액), 총금액 --- ...20000_create_condolence_expenses_table.php | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 database/migrations/2026_03_06_220000_create_condolence_expenses_table.php diff --git a/database/migrations/2026_03_06_220000_create_condolence_expenses_table.php b/database/migrations/2026_03_06_220000_create_condolence_expenses_table.php new file mode 100644 index 0000000..365b8d0 --- /dev/null +++ b/database/migrations/2026_03_06_220000_create_condolence_expenses_table.php @@ -0,0 +1,41 @@ +id(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->date('event_date')->nullable()->comment('경조사일자'); + $table->date('expense_date')->nullable()->comment('지출일자'); + $table->string('partner_name', 100)->comment('거래처명/대상자'); + $table->string('description', 200)->nullable()->comment('내역'); + $table->string('category', 20)->default('congratulation')->comment('구분: congratulation(축의), condolence(부조)'); + $table->boolean('has_cash')->default(false)->comment('부조금 여부'); + $table->string('cash_method', 30)->nullable()->comment('지출방법: cash, transfer, card'); + $table->integer('cash_amount')->default(0)->comment('부조금 금액'); + $table->boolean('has_gift')->default(false)->comment('선물 여부'); + $table->string('gift_type', 50)->nullable()->comment('선물 종류'); + $table->integer('gift_amount')->default(0)->comment('선물 금액'); + $table->integer('total_amount')->default(0)->comment('총금액'); + $table->json('options')->nullable(); + $table->text('memo')->nullable()->comment('비고'); + $table->unsignedBigInteger('created_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['tenant_id', 'event_date']); + $table->index(['tenant_id', 'category']); + }); + } + + public function down(): void + { + Schema::dropIfExists('condolence_expenses'); + } +}; From 22160e5904baa34b65b7483c3b8e9a9848de2807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 6 Mar 2026 21:44:52 +0900 Subject: [PATCH 091/166] =?UTF-8?q?feat:=20[menu]=20=EA=B2=BD=EC=A1=B0?= =?UTF-8?q?=EC=82=AC=EB=B9=84=EA=B4=80=EB=A6=AC=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 각 테넌트의 부가세관리 메뉴 하위에 경조사비관리 메뉴 자동 추가 - 중복 방지 (이미 존재하면 skip) --- ...06_223000_add_condolence_expenses_menu.php | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 database/migrations/2026_03_06_223000_add_condolence_expenses_menu.php diff --git a/database/migrations/2026_03_06_223000_add_condolence_expenses_menu.php b/database/migrations/2026_03_06_223000_add_condolence_expenses_menu.php new file mode 100644 index 0000000..dc739e0 --- /dev/null +++ b/database/migrations/2026_03_06_223000_add_condolence_expenses_menu.php @@ -0,0 +1,48 @@ +where('url', '/finance/vat') + ->whereNull('deleted_at') + ->get(); + + foreach ($vatMenus as $vatMenu) { + // 이미 존재하면 skip + $exists = DB::table('menus') + ->where('tenant_id', $vatMenu->tenant_id) + ->where('url', '/finance/condolence-expenses') + ->whereNull('deleted_at') + ->exists(); + + if ($exists) { + continue; + } + + DB::table('menus')->insert([ + 'tenant_id' => $vatMenu->tenant_id, + 'parent_id' => $vatMenu->parent_id, + 'name' => '경조사비관리', + 'url' => '/finance/condolence-expenses', + 'icon' => 'gift', + 'sort_order' => $vatMenu->sort_order + 1, + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + public function down(): void + { + DB::table('menus') + ->where('url', '/finance/condolence-expenses') + ->delete(); + } +}; From eb28b577e0876fe5091e407dec2fd05fd9978023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 6 Mar 2026 22:41:31 +0900 Subject: [PATCH 092/166] =?UTF-8?q?feat:=20[approval]=20=EC=9C=84=EC=9E=84?= =?UTF-8?q?=EC=9E=A5=20=EC=96=91=EC=8B=9D=20=EB=8D=B0=EC=9D=B4=ED=84=B0=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 - 전체 테넌트에 delegation 양식 레코드 자동 삽입 --- .../2026_03_06_230000_add_delegation_form.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 database/migrations/2026_03_06_230000_add_delegation_form.php diff --git a/database/migrations/2026_03_06_230000_add_delegation_form.php b/database/migrations/2026_03_06_230000_add_delegation_form.php new file mode 100644 index 0000000..f078943 --- /dev/null +++ b/database/migrations/2026_03_06_230000_add_delegation_form.php @@ -0,0 +1,38 @@ +whereNull('deleted_at')->pluck('id'); + + foreach ($tenants as $tenantId) { + $exists = DB::table('approval_forms') + ->where('tenant_id', $tenantId) + ->where('code', 'delegation') + ->exists(); + + if (! $exists) { + DB::table('approval_forms')->insert([ + 'tenant_id' => $tenantId, + 'name' => '위임장', + 'code' => 'delegation', + 'category' => 'request', + 'template' => '[]', + 'body_template' => '', + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + } + + public function down(): void + { + DB::table('approval_forms')->where('code', 'delegation')->delete(); + } +}; From c5a0115e01f199076a968e7fc87993d1a30e103e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 6 Mar 2026 23:00:55 +0900 Subject: [PATCH 093/166] =?UTF-8?q?feat:=20[approval]=20=EC=9D=B4=EC=82=AC?= =?UTF-8?q?=ED=9A=8C=EC=9D=98=EC=82=AC=EB=A1=9D=20=EC=96=91=EC=8B=9D=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=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 --- ...26_03_06_233000_add_board_minutes_form.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 database/migrations/2026_03_06_233000_add_board_minutes_form.php diff --git a/database/migrations/2026_03_06_233000_add_board_minutes_form.php b/database/migrations/2026_03_06_233000_add_board_minutes_form.php new file mode 100644 index 0000000..3c41d56 --- /dev/null +++ b/database/migrations/2026_03_06_233000_add_board_minutes_form.php @@ -0,0 +1,38 @@ +whereNull('deleted_at')->pluck('id'); + + foreach ($tenants as $tenantId) { + $exists = DB::table('approval_forms') + ->where('tenant_id', $tenantId) + ->where('code', 'board_minutes') + ->exists(); + + if (! $exists) { + DB::table('approval_forms')->insert([ + 'tenant_id' => $tenantId, + 'name' => '이사회의사록', + 'code' => 'board_minutes', + 'category' => 'request', + 'template' => '[]', + 'body_template' => '', + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + } + + public function down(): void + { + DB::table('approval_forms')->where('code', 'board_minutes')->delete(); + } +}; From 9d4143a4dc8f794846d9d178b23c795f13938d8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 6 Mar 2026 23:21:50 +0900 Subject: [PATCH 094/166] =?UTF-8?q?feat:=20[approval]=20=EA=B2=AC=EC=A0=81?= =?UTF-8?q?=EC=84=9C=20=EC=96=91=EC=8B=9D=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 --- .../2026_03_06_235000_add_quotation_form.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 database/migrations/2026_03_06_235000_add_quotation_form.php diff --git a/database/migrations/2026_03_06_235000_add_quotation_form.php b/database/migrations/2026_03_06_235000_add_quotation_form.php new file mode 100644 index 0000000..841fa4b --- /dev/null +++ b/database/migrations/2026_03_06_235000_add_quotation_form.php @@ -0,0 +1,38 @@ +whereNull('deleted_at')->pluck('id'); + + foreach ($tenants as $tenantId) { + $exists = DB::table('approval_forms') + ->where('tenant_id', $tenantId) + ->where('code', 'quotation') + ->exists(); + + if (! $exists) { + DB::table('approval_forms')->insert([ + 'tenant_id' => $tenantId, + 'name' => '견적서', + 'code' => 'quotation', + 'category' => 'expense', + 'template' => '[]', + 'body_template' => '', + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + } + + public function down(): void + { + DB::table('approval_forms')->where('code', 'quotation')->delete(); + } +}; From 449fce1d2bd43048a98f5fa07be18819f9221805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 6 Mar 2026 23:38:56 +0900 Subject: [PATCH 095/166] =?UTF-8?q?feat:=20[approval]=20=EA=B3=B5=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=96=91=EC=8B=9D=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 --- ..._03_07_000000_add_official_letter_form.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 database/migrations/2026_03_07_000000_add_official_letter_form.php diff --git a/database/migrations/2026_03_07_000000_add_official_letter_form.php b/database/migrations/2026_03_07_000000_add_official_letter_form.php new file mode 100644 index 0000000..b2f37a8 --- /dev/null +++ b/database/migrations/2026_03_07_000000_add_official_letter_form.php @@ -0,0 +1,38 @@ +whereNull('deleted_at')->pluck('id'); + + foreach ($tenants as $tenantId) { + $exists = DB::table('approval_forms') + ->where('tenant_id', $tenantId) + ->where('code', 'official_letter') + ->exists(); + + if (! $exists) { + DB::table('approval_forms')->insert([ + 'tenant_id' => $tenantId, + 'name' => '공문서', + 'code' => 'official_letter', + 'category' => 'general', + 'template' => '[]', + 'body_template' => '', + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + } + + public function down(): void + { + DB::table('approval_forms')->where('code', 'official_letter')->delete(); + } +}; From ad93743bdca3dcf4c180dc37772c0bfa0280b216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 7 Mar 2026 00:28:59 +0900 Subject: [PATCH 096/166] =?UTF-8?q?feat:=20[approval]=20=EC=97=B0=EC=B0=A8?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=B4=89=EC=A7=84=20=ED=86=B5=EC=A7=80?= =?UTF-8?q?=EC=84=9C=201=EC=B0=A8/2=EC=B0=A8=20=EC=96=91=EC=8B=9D=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 - leave_promotion_1st: 연차사용촉진 통지서 (1차) - hr 카테고리 - leave_promotion_2nd: 연차사용촉진 통지서 (2차) - hr 카테고리 --- ...03_07_100000_add_leave_promotion_forms.php | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 database/migrations/2026_03_07_100000_add_leave_promotion_forms.php diff --git a/database/migrations/2026_03_07_100000_add_leave_promotion_forms.php b/database/migrations/2026_03_07_100000_add_leave_promotion_forms.php new file mode 100644 index 0000000..71653da --- /dev/null +++ b/database/migrations/2026_03_07_100000_add_leave_promotion_forms.php @@ -0,0 +1,60 @@ +whereNull('deleted_at')->pluck('id'); + + foreach ($tenants as $tenantId) { + // 1차 통지서 + $exists1st = DB::table('approval_forms') + ->where('tenant_id', $tenantId) + ->where('code', 'leave_promotion_1st') + ->exists(); + + if (! $exists1st) { + DB::table('approval_forms')->insert([ + 'tenant_id' => $tenantId, + 'name' => '연차사용촉진 통지서 (1차)', + 'code' => 'leave_promotion_1st', + 'category' => 'hr', + 'template' => '[]', + 'body_template' => '', + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + // 2차 통지서 + $exists2nd = DB::table('approval_forms') + ->where('tenant_id', $tenantId) + ->where('code', 'leave_promotion_2nd') + ->exists(); + + if (! $exists2nd) { + DB::table('approval_forms')->insert([ + 'tenant_id' => $tenantId, + 'name' => '연차사용촉진 통지서 (2차)', + 'code' => 'leave_promotion_2nd', + 'category' => 'hr', + 'template' => '[]', + 'body_template' => '', + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + } + + public function down(): void + { + DB::table('approval_forms')->where('code', 'leave_promotion_1st')->delete(); + DB::table('approval_forms')->where('code', 'leave_promotion_2nd')->delete(); + } +}; From 2df8ecf7650a37a2dc24137fc15315558f3d73f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 5 Mar 2026 16:41:51 +0900 Subject: [PATCH 097/166] =?UTF-8?q?feat:=20[=EC=83=9D=EC=82=B0=EC=A7=80?= =?UTF-8?q?=EC=8B=9C]=20=EC=A0=84=EC=9A=A9=20API=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=8B=A0=EA=B7=9C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductionOrderService: 목록(index), 통계(stats), 상세(show) 구현 - Order 기반 생산지시 대상 필터링 (IN_PROGRESS~SHIPPED) - workOrderProgress 가공 필드 (total/completed/inProgress) - production_ordered_at (첫 WorkOrder created_at 기반) - BOM 공정 분류 추출 (order_nodes.options.bom_result) - ProductionOrderController: FormRequest + ApiResponse 패턴 - ProductionOrderIndexRequest: search, production_status, sort, pagination 검증 - ProductionOrderApi.php: Swagger 문서 (목록/통계/상세) - production.php: GET /production-orders, /stats, /{orderId} 라우트 추가 Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/ProductionOrderController.php | 50 ++++ .../ProductionOrderIndexRequest.php | 25 ++ app/Services/ProductionOrderService.php | 231 ++++++++++++++++++ app/Swagger/v1/ProductionOrderApi.php | 186 ++++++++++++++ routes/api/v1/production.php | 8 + 5 files changed, 500 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/ProductionOrderController.php create mode 100644 app/Http/Requests/ProductionOrder/ProductionOrderIndexRequest.php create mode 100644 app/Services/ProductionOrderService.php create mode 100644 app/Swagger/v1/ProductionOrderApi.php diff --git a/app/Http/Controllers/Api/V1/ProductionOrderController.php b/app/Http/Controllers/Api/V1/ProductionOrderController.php new file mode 100644 index 0000000..ea7f794 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ProductionOrderController.php @@ -0,0 +1,50 @@ +service->index($request->validated()); + + return ApiResponse::success($result, __('message.fetched')); + } + + /** + * 생산지시 상태별 통계 + */ + public function stats(): JsonResponse + { + $stats = $this->service->stats(); + + return ApiResponse::success($stats, __('message.fetched')); + } + + /** + * 생산지시 상세 조회 + */ + public function show(int $orderId): JsonResponse + { + try { + $detail = $this->service->show($orderId); + + return ApiResponse::success($detail, __('message.fetched')); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return ApiResponse::error(__('error.order.not_found'), 404); + } + } +} diff --git a/app/Http/Requests/ProductionOrder/ProductionOrderIndexRequest.php b/app/Http/Requests/ProductionOrder/ProductionOrderIndexRequest.php new file mode 100644 index 0000000..39a848c --- /dev/null +++ b/app/Http/Requests/ProductionOrder/ProductionOrderIndexRequest.php @@ -0,0 +1,25 @@ + 'nullable|string|max:100', + 'production_status' => 'nullable|in:waiting,in_production,completed', + 'sort_by' => 'nullable|in:created_at,delivery_date,order_no', + 'sort_dir' => 'nullable|in:asc,desc', + 'page' => 'nullable|integer|min:1', + 'per_page' => 'nullable|integer|min:1|max:100', + ]; + } +} diff --git a/app/Services/ProductionOrderService.php b/app/Services/ProductionOrderService.php new file mode 100644 index 0000000..9d7cd9c --- /dev/null +++ b/app/Services/ProductionOrderService.php @@ -0,0 +1,231 @@ +tenantId(); + + $query = Order::query() + ->where('tenant_id', $tenantId) + ->whereIn('status_code', self::PRODUCTION_STATUSES) + ->with(['client', 'workOrders.process', 'workOrders.assignees.user']) + ->withCount('workOrders'); + + // 검색어 필터 + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('order_no', 'like', "%{$search}%") + ->orWhere('client_name', 'like', "%{$search}%") + ->orWhere('site_name', 'like', "%{$search}%"); + }); + } + + // 생산 상태 필터 + if (! empty($params['production_status'])) { + switch ($params['production_status']) { + case 'waiting': + $query->where('status_code', Order::STATUS_IN_PROGRESS); + break; + case 'in_production': + $query->where('status_code', Order::STATUS_IN_PRODUCTION); + break; + case 'completed': + $query->whereIn('status_code', [ + Order::STATUS_PRODUCED, + Order::STATUS_SHIPPING, + Order::STATUS_SHIPPED, + ]); + break; + } + } + + // 정렬 + $sortBy = $params['sort_by'] ?? 'created_at'; + $sortDir = $params['sort_dir'] ?? 'desc'; + $query->orderBy($sortBy, $sortDir); + + // 페이지네이션 + $perPage = $params['per_page'] ?? 20; + $result = $query->paginate($perPage); + + // 가공 필드 추가 + $result->getCollection()->transform(function (Order $order) { + $order->production_ordered_at = $order->workOrders->min('created_at'); + + $workOrders = $order->workOrders; + $order->work_order_progress = [ + 'total' => $workOrders->count(), + 'completed' => $workOrders->where('status', 'completed')->count() + + $workOrders->where('status', 'shipped')->count(), + 'in_progress' => $workOrders->where('status', 'in_progress')->count(), + ]; + + // 프론트 탭용 production_status 매핑 + $order->production_status = $this->mapProductionStatus($order->status_code); + + return $order; + }); + + return $result; + } + + /** + * 상태별 통계 + */ + public function stats(): array + { + $tenantId = $this->tenantId(); + + $waiting = Order::where('tenant_id', $tenantId) + ->where('status_code', Order::STATUS_IN_PROGRESS) + ->count(); + + $inProduction = Order::where('tenant_id', $tenantId) + ->where('status_code', Order::STATUS_IN_PRODUCTION) + ->count(); + + $completed = Order::where('tenant_id', $tenantId) + ->whereIn('status_code', [ + Order::STATUS_PRODUCED, + Order::STATUS_SHIPPING, + Order::STATUS_SHIPPED, + ]) + ->count(); + + return [ + 'total' => $waiting + $inProduction + $completed, + 'waiting' => $waiting, + 'in_production' => $inProduction, + 'completed' => $completed, + ]; + } + + /** + * 생산지시 상세 조회 + */ + public function show(int $orderId): array + { + $tenantId = $this->tenantId(); + + $order = Order::query() + ->where('tenant_id', $tenantId) + ->whereIn('status_code', self::PRODUCTION_STATUSES) + ->with([ + 'client', + 'workOrders.process', + 'workOrders.items', + 'workOrders.assignees.user', + 'nodes', + ]) + ->findOrFail($orderId); + + // 생산지시일 + $order->production_ordered_at = $order->workOrders->min('created_at'); + $order->production_status = $this->mapProductionStatus($order->status_code); + + // WorkOrder 진행 현황 + $workOrderProgress = [ + 'total' => $order->workOrders->count(), + 'completed' => $order->workOrders->where('status', 'completed')->count() + + $order->workOrders->where('status', 'shipped')->count(), + 'in_progress' => $order->workOrders->where('status', 'in_progress')->count(), + ]; + + // WorkOrder 목록 가공 + $workOrders = $order->workOrders->map(function ($wo) { + return [ + 'id' => $wo->id, + 'work_order_no' => $wo->work_order_no, + 'process_name' => $wo->process?->process_name ?? '', + 'quantity' => $wo->items->count(), + 'status' => $wo->status, + 'assignees' => $wo->assignees->map(fn ($a) => $a->user?->name ?? '')->filter()->values()->toArray(), + ]; + }); + + // BOM 데이터 (order_nodes에서 추출) + $bomProcessGroups = $this->extractBomProcessGroups($order->nodes); + + return [ + 'order' => $order->makeHidden(['workOrders', 'nodes']), + 'production_ordered_at' => $order->production_ordered_at, + 'production_status' => $order->production_status, + 'work_order_progress' => $workOrderProgress, + 'work_orders' => $workOrders, + 'bom_process_groups' => $bomProcessGroups, + ]; + } + + /** + * Order status_code → 프론트 production_status 매핑 + */ + private function mapProductionStatus(string $statusCode): string + { + return match ($statusCode) { + Order::STATUS_IN_PROGRESS => 'waiting', + Order::STATUS_IN_PRODUCTION => 'in_production', + default => 'completed', + }; + } + + /** + * order_nodes에서 BOM 공정 분류 추출 + */ + private function extractBomProcessGroups($nodes): array + { + $groups = []; + + foreach ($nodes as $node) { + $bomResult = $node->options['bom_result'] ?? null; + if (! $bomResult) { + continue; + } + + // bom_result 구조에 따라 공정별 그룹화 + foreach ($bomResult as $item) { + $processName = $item['process_name'] ?? '기타'; + + if (! isset($groups[$processName])) { + $groups[$processName] = [ + 'process_name' => $processName, + 'size_spec' => $item['size_spec'] ?? null, + 'items' => [], + ]; + } + + $groups[$processName]['items'][] = [ + 'id' => $item['id'] ?? null, + 'item_code' => $item['item_code'] ?? '', + 'item_name' => $item['item_name'] ?? '', + 'spec' => $item['spec'] ?? '', + 'lot_no' => $item['lot_no'] ?? '', + 'required_qty' => $item['required_qty'] ?? 0, + 'qty' => $item['qty'] ?? 0, + ]; + } + } + + return array_values($groups); + } +} diff --git a/app/Swagger/v1/ProductionOrderApi.php b/app/Swagger/v1/ProductionOrderApi.php new file mode 100644 index 0000000..cbd1ed6 --- /dev/null +++ b/app/Swagger/v1/ProductionOrderApi.php @@ -0,0 +1,186 @@ +whereNumber('id')->name('v1.inspections.destroy'); // 삭제 Route::patch('/{id}/complete', [InspectionController::class, 'complete'])->whereNumber('id')->name('v1.inspections.complete'); // 완료 처리 }); + +// Production Order API (생산지시 조회) +Route::prefix('production-orders')->group(function () { + Route::get('', [ProductionOrderController::class, 'index'])->name('v1.production-orders.index'); + Route::get('/stats', [ProductionOrderController::class, 'stats'])->name('v1.production-orders.stats'); + Route::get('/{orderId}', [ProductionOrderController::class, 'show'])->whereNumber('orderId')->name('v1.production-orders.show'); +}); From 59d13eeb9f64dfc3ff2657b3415332bd99a84acc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 5 Mar 2026 17:27:35 +0900 Subject: [PATCH 098/166] =?UTF-8?q?fix:=20[=EC=83=9D=EC=82=B0=EC=A7=80?= =?UTF-8?q?=EC=8B=9C]=20=EB=82=A0=EC=A7=9C=ED=8F=AC=EB=A7=B7=C2=B7?= =?UTF-8?q?=EA=B0=9C=EC=86=8C=EC=88=98=C2=B7=EC=9E=90=EC=9E=AC=ED=88=AC?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=20=EC=9E=90=EB=8F=99=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductionOrderService: production_ordered_at를 Y-m-d 포맷으로 변환 - ProductionOrderService: withCount('nodes')로 개소수(node_count) 응답 추가 - WorkOrderService: autoStartWorkOrderOnMaterialInput() 신규 메서드 - 자재투입 시 WO가 unassigned/pending/waiting이면 in_progress로 자동 전환 - syncOrderStatus()로 Order도 IN_PRODUCTION 동기화 - Swagger: node_count 필드 문서화, 날짜 포맷 수정 Co-Authored-By: Claude Opus 4.6 --- app/Services/ProductionOrderService.php | 19 ++++++++--- app/Services/WorkOrderService.php | 42 +++++++++++++++++++++++++ app/Swagger/v1/ProductionOrderApi.php | 10 +++--- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/app/Services/ProductionOrderService.php b/app/Services/ProductionOrderService.php index 9d7cd9c..65d6fc7 100644 --- a/app/Services/ProductionOrderService.php +++ b/app/Services/ProductionOrderService.php @@ -29,7 +29,7 @@ public function index(array $params): LengthAwarePaginator ->where('tenant_id', $tenantId) ->whereIn('status_code', self::PRODUCTION_STATUSES) ->with(['client', 'workOrders.process', 'workOrders.assignees.user']) - ->withCount('workOrders'); + ->withCount(['workOrders', 'nodes']); // 검색어 필터 if (! empty($params['search'])) { @@ -71,7 +71,13 @@ public function index(array $params): LengthAwarePaginator // 가공 필드 추가 $result->getCollection()->transform(function (Order $order) { - $order->production_ordered_at = $order->workOrders->min('created_at'); + $minCreatedAt = $order->workOrders->min('created_at'); + $order->production_ordered_at = $minCreatedAt + ? $minCreatedAt->format('Y-m-d') + : null; + + // 개소수 (order_nodes 수) + $order->node_count = $order->nodes_count ?? 0; $workOrders = $order->workOrders; $order->work_order_progress = [ @@ -138,10 +144,14 @@ public function show(int $orderId): array 'workOrders.assignees.user', 'nodes', ]) + ->withCount('nodes') ->findOrFail($orderId); - // 생산지시일 - $order->production_ordered_at = $order->workOrders->min('created_at'); + // 생산지시일 (날짜만) + $minCreatedAt = $order->workOrders->min('created_at'); + $order->production_ordered_at = $minCreatedAt + ? $minCreatedAt->format('Y-m-d') + : null; $order->production_status = $this->mapProductionStatus($order->status_code); // WorkOrder 진행 현황 @@ -171,6 +181,7 @@ public function show(int $orderId): array 'order' => $order->makeHidden(['workOrders', 'nodes']), 'production_ordered_at' => $order->production_ordered_at, 'production_status' => $order->production_status, + 'node_count' => $order->nodes_count ?? 0, 'work_order_progress' => $workOrderProgress, 'work_orders' => $workOrders, 'bom_process_groups' => $bomProcessGroups, diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index e35f357..806c57a 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -850,6 +850,42 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void ); } + /** + * 자재 투입 시 작업지시가 대기 상태이면 자동으로 진행중으로 전환 + * + * pending/waiting 상태에서 첫 자재 투입이 발생하면 + * 작업지시 → in_progress, 수주 → IN_PRODUCTION 으로 자동 전환 + */ + private function autoStartWorkOrderOnMaterialInput(WorkOrder $workOrder, int $tenantId): void + { + // 아직 진행 전인 상태에서만 자동 전환 (자재투입 = 실질적 작업 시작) + if (! in_array($workOrder->status, [ + WorkOrder::STATUS_UNASSIGNED, + WorkOrder::STATUS_PENDING, + WorkOrder::STATUS_WAITING, + ])) { + return; + } + + $oldStatus = $workOrder->status; + $workOrder->status = WorkOrder::STATUS_IN_PROGRESS; + $workOrder->updated_by = $this->apiUserId(); + $workOrder->save(); + + // 감사 로그 + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $workOrder->id, + 'status_auto_changed_on_material_input', + ['status' => $oldStatus], + ['status' => WorkOrder::STATUS_IN_PROGRESS] + ); + + // 연결된 수주(Order) 상태 동기화 (IN_PROGRESS → IN_PRODUCTION) + $this->syncOrderStatus($workOrder, $tenantId); + } + /** * 작업지시 품목에 결과 데이터 저장 */ @@ -1458,6 +1494,9 @@ public function registerMaterialInput(int $workOrderId, array $inputs): array $totalCount = array_sum(array_column($delegatedResults, 'material_count')); $allResults = array_merge(...array_map(fn ($r) => $r['input_results'], $delegatedResults)); + // 자재 투입 시 작업지시가 대기 상태면 자동으로 진행중으로 전환 + $this->autoStartWorkOrderOnMaterialInput($workOrder, $tenantId); + return [ 'work_order_id' => $workOrderId, 'material_count' => $totalCount, @@ -1536,6 +1575,9 @@ public function registerMaterialInput(int $workOrderId, array $inputs): array $allResults = array_merge($allResults, $dr['input_results']); } + // 자재 투입 시 작업지시가 대기 상태면 자동으로 진행중으로 전환 + $this->autoStartWorkOrderOnMaterialInput($workOrder, $tenantId); + return [ 'work_order_id' => $workOrderId, 'material_count' => count($allResults), diff --git a/app/Swagger/v1/ProductionOrderApi.php b/app/Swagger/v1/ProductionOrderApi.php index cbd1ed6..bed878d 100644 --- a/app/Swagger/v1/ProductionOrderApi.php +++ b/app/Swagger/v1/ProductionOrderApi.php @@ -14,11 +14,12 @@ * @OA\Property(property="order_no", type="string", example="ORD-20260301-0001", description="수주번호 (= 생산지시번호)"), * @OA\Property(property="site_name", type="string", example="서울현장", nullable=true, description="현장명"), * @OA\Property(property="client_name", type="string", example="(주)고객사", nullable=true, description="거래처명"), - * @OA\Property(property="quantity", type="number", example=10, description="수량"), + * @OA\Property(property="quantity", type="number", example=232, description="부품수량 합계"), + * @OA\Property(property="node_count", type="integer", example=4, description="개소수 (order_nodes 수)"), * @OA\Property(property="delivery_date", type="string", format="date", example="2026-03-15", nullable=true, description="납기일"), - * @OA\Property(property="production_ordered_at", type="string", format="date-time", nullable=true, description="생산지시일 (첫 WorkOrder 생성일)"), + * @OA\Property(property="production_ordered_at", type="string", format="date", example="2026-02-21", nullable=true, description="생산지시일 (첫 WorkOrder 생성일, Y-m-d)"), * @OA\Property(property="production_status", type="string", enum={"waiting","in_production","completed"}, example="waiting", description="생산 상태"), - * @OA\Property(property="work_orders_count", type="integer", example=3, description="작업지시 수"), + * @OA\Property(property="work_orders_count", type="integer", example=2, description="작업지시 수 (공정별 1건)"), * @OA\Property(property="work_order_progress", type="object", * @OA\Property(property="total", type="integer", example=3), * @OA\Property(property="completed", type="integer", example=1), @@ -47,8 +48,9 @@ * description="생산지시 상세", * * @OA\Property(property="order", ref="#/components/schemas/ProductionOrderListItem"), - * @OA\Property(property="production_ordered_at", type="string", format="date-time", nullable=true), + * @OA\Property(property="production_ordered_at", type="string", format="date", example="2026-02-21", nullable=true), * @OA\Property(property="production_status", type="string", enum={"waiting","in_production","completed"}), + * @OA\Property(property="node_count", type="integer", example=4, description="개소수"), * @OA\Property(property="work_order_progress", type="object", * @OA\Property(property="total", type="integer"), * @OA\Property(property="completed", type="integer"), From 38c240277176828220bf014a25e54d870e6ad288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 5 Mar 2026 19:26:52 +0900 Subject: [PATCH 099/166] =?UTF-8?q?fix:=20[=EC=83=9D=EC=82=B0=EC=A7=80?= =?UTF-8?q?=EC=8B=9C]=20=EA=B3=B5=EC=A0=95=20=EC=A7=84=ED=96=89=20?= =?UTF-8?q?=ED=98=84=ED=99=A9=20WO=20=ED=95=84=ED=84=B0=EB=A7=81=20+=20BOM?= =?UTF-8?q?=20=ED=8C=8C=EC=8B=B1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공정 진행 현황: process_id=null인 구매품/서비스 WO 제외 (withCount, 목록/상세 모두) - extractBomProcessGroups: bom_result.items[] 구조에 맞게 파싱 수정 - process_name → process_group 키 사용 - 품목 필드 매핑 수정 (item_id, specification, unit, quantity, unit_price, total_price, node_name) Co-Authored-By: Claude Opus 4.6 --- app/Services/ProductionOrderService.php | 76 ++++++++++++++++--------- 1 file changed, 49 insertions(+), 27 deletions(-) diff --git a/app/Services/ProductionOrderService.php b/app/Services/ProductionOrderService.php index 65d6fc7..349a6b3 100644 --- a/app/Services/ProductionOrderService.php +++ b/app/Services/ProductionOrderService.php @@ -29,7 +29,10 @@ public function index(array $params): LengthAwarePaginator ->where('tenant_id', $tenantId) ->whereIn('status_code', self::PRODUCTION_STATUSES) ->with(['client', 'workOrders.process', 'workOrders.assignees.user']) - ->withCount(['workOrders', 'nodes']); + ->withCount([ + 'workOrders' => fn ($q) => $q->whereNotNull('process_id'), + 'nodes', + ]); // 검색어 필터 if (! empty($params['search'])) { @@ -79,12 +82,13 @@ public function index(array $params): LengthAwarePaginator // 개소수 (order_nodes 수) $order->node_count = $order->nodes_count ?? 0; - $workOrders = $order->workOrders; + // 생산 공정이 있는 WO만 (구매품/서비스 제외) + $productionWOs = $order->workOrders->filter(fn ($wo) => ! empty($wo->process_id)); $order->work_order_progress = [ - 'total' => $workOrders->count(), - 'completed' => $workOrders->where('status', 'completed')->count() - + $workOrders->where('status', 'shipped')->count(), - 'in_progress' => $workOrders->where('status', 'in_progress')->count(), + 'total' => $productionWOs->count(), + 'completed' => $productionWOs->where('status', 'completed')->count() + + $productionWOs->where('status', 'shipped')->count(), + 'in_progress' => $productionWOs->where('status', 'in_progress')->count(), ]; // 프론트 탭용 production_status 매핑 @@ -154,16 +158,19 @@ public function show(int $orderId): array : null; $order->production_status = $this->mapProductionStatus($order->status_code); - // WorkOrder 진행 현황 + // 생산 공정이 있는 WorkOrder만 필터 (process_id가 null인 구매품/서비스 제외) + $productionWorkOrders = $order->workOrders->filter(fn ($wo) => ! empty($wo->process_id)); + + // WorkOrder 진행 현황 (생산 공정 기준) $workOrderProgress = [ - 'total' => $order->workOrders->count(), - 'completed' => $order->workOrders->where('status', 'completed')->count() - + $order->workOrders->where('status', 'shipped')->count(), - 'in_progress' => $order->workOrders->where('status', 'in_progress')->count(), + 'total' => $productionWorkOrders->count(), + 'completed' => $productionWorkOrders->where('status', 'completed')->count() + + $productionWorkOrders->where('status', 'shipped')->count(), + 'in_progress' => $productionWorkOrders->where('status', 'in_progress')->count(), ]; - // WorkOrder 목록 가공 - $workOrders = $order->workOrders->map(function ($wo) { + // WorkOrder 목록 가공 (생산 공정만) + $workOrders = $productionWorkOrders->values()->map(function ($wo) { return [ 'id' => $wo->id, 'work_order_no' => $wo->work_order_no, @@ -202,6 +209,9 @@ private function mapProductionStatus(string $statusCode): string /** * order_nodes에서 BOM 공정 분류 추출 + * + * bom_result 구조: { items: [...], success, subtotals, ... } + * 각 item: { item_id, item_code, item_name, process_group, specification, quantity, unit, ... } */ private function extractBomProcessGroups($nodes): array { @@ -209,30 +219,42 @@ private function extractBomProcessGroups($nodes): array foreach ($nodes as $node) { $bomResult = $node->options['bom_result'] ?? null; - if (! $bomResult) { + if (! $bomResult || ! is_array($bomResult)) { continue; } - // bom_result 구조에 따라 공정별 그룹화 - foreach ($bomResult as $item) { - $processName = $item['process_name'] ?? '기타'; + // bom_result.items 배열에서 추출 + $items = $bomResult['items'] ?? []; + if (! is_array($items)) { + continue; + } - if (! isset($groups[$processName])) { - $groups[$processName] = [ - 'process_name' => $processName, - 'size_spec' => $item['size_spec'] ?? null, + $nodeName = $node->name ?? ''; + + foreach ($items as $item) { + if (! is_array($item)) { + continue; + } + + $processGroup = $item['process_group'] ?? $item['category_group'] ?? '기타'; + + if (! isset($groups[$processGroup])) { + $groups[$processGroup] = [ + 'process_name' => $processGroup, 'items' => [], ]; } - $groups[$processName]['items'][] = [ - 'id' => $item['id'] ?? null, + $groups[$processGroup]['items'][] = [ + 'id' => $item['item_id'] ?? null, 'item_code' => $item['item_code'] ?? '', 'item_name' => $item['item_name'] ?? '', - 'spec' => $item['spec'] ?? '', - 'lot_no' => $item['lot_no'] ?? '', - 'required_qty' => $item['required_qty'] ?? 0, - 'qty' => $item['qty'] ?? 0, + 'spec' => $item['specification'] ?? '', + 'unit' => $item['unit'] ?? '', + 'quantity' => $item['quantity'] ?? 0, + 'unit_price' => $item['unit_price'] ?? 0, + 'total_price' => $item['total_price'] ?? 0, + 'node_name' => $nodeName, ]; } } From 0aa0a8592de2d86d6ffcea552ec55cacdf351aa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 5 Mar 2026 20:05:54 +0900 Subject: [PATCH 100/166] =?UTF-8?q?feat:=20[=EC=83=9D=EC=82=B0=EC=A7=80?= =?UTF-8?q?=EC=8B=9C]=20=EC=9E=AC=EA=B3=A0=EC=83=9D=EC=82=B0=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B0=20=EA=B3=B5=EC=A0=95=20=EC=9D=BC=EB=B0=98=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Process P-004 options에 is_auxiliary 플래그 도입 - WO 생성 시 Process의 is_auxiliary를 WO options에 자동 복사 - ProductionOrderService: 보조 공정 WO를 공정 진행 현황에서 제외 - WorkOrderService: 보조 공정 WO의 상태 변경이 수주 상태에 영향 주지 않도록 처리 - syncOrderStatus(): 보조 공정이면 스킵 - autoStartWorkOrderOnMaterialInput(): WO는 진행중 전환하되 수주 상태는 유지 Co-Authored-By: Claude Opus 4.6 --- app/Services/OrderService.php | 8 ++++-- app/Services/ProductionOrderService.php | 27 ++++++++++++++++--- app/Services/WorkOrderService.php | 35 +++++++++++++++++++++++-- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index ab63e6f..c3df77a 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -1325,9 +1325,13 @@ public function createProductionOrder(int $orderId, array $data) // 작업지시번호 생성 $workOrderNo = $this->generateWorkOrderNo($tenantId); - // 절곡 공정이면 bending_info 자동 생성 + // 공정 옵션 초기화 (보조 공정 플래그 포함) $workOrderOptions = null; if ($processId) { + $process = \App\Models\Process::find($processId); + if ($process && ! empty($process->options['is_auxiliary'])) { + $workOrderOptions = ['is_auxiliary' => true]; + } // 이 작업지시에 포함되는 노드 ID만 추출 $nodeIds = collect($items) ->pluck('order_node_id') @@ -1338,7 +1342,7 @@ public function createProductionOrder(int $orderId, array $data) $buildResult = app(BendingInfoBuilder::class)->build($order, $processId, $nodeIds ?: null); if ($buildResult) { - $workOrderOptions = ['bending_info' => $buildResult['bending_info']]; + $workOrderOptions = array_merge($workOrderOptions ?? [], ['bending_info' => $buildResult['bending_info']]); } } diff --git a/app/Services/ProductionOrderService.php b/app/Services/ProductionOrderService.php index 349a6b3..a4889de 100644 --- a/app/Services/ProductionOrderService.php +++ b/app/Services/ProductionOrderService.php @@ -82,8 +82,8 @@ public function index(array $params): LengthAwarePaginator // 개소수 (order_nodes 수) $order->node_count = $order->nodes_count ?? 0; - // 생산 공정이 있는 WO만 (구매품/서비스 제외) - $productionWOs = $order->workOrders->filter(fn ($wo) => ! empty($wo->process_id)); + // 주요 생산 공정 WO만 (구매품 + 보조 공정 제외) + $productionWOs = $this->filterMainProductionWOs($order->workOrders); $order->work_order_progress = [ 'total' => $productionWOs->count(), 'completed' => $productionWOs->where('status', 'completed')->count() @@ -158,8 +158,8 @@ public function show(int $orderId): array : null; $order->production_status = $this->mapProductionStatus($order->status_code); - // 생산 공정이 있는 WorkOrder만 필터 (process_id가 null인 구매품/서비스 제외) - $productionWorkOrders = $order->workOrders->filter(fn ($wo) => ! empty($wo->process_id)); + // 주요 생산 공정 WO만 필터 (구매품 + 보조 공정 제외) + $productionWorkOrders = $this->filterMainProductionWOs($order->workOrders); // WorkOrder 진행 현황 (생산 공정 기준) $workOrderProgress = [ @@ -261,4 +261,23 @@ private function extractBomProcessGroups($nodes): array return array_values($groups); } + + /** + * 주요 생산 공정 WO만 필터 (구매품/서비스 + 보조 공정 제외) + * + * 제외 대상: + * - process_id가 null인 WO (구매품/서비스) + * - options.is_auxiliary가 true인 WO (재고생산 등 보조 공정) + */ + private function filterMainProductionWOs($workOrders): \Illuminate\Support\Collection + { + return $workOrders->filter(function ($wo) { + if (empty($wo->process_id)) { + return false; + } + $options = is_array($wo->options) ? $wo->options : (json_decode($wo->options, true) ?? []); + + return empty($options['is_auxiliary']); + }); + } } diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index 806c57a..d18aa7d 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -259,6 +259,17 @@ public function store(array $data) $salesOrderId = $data['sales_order_id'] ?? null; unset($data['items'], $data['bending_detail']); + // 공정의 is_auxiliary 플래그를 WO options에 복사 + if (! empty($data['process_id'])) { + $process = \App\Models\Process::find($data['process_id']); + if ($process && ! empty($process->options['is_auxiliary'])) { + $opts = $data['options'] ?? []; + $opts = is_array($opts) ? $opts : (json_decode($opts, true) ?? []); + $opts['is_auxiliary'] = true; + $data['options'] = $opts; + } + } + $workOrder = WorkOrder::create($data); // process 관계 로드 (isBending 체크용) @@ -815,6 +826,11 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void return; } + // 보조 공정(재고생산 등)은 수주 상태에 영향 주지 않음 + if ($this->isAuxiliaryWorkOrder($workOrder)) { + return; + } + $order = Order::where('tenant_id', $tenantId)->find($workOrder->sales_order_id); if (! $order) { return; @@ -858,6 +874,9 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void */ private function autoStartWorkOrderOnMaterialInput(WorkOrder $workOrder, int $tenantId): void { + // 보조 공정(재고생산 등)은 WO 자체는 진행중으로 전환하되, 수주 상태는 변경하지 않음 + $isAuxiliary = $this->isAuxiliaryWorkOrder($workOrder); + // 아직 진행 전인 상태에서만 자동 전환 (자재투입 = 실질적 작업 시작) if (! in_array($workOrder->status, [ WorkOrder::STATUS_UNASSIGNED, @@ -882,8 +901,10 @@ private function autoStartWorkOrderOnMaterialInput(WorkOrder $workOrder, int $te ['status' => WorkOrder::STATUS_IN_PROGRESS] ); - // 연결된 수주(Order) 상태 동기화 (IN_PROGRESS → IN_PRODUCTION) - $this->syncOrderStatus($workOrder, $tenantId); + // 보조 공정이 아닌 경우만 수주 상태 동기화 + if (! $isAuxiliary) { + $this->syncOrderStatus($workOrder, $tenantId); + } } /** @@ -926,6 +947,16 @@ private function saveItemResults(WorkOrder $workOrder, ?array $resultData, int $ } } + /** + * 보조 공정(재고생산 등) 여부 판단 + */ + private function isAuxiliaryWorkOrder(WorkOrder $workOrder): bool + { + $options = is_array($workOrder->options) ? $workOrder->options : (json_decode($workOrder->options, true) ?? []); + + return ! empty($options['is_auxiliary']); + } + /** * LOT 번호 생성 */ From a6e29bc1f361dfdffae0c8e08261784e9793da08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 5 Mar 2026 20:29:39 +0900 Subject: [PATCH 101/166] =?UTF-8?q?feat:=20[=ED=92=88=EC=A7=88=EA=B4=80?= =?UTF-8?q?=EB=A6=AC]=20=EB=B0=B1=EC=97=94=EB=93=9C=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20-=20=ED=92=88=EC=A7=88=EA=B4=80=EB=A6=AC=EC=84=9C?= =?UTF-8?q?=20+=20=EC=8B=A4=EC=A0=81=EC=8B=A0=EA=B3=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 품질관리서(quality_documents) CRUD API 14개 엔드포인트 - 실적신고(performance_reports) 관리 API 6개 엔드포인트 - DB 마이그레이션 4개 테이블 (quality_documents, quality_document_orders, quality_document_locations, performance_reports) - 모델 4개 + 서비스 2개 + 컨트롤러 2개 + FormRequest 4개 - stats() ambiguous column 버그 수정 (JOIN 시 테이블 접두사 추가) - missing() status_code 컬럼명/값 수정 Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/PerformanceReportController.php | 59 ++ .../Api/V1/QualityDocumentController.php | 127 +++ .../PerformanceReportConfirmRequest.php | 29 + .../Quality/PerformanceReportMemoRequest.php | 30 + .../Quality/QualityDocumentStoreRequest.php | 43 + .../Quality/QualityDocumentUpdateRequest.php | 36 + app/Models/Qualitys/PerformanceReport.php | 76 ++ app/Models/Qualitys/QualityDocument.php | 131 +++ .../Qualitys/QualityDocumentLocation.php | 57 ++ app/Models/Qualitys/QualityDocumentOrder.php | 31 + app/Services/PerformanceReportService.php | 258 ++++++ app/Services/QualityDocumentService.php | 748 ++++++++++++++++++ ..._180001_create_quality_documents_table.php | 39 + ...2_create_quality_document_orders_table.php | 25 + ...reate_quality_document_locations_table.php | 31 + ...80004_create_performance_reports_table.php | 37 + lang/ko/error.php | 9 + routes/api.php | 1 + routes/api/v1/quality.php | 40 + 19 files changed, 1807 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/PerformanceReportController.php create mode 100644 app/Http/Controllers/Api/V1/QualityDocumentController.php create mode 100644 app/Http/Requests/Quality/PerformanceReportConfirmRequest.php create mode 100644 app/Http/Requests/Quality/PerformanceReportMemoRequest.php create mode 100644 app/Http/Requests/Quality/QualityDocumentStoreRequest.php create mode 100644 app/Http/Requests/Quality/QualityDocumentUpdateRequest.php create mode 100644 app/Models/Qualitys/PerformanceReport.php create mode 100644 app/Models/Qualitys/QualityDocument.php create mode 100644 app/Models/Qualitys/QualityDocumentLocation.php create mode 100644 app/Models/Qualitys/QualityDocumentOrder.php create mode 100644 app/Services/PerformanceReportService.php create mode 100644 app/Services/QualityDocumentService.php create mode 100644 database/migrations/2026_03_05_180001_create_quality_documents_table.php create mode 100644 database/migrations/2026_03_05_180002_create_quality_document_orders_table.php create mode 100644 database/migrations/2026_03_05_180003_create_quality_document_locations_table.php create mode 100644 database/migrations/2026_03_05_180004_create_performance_reports_table.php create mode 100644 routes/api/v1/quality.php diff --git a/app/Http/Controllers/Api/V1/PerformanceReportController.php b/app/Http/Controllers/Api/V1/PerformanceReportController.php new file mode 100644 index 0000000..75d40ea --- /dev/null +++ b/app/Http/Controllers/Api/V1/PerformanceReportController.php @@ -0,0 +1,59 @@ +service->index($request->all()); + }, __('message.fetched')); + } + + public function stats(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->stats($request->all()); + }, __('message.fetched')); + } + + public function confirm(PerformanceReportConfirmRequest $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->confirm($request->validated()['ids']); + }, __('message.updated')); + } + + public function unconfirm(PerformanceReportConfirmRequest $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->unconfirm($request->validated()['ids']); + }, __('message.updated')); + } + + public function updateMemo(PerformanceReportMemoRequest $request) + { + return ApiResponse::handle(function () use ($request) { + $data = $request->validated(); + + return $this->service->updateMemo($data['ids'], $data['memo']); + }, __('message.updated')); + } + + public function missing(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->missing($request->all()); + }, __('message.fetched')); + } +} diff --git a/app/Http/Controllers/Api/V1/QualityDocumentController.php b/app/Http/Controllers/Api/V1/QualityDocumentController.php new file mode 100644 index 0000000..82cb42f --- /dev/null +++ b/app/Http/Controllers/Api/V1/QualityDocumentController.php @@ -0,0 +1,127 @@ +service->index($request->all()); + }, __('message.fetched')); + } + + public function stats(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->stats($request->all()); + }, __('message.fetched')); + } + + public function calendar(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->calendar($request->all()); + }, __('message.fetched')); + } + + public function availableOrders(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->availableOrders($request->all()); + }, __('message.fetched')); + } + + public function show(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.fetched')); + } + + public function store(QualityDocumentStoreRequest $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->store($request->validated()); + }, __('message.created')); + } + + public function update(QualityDocumentUpdateRequest $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->update($id, $request->validated()); + }, __('message.updated')); + } + + public function destroy(int $id) + { + return ApiResponse::handle(function () use ($id) { + $this->service->destroy($id); + + return 'success'; + }, __('message.deleted')); + } + + public function complete(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->complete($id); + }, __('message.updated')); + } + + public function attachOrders(Request $request, int $id) + { + $request->validate([ + 'order_ids' => ['required', 'array', 'min:1'], + 'order_ids.*' => ['required', 'integer'], + ]); + + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->attachOrders($id, $request->input('order_ids')); + }, __('message.updated')); + } + + public function detachOrder(int $id, int $orderId) + { + return ApiResponse::handle(function () use ($id, $orderId) { + return $this->service->detachOrder($id, $orderId); + }, __('message.updated')); + } + + public function inspectLocation(Request $request, int $id, int $locId) + { + $request->validate([ + 'post_width' => ['nullable', 'integer'], + 'post_height' => ['nullable', 'integer'], + 'change_reason' => ['nullable', 'string', 'max:500'], + 'inspection_status' => ['nullable', 'string', 'in:pending,completed'], + ]); + + return ApiResponse::handle(function () use ($request, $id, $locId) { + return $this->service->inspectLocation($id, $locId, $request->all()); + }, __('message.updated')); + } + + public function requestDocument(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->requestDocument($id); + }, __('message.fetched')); + } + + public function resultDocument(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->resultDocument($id); + }, __('message.fetched')); + } +} diff --git a/app/Http/Requests/Quality/PerformanceReportConfirmRequest.php b/app/Http/Requests/Quality/PerformanceReportConfirmRequest.php new file mode 100644 index 0000000..de0b74b --- /dev/null +++ b/app/Http/Requests/Quality/PerformanceReportConfirmRequest.php @@ -0,0 +1,29 @@ + ['required', 'array', 'min:1'], + 'ids.*' => ['required', 'integer', 'exists:performance_reports,id'], + ]; + } + + public function messages(): array + { + return [ + 'ids.required' => __('validation.required', ['attribute' => '실적신고 ID']), + 'ids.min' => __('validation.min.array', ['attribute' => '실적신고 ID', 'min' => 1]), + ]; + } +} diff --git a/app/Http/Requests/Quality/PerformanceReportMemoRequest.php b/app/Http/Requests/Quality/PerformanceReportMemoRequest.php new file mode 100644 index 0000000..76fab6d --- /dev/null +++ b/app/Http/Requests/Quality/PerformanceReportMemoRequest.php @@ -0,0 +1,30 @@ + ['required', 'array', 'min:1'], + 'ids.*' => ['required', 'integer', 'exists:performance_reports,id'], + 'memo' => ['required', 'string', 'max:1000'], + ]; + } + + public function messages(): array + { + return [ + 'ids.required' => __('validation.required', ['attribute' => '실적신고 ID']), + 'memo.required' => __('validation.required', ['attribute' => '메모']), + ]; + } +} diff --git a/app/Http/Requests/Quality/QualityDocumentStoreRequest.php b/app/Http/Requests/Quality/QualityDocumentStoreRequest.php new file mode 100644 index 0000000..ae450f4 --- /dev/null +++ b/app/Http/Requests/Quality/QualityDocumentStoreRequest.php @@ -0,0 +1,43 @@ + ['required', 'string', 'max:200'], + 'client_id' => ['nullable', 'integer', 'exists:clients,id'], + 'inspector_id' => ['nullable', 'integer', 'exists:users,id'], + 'received_date' => ['nullable', 'date'], + 'options' => ['nullable', 'array'], + 'options.manager' => ['nullable', 'array'], + 'options.manager.name' => ['nullable', 'string', 'max:50'], + 'options.manager.phone' => ['nullable', 'string', 'max:30'], + 'options.inspection' => ['nullable', 'array'], + 'options.inspection.request_date' => ['nullable', 'date'], + 'options.inspection.start_date' => ['nullable', 'date'], + 'options.inspection.end_date' => ['nullable', 'date'], + 'options.site_address' => ['nullable', 'array'], + 'options.construction_site' => ['nullable', 'array'], + 'options.material_distributor' => ['nullable', 'array'], + 'options.contractor' => ['nullable', 'array'], + 'options.supervisor' => ['nullable', 'array'], + ]; + } + + public function messages(): array + { + return [ + 'site_name.required' => __('validation.required', ['attribute' => '현장명']), + ]; + } +} diff --git a/app/Http/Requests/Quality/QualityDocumentUpdateRequest.php b/app/Http/Requests/Quality/QualityDocumentUpdateRequest.php new file mode 100644 index 0000000..58c7a93 --- /dev/null +++ b/app/Http/Requests/Quality/QualityDocumentUpdateRequest.php @@ -0,0 +1,36 @@ + ['sometimes', 'string', 'max:200'], + 'client_id' => ['nullable', 'integer', 'exists:clients,id'], + 'inspector_id' => ['nullable', 'integer', 'exists:users,id'], + 'received_date' => ['nullable', 'date'], + 'options' => ['nullable', 'array'], + 'options.manager' => ['nullable', 'array'], + 'options.manager.name' => ['nullable', 'string', 'max:50'], + 'options.manager.phone' => ['nullable', 'string', 'max:30'], + 'options.inspection' => ['nullable', 'array'], + 'options.inspection.request_date' => ['nullable', 'date'], + 'options.inspection.start_date' => ['nullable', 'date'], + 'options.inspection.end_date' => ['nullable', 'date'], + 'options.site_address' => ['nullable', 'array'], + 'options.construction_site' => ['nullable', 'array'], + 'options.material_distributor' => ['nullable', 'array'], + 'options.contractor' => ['nullable', 'array'], + 'options.supervisor' => ['nullable', 'array'], + ]; + } +} diff --git a/app/Models/Qualitys/PerformanceReport.php b/app/Models/Qualitys/PerformanceReport.php new file mode 100644 index 0000000..3c03cc9 --- /dev/null +++ b/app/Models/Qualitys/PerformanceReport.php @@ -0,0 +1,76 @@ + 'date', + 'year' => 'integer', + 'quarter' => 'integer', + ]; + + // ===== Relationships ===== + + public function qualityDocument() + { + return $this->belongsTo(QualityDocument::class); + } + + public function confirmer() + { + return $this->belongsTo(User::class, 'confirmed_by'); + } + + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } + + // ===== Status Helpers ===== + + public function isUnconfirmed(): bool + { + return $this->confirmation_status === self::STATUS_UNCONFIRMED; + } + + public function isConfirmed(): bool + { + return $this->confirmation_status === self::STATUS_CONFIRMED; + } + + public function isReported(): bool + { + return $this->confirmation_status === self::STATUS_REPORTED; + } +} diff --git a/app/Models/Qualitys/QualityDocument.php b/app/Models/Qualitys/QualityDocument.php new file mode 100644 index 0000000..bcbc104 --- /dev/null +++ b/app/Models/Qualitys/QualityDocument.php @@ -0,0 +1,131 @@ + 'array', + 'received_date' => 'date', + ]; + + // ===== Relationships ===== + + public function client() + { + return $this->belongsTo(Client::class, 'client_id'); + } + + public function inspector() + { + return $this->belongsTo(User::class, 'inspector_id'); + } + + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function documentOrders() + { + return $this->hasMany(QualityDocumentOrder::class); + } + + public function locations() + { + return $this->hasMany(QualityDocumentLocation::class); + } + + public function performanceReport() + { + return $this->hasOne(PerformanceReport::class); + } + + // ===== 채번 ===== + + public static function generateDocNumber(int $tenantId): string + { + $prefix = 'KD-QD'; + $yearMonth = now()->format('Ym'); + + $lastNo = static::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('quality_doc_number', 'like', "{$prefix}-{$yearMonth}-%") + ->orderByDesc('quality_doc_number') + ->value('quality_doc_number'); + + if ($lastNo) { + $seq = (int) substr($lastNo, -4) + 1; + } else { + $seq = 1; + } + + return sprintf('%s-%s-%04d', $prefix, $yearMonth, $seq); + } + + // ===== Status Helpers ===== + + public function isReceived(): bool + { + return $this->status === self::STATUS_RECEIVED; + } + + public function isInProgress(): bool + { + return $this->status === self::STATUS_IN_PROGRESS; + } + + public function isCompleted(): bool + { + return $this->status === self::STATUS_COMPLETED; + } + + public static function mapStatusToFrontend(string $status): string + { + return match ($status) { + self::STATUS_RECEIVED => 'reception', + self::STATUS_IN_PROGRESS => 'in_progress', + self::STATUS_COMPLETED => 'completed', + default => $status, + }; + } + + public static function mapStatusFromFrontend(string $status): string + { + return match ($status) { + 'reception' => self::STATUS_RECEIVED, + default => $status, + }; + } +} diff --git a/app/Models/Qualitys/QualityDocumentLocation.php b/app/Models/Qualitys/QualityDocumentLocation.php new file mode 100644 index 0000000..1f7cecd --- /dev/null +++ b/app/Models/Qualitys/QualityDocumentLocation.php @@ -0,0 +1,57 @@ +belongsTo(QualityDocument::class); + } + + public function qualityDocumentOrder() + { + return $this->belongsTo(QualityDocumentOrder::class); + } + + public function orderItem() + { + return $this->belongsTo(OrderItem::class); + } + + public function document() + { + return $this->belongsTo(Document::class); + } + + public function isPending(): bool + { + return $this->inspection_status === self::STATUS_PENDING; + } + + public function isCompleted(): bool + { + return $this->inspection_status === self::STATUS_COMPLETED; + } +} diff --git a/app/Models/Qualitys/QualityDocumentOrder.php b/app/Models/Qualitys/QualityDocumentOrder.php new file mode 100644 index 0000000..4d804f7 --- /dev/null +++ b/app/Models/Qualitys/QualityDocumentOrder.php @@ -0,0 +1,31 @@ +belongsTo(QualityDocument::class); + } + + public function order() + { + return $this->belongsTo(Order::class); + } + + public function locations() + { + return $this->hasMany(QualityDocumentLocation::class); + } +} diff --git a/app/Services/PerformanceReportService.php b/app/Services/PerformanceReportService.php new file mode 100644 index 0000000..80b343b --- /dev/null +++ b/app/Services/PerformanceReportService.php @@ -0,0 +1,258 @@ +tenantId(); + $perPage = (int) ($params['per_page'] ?? 20); + $q = trim((string) ($params['q'] ?? '')); + $year = $params['year'] ?? null; + $quarter = $params['quarter'] ?? null; + $confirmStatus = $params['confirm_status'] ?? null; + + $query = PerformanceReport::query() + ->where('performance_reports.tenant_id', $tenantId) + ->with(['qualityDocument.client', 'qualityDocument.locations', 'confirmer:id,name']); + + if ($q !== '') { + $query->whereHas('qualityDocument', function ($qq) use ($q) { + $qq->where('quality_doc_number', 'like', "%{$q}%") + ->orWhere('site_name', 'like', "%{$q}%"); + }); + } + + if ($year !== null) { + $query->where('year', $year); + } + if ($quarter !== null) { + $query->where('quarter', $quarter); + } + if ($confirmStatus !== null) { + $query->where('confirmation_status', $confirmStatus); + } + + $query->orderByDesc('performance_reports.id'); + $paginated = $query->paginate($perPage); + + $transformedData = $paginated->getCollection()->map(fn ($report) => $this->transformToFrontend($report)); + + return [ + 'items' => $transformedData, + 'current_page' => $paginated->currentPage(), + 'last_page' => $paginated->lastPage(), + 'per_page' => $paginated->perPage(), + 'total' => $paginated->total(), + ]; + } + + /** + * 통계 조회 + */ + public function stats(array $params = []): array + { + $tenantId = $this->tenantId(); + + $query = PerformanceReport::where('performance_reports.tenant_id', $tenantId); + + if (! empty($params['year'])) { + $query->where('performance_reports.year', $params['year']); + } + if (! empty($params['quarter'])) { + $query->where('performance_reports.quarter', $params['quarter']); + } + + $counts = (clone $query) + ->select('confirmation_status', DB::raw('count(*) as count')) + ->groupBy('confirmation_status') + ->pluck('count', 'confirmation_status') + ->toArray(); + + $totalLocations = (clone $query) + ->join('quality_documents', 'quality_documents.id', '=', 'performance_reports.quality_document_id') + ->join('quality_document_locations', 'quality_document_locations.quality_document_id', '=', 'quality_documents.id') + ->count('quality_document_locations.id'); + + return [ + 'total_count' => array_sum($counts), + 'confirmed_count' => $counts[PerformanceReport::STATUS_CONFIRMED] ?? 0, + 'unconfirmed_count' => $counts[PerformanceReport::STATUS_UNCONFIRMED] ?? 0, + 'total_locations' => $totalLocations, + ]; + } + + /** + * 일괄 확정 + */ + public function confirm(array $ids) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($ids, $tenantId, $userId) { + $reports = PerformanceReport::where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->with(['qualityDocument']) + ->get(); + + $errors = []; + foreach ($reports as $report) { + if ($report->isConfirmed() || $report->isReported()) { + continue; + } + + // 필수정보 검증 + $requiredInfo = $this->qualityDocumentService->calculateRequiredInfo($report->qualityDocument); + if ($requiredInfo !== '완료') { + $errors[] = [ + 'id' => $report->id, + 'quality_doc_number' => $report->qualityDocument->quality_doc_number, + 'reason' => $requiredInfo, + ]; + + continue; + } + + $report->update([ + 'confirmation_status' => PerformanceReport::STATUS_CONFIRMED, + 'confirmed_date' => now()->toDateString(), + 'confirmed_by' => $userId, + 'updated_by' => $userId, + ]); + } + + if (! empty($errors)) { + throw new BadRequestHttpException(json_encode([ + 'message' => __('error.quality.confirm_failed'), + 'errors' => $errors, + ])); + } + + return ['confirmed_count' => count($ids) - count($errors)]; + }); + } + + /** + * 일괄 확정 해제 + */ + public function unconfirm(array $ids) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($ids, $tenantId, $userId) { + PerformanceReport::where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->where('confirmation_status', PerformanceReport::STATUS_CONFIRMED) + ->update([ + 'confirmation_status' => PerformanceReport::STATUS_UNCONFIRMED, + 'confirmed_date' => null, + 'confirmed_by' => null, + 'updated_by' => $userId, + ]); + + return ['unconfirmed_count' => count($ids)]; + }); + } + + /** + * 일괄 메모 업데이트 + */ + public function updateMemo(array $ids, string $memo) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + PerformanceReport::where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->update([ + 'memo' => $memo, + 'updated_by' => $userId, + ]); + + return ['updated_count' => count($ids)]; + } + + /** + * 누락체크 (출고완료 but 제품검사 미등록) + */ + public function missing(array $params): array + { + $tenantId = $this->tenantId(); + + // 품질관리서가 등록된 수주 ID + $registeredOrderIds = DB::table('quality_document_orders') + ->join('quality_documents', 'quality_documents.id', '=', 'quality_document_orders.quality_document_id') + ->where('quality_documents.tenant_id', $tenantId) + ->pluck('quality_document_orders.order_id'); + + // 출고완료 상태이지만 품질관리서 미등록 수주 + $query = DB::table('orders') + ->where('tenant_id', $tenantId) + ->whereNotIn('id', $registeredOrderIds) + ->where('status_code', 'SHIPPED'); // TODO: 출고완료 상태 추가 시 상수 확인 + + if (! empty($params['year'])) { + $query->whereYear('created_at', $params['year']); + } + if (! empty($params['quarter'])) { + $quarter = (int) $params['quarter']; + $startMonth = ($quarter - 1) * 3 + 1; + $endMonth = $quarter * 3; + $query->whereMonth('created_at', '>=', $startMonth) + ->whereMonth('created_at', '<=', $endMonth); + } + + return $query->orderByDesc('id') + ->limit(100) + ->get() + ->map(fn ($order) => [ + 'id' => $order->id, + 'order_number' => $order->order_no ?? '', + 'site_name' => $order->site_name ?? '', + 'client' => '', // 별도 조인 필요 + 'delivery_date' => $order->delivery_date ?? '', + ]) + ->toArray(); + } + + /** + * DB → 프론트엔드 변환 + */ + private function transformToFrontend(PerformanceReport $report): array + { + $doc = $report->qualityDocument; + + return [ + 'id' => $report->id, + 'quality_doc_number' => $doc?->quality_doc_number ?? '', + 'created_date' => $report->created_at?->format('Y-m-d') ?? '', + 'site_name' => $doc?->site_name ?? '', + 'client' => $doc?->client?->name ?? '', + 'location_count' => $doc?->locations?->count() ?? 0, + 'required_info' => $doc ? $this->qualityDocumentService->calculateRequiredInfo($doc) : '', + 'confirm_status' => $report->confirmation_status === PerformanceReport::STATUS_CONFIRMED ? 'confirmed' : 'unconfirmed', + 'confirm_date' => $report->confirmed_date?->format('Y-m-d'), + 'memo' => $report->memo ?? '', + 'year' => $report->year, + 'quarter' => $report->quarter, + ]; + } +} diff --git a/app/Services/QualityDocumentService.php b/app/Services/QualityDocumentService.php new file mode 100644 index 0000000..66af795 --- /dev/null +++ b/app/Services/QualityDocumentService.php @@ -0,0 +1,748 @@ +tenantId(); + $perPage = (int) ($params['per_page'] ?? 20); + $q = trim((string) ($params['q'] ?? '')); + $status = $params['status'] ?? null; + $dateFrom = $params['date_from'] ?? null; + $dateTo = $params['date_to'] ?? null; + + $query = QualityDocument::query() + ->where('tenant_id', $tenantId) + ->with(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations']); + + if ($q !== '') { + $query->where(function ($qq) use ($q) { + $qq->where('quality_doc_number', 'like', "%{$q}%") + ->orWhere('site_name', 'like', "%{$q}%"); + }); + } + + if ($status !== null) { + $dbStatus = QualityDocument::mapStatusFromFrontend($status); + $query->where('status', $dbStatus); + } + + if ($dateFrom !== null) { + $query->where('received_date', '>=', $dateFrom); + } + if ($dateTo !== null) { + $query->where('received_date', '<=', $dateTo); + } + + $query->orderByDesc('id'); + $paginated = $query->paginate($perPage); + + $transformedData = $paginated->getCollection()->map(fn ($doc) => $this->transformToFrontend($doc)); + + return [ + 'items' => $transformedData, + 'current_page' => $paginated->currentPage(), + 'last_page' => $paginated->lastPage(), + 'per_page' => $paginated->perPage(), + 'total' => $paginated->total(), + ]; + } + + /** + * 통계 조회 + */ + public function stats(array $params = []): array + { + $tenantId = $this->tenantId(); + + $query = QualityDocument::where('tenant_id', $tenantId); + + if (! empty($params['date_from'])) { + $query->where('received_date', '>=', $params['date_from']); + } + if (! empty($params['date_to'])) { + $query->where('received_date', '<=', $params['date_to']); + } + + $counts = (clone $query) + ->select('status', DB::raw('count(*) as count')) + ->groupBy('status') + ->pluck('count', 'status') + ->toArray(); + + return [ + 'reception_count' => $counts[QualityDocument::STATUS_RECEIVED] ?? 0, + 'in_progress_count' => $counts[QualityDocument::STATUS_IN_PROGRESS] ?? 0, + 'completed_count' => $counts[QualityDocument::STATUS_COMPLETED] ?? 0, + ]; + } + + /** + * 캘린더 스케줄 조회 + */ + public function calendar(array $params): array + { + $tenantId = $this->tenantId(); + $year = (int) ($params['year'] ?? now()->year); + $month = (int) ($params['month'] ?? now()->month); + + $startDate = sprintf('%04d-%02d-01', $year, $month); + $endDate = date('Y-m-t', strtotime($startDate)); + + $query = QualityDocument::query() + ->where('tenant_id', $tenantId) + ->with(['inspector:id,name']); + + // options JSON 내 inspection.start_date / inspection.end_date 기준 필터링 + // received_date 기준으로 범위 필터 (options JSON은 직접 SQL 필터 어려우므로) + $query->where(function ($q) use ($startDate, $endDate) { + $q->whereBetween('received_date', [$startDate, $endDate]); + }); + + if (! empty($params['status'])) { + $dbStatus = QualityDocument::mapStatusFromFrontend($params['status']); + $query->where('status', $dbStatus); + } + + return $query->orderBy('received_date') + ->get() + ->map(function (QualityDocument $doc) { + $options = $doc->options ?? []; + $inspection = $options['inspection'] ?? []; + + return [ + 'id' => $doc->id, + 'start_date' => $inspection['start_date'] ?? $doc->received_date?->format('Y-m-d'), + 'end_date' => $inspection['end_date'] ?? $doc->received_date?->format('Y-m-d'), + 'inspector' => $doc->inspector?->name ?? '', + 'site_name' => $doc->site_name, + 'status' => QualityDocument::mapStatusToFrontend($doc->status), + ]; + }) + ->values() + ->toArray(); + } + + /** + * 단건 조회 + */ + public function show(int $id) + { + $tenantId = $this->tenantId(); + + $doc = QualityDocument::where('tenant_id', $tenantId) + ->with([ + 'client', + 'inspector:id,name', + 'creator:id,name', + 'documentOrders.order', + 'locations.orderItem.node', + ]) + ->find($id); + + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + return $this->transformToFrontend($doc, true); + } + + /** + * 생성 + */ + public function store(array $data) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + $data['tenant_id'] = $tenantId; + $data['quality_doc_number'] = QualityDocument::generateDocNumber($tenantId); + $data['status'] = QualityDocument::STATUS_RECEIVED; + $data['created_by'] = $userId; + + $doc = QualityDocument::create($data); + + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $doc->id, + 'created', + null, + $doc->toArray() + ); + + return $this->transformToFrontend($doc->load(['client', 'inspector:id,name', 'creator:id,name'])); + }); + } + + /** + * 수정 + */ + public function update(int $id, array $data) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $doc = QualityDocument::where('tenant_id', $tenantId)->find($id); + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $beforeData = $doc->toArray(); + + return DB::transaction(function () use ($doc, $data, $userId, $beforeData) { + $data['updated_by'] = $userId; + + // options는 기존 값과 병합 + if (isset($data['options'])) { + $existingOptions = $doc->options ?? []; + $data['options'] = array_replace_recursive($existingOptions, $data['options']); + } + + $doc->update($data); + + $this->auditLogger->log( + $doc->tenant_id, + self::AUDIT_TARGET, + $doc->id, + 'updated', + $beforeData, + $doc->fresh()->toArray() + ); + + return $this->transformToFrontend($doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations'])); + }); + } + + /** + * 삭제 + */ + public function destroy(int $id) + { + $tenantId = $this->tenantId(); + + $doc = QualityDocument::where('tenant_id', $tenantId)->find($id); + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + if ($doc->isCompleted()) { + throw new BadRequestHttpException(__('error.quality.cannot_delete_completed')); + } + + $beforeData = $doc->toArray(); + $doc->deleted_by = $this->apiUserId(); + $doc->save(); + $doc->delete(); + + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $doc->id, + 'deleted', + $beforeData, + null + ); + + return 'success'; + } + + /** + * 검사 완료 처리 + */ + public function complete(int $id) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $doc = QualityDocument::where('tenant_id', $tenantId) + ->with(['locations']) + ->find($id); + + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + if ($doc->isCompleted()) { + throw new BadRequestHttpException(__('error.quality.already_completed')); + } + + // 미완료 개소 확인 + $pendingCount = $doc->locations->where('inspection_status', QualityDocumentLocation::STATUS_PENDING)->count(); + if ($pendingCount > 0) { + throw new BadRequestHttpException(__('error.quality.pending_locations', ['count' => $pendingCount])); + } + + $beforeData = $doc->toArray(); + + return DB::transaction(function () use ($doc, $userId, $tenantId, $beforeData) { + $doc->update([ + 'status' => QualityDocument::STATUS_COMPLETED, + 'updated_by' => $userId, + ]); + + // 실적신고 자동 생성 + $now = now(); + PerformanceReport::firstOrCreate( + [ + 'tenant_id' => $tenantId, + 'quality_document_id' => $doc->id, + ], + [ + 'year' => $now->year, + 'quarter' => (int) ceil($now->month / 3), + 'confirmation_status' => PerformanceReport::STATUS_UNCONFIRMED, + 'created_by' => $userId, + ] + ); + + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $doc->id, + 'completed', + $beforeData, + $doc->fresh()->toArray() + ); + + return $this->transformToFrontend($doc->load(['client', 'inspector:id,name', 'creator:id,name'])); + }); + } + + /** + * 검사 미등록 수주 목록 + */ + public function availableOrders(array $params): array + { + $tenantId = $this->tenantId(); + $q = trim((string) ($params['q'] ?? '')); + + // 이미 연결된 수주 ID 목록 + $linkedOrderIds = QualityDocumentOrder::whereHas('qualityDocument', function ($query) use ($tenantId) { + $query->where('tenant_id', $tenantId); + })->pluck('order_id'); + + $query = Order::where('tenant_id', $tenantId) + ->whereNotIn('id', $linkedOrderIds) + ->with(['items']) + ->withCount('items'); + + if ($q !== '') { + $query->where(function ($qq) use ($q) { + $qq->where('order_no', 'like', "%{$q}%") + ->orWhere('site_name', 'like', "%{$q}%"); + }); + } + + return $query->orderByDesc('id') + ->limit(50) + ->get() + ->map(fn ($order) => [ + 'id' => $order->id, + 'order_number' => $order->order_no, + 'site_name' => $order->site_name ?? '', + 'delivery_date' => $order->delivery_date ?? '', + 'location_count' => $order->items_count, + ]) + ->toArray(); + } + + /** + * 수주 연결 + */ + public function attachOrders(int $docId, array $orderIds) + { + $tenantId = $this->tenantId(); + + $doc = QualityDocument::where('tenant_id', $tenantId)->find($docId); + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + return DB::transaction(function () use ($doc, $orderIds, $tenantId) { + foreach ($orderIds as $orderId) { + $order = Order::where('tenant_id', $tenantId)->find($orderId); + if (! $order) { + continue; + } + + // 중복 체크 + $docOrder = QualityDocumentOrder::firstOrCreate([ + 'quality_document_id' => $doc->id, + 'order_id' => $orderId, + ]); + + // 수주 연결 시 개소(order_items)를 locations에 자동 생성 + $orderItems = OrderItem::where('order_id', $orderId)->get(); + foreach ($orderItems as $item) { + QualityDocumentLocation::firstOrCreate([ + 'quality_document_id' => $doc->id, + 'quality_document_order_id' => $docOrder->id, + 'order_item_id' => $item->id, + ]); + } + } + + // 상태를 진행중으로 변경 (접수 상태일 때) + if ($doc->isReceived()) { + $doc->update(['status' => QualityDocument::STATUS_IN_PROGRESS]); + } + + return $this->transformToFrontend( + $doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node']) + ); + }); + } + + /** + * 수주 연결 해제 + */ + public function detachOrder(int $docId, int $orderId) + { + $tenantId = $this->tenantId(); + + $doc = QualityDocument::where('tenant_id', $tenantId)->find($docId); + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + if ($doc->isCompleted()) { + throw new BadRequestHttpException(__('error.quality.cannot_modify_completed')); + } + + $docOrder = QualityDocumentOrder::where('quality_document_id', $docId) + ->where('order_id', $orderId) + ->first(); + + if ($docOrder) { + // 해당 수주의 locations 삭제 + QualityDocumentLocation::where('quality_document_order_id', $docOrder->id)->delete(); + $docOrder->delete(); + } + + return $this->transformToFrontend( + $doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node']) + ); + } + + /** + * 필수정보 계산 + */ + public function calculateRequiredInfo(QualityDocument $doc): string + { + $options = $doc->options ?? []; + $missing = 0; + + $sections = [ + 'construction_site' => ['name', 'land_location', 'lot_number'], + 'material_distributor' => ['company', 'address', 'ceo', 'phone'], + 'contractor' => ['company', 'address', 'name', 'phone'], + 'supervisor' => ['office', 'address', 'name', 'phone'], + ]; + + foreach ($sections as $section => $fields) { + $data = $options[$section] ?? []; + foreach ($fields as $field) { + if (empty($data[$field])) { + $missing++; + break; // 섹션 단위 + } + } + } + + return $missing === 0 ? '완료' : "{$missing}건 누락"; + } + + /** + * DB → 프론트엔드 변환 + */ + private function transformToFrontend(QualityDocument $doc, bool $detail = false): array + { + $options = $doc->options ?? []; + + $result = [ + 'id' => $doc->id, + 'quality_doc_number' => $doc->quality_doc_number, + 'site_name' => $doc->site_name, + 'client' => $doc->client?->name ?? '', + 'location_count' => $doc->locations?->count() ?? 0, + 'required_info' => $this->calculateRequiredInfo($doc), + 'inspection_period' => $this->formatInspectionPeriod($options), + 'inspector' => $doc->inspector?->name ?? '', + 'status' => QualityDocument::mapStatusToFrontend($doc->status), + 'author' => $doc->creator?->name ?? '', + 'reception_date' => $doc->received_date?->format('Y-m-d'), + 'manager' => $options['manager']['name'] ?? '', + 'manager_contact' => $options['manager']['phone'] ?? '', + ]; + + if ($detail) { + $result['construction_site'] = [ + 'site_name' => $options['construction_site']['name'] ?? '', + 'land_location' => $options['construction_site']['land_location'] ?? '', + 'lot_number' => $options['construction_site']['lot_number'] ?? '', + ]; + $result['material_distributor'] = [ + 'company_name' => $options['material_distributor']['company'] ?? '', + 'company_address' => $options['material_distributor']['address'] ?? '', + 'representative_name' => $options['material_distributor']['ceo'] ?? '', + 'phone' => $options['material_distributor']['phone'] ?? '', + ]; + $result['constructor_info'] = [ + 'company_name' => $options['contractor']['company'] ?? '', + 'company_address' => $options['contractor']['address'] ?? '', + 'name' => $options['contractor']['name'] ?? '', + 'phone' => $options['contractor']['phone'] ?? '', + ]; + $result['supervisor'] = [ + 'office_name' => $options['supervisor']['office'] ?? '', + 'office_address' => $options['supervisor']['address'] ?? '', + 'name' => $options['supervisor']['name'] ?? '', + 'phone' => $options['supervisor']['phone'] ?? '', + ]; + $result['schedule_info'] = [ + 'visit_request_date' => $options['inspection']['request_date'] ?? '', + 'start_date' => $options['inspection']['start_date'] ?? '', + 'end_date' => $options['inspection']['end_date'] ?? '', + 'inspector' => $doc->inspector?->name ?? '', + 'site_postal_code' => $options['site_address']['postal_code'] ?? '', + 'site_address' => $options['site_address']['address'] ?? '', + 'site_address_detail' => $options['site_address']['detail'] ?? '', + ]; + + // 개소 목록 + $result['order_items'] = $doc->locations->map(function ($loc) { + $orderItem = $loc->orderItem; + $node = $orderItem?->node; + $nodeOptions = $node?->options ?? []; + $order = $loc->qualityDocumentOrder?->order; + + return [ + 'id' => (string) $loc->id, + 'order_number' => $order?->order_no ?? '', + 'site_name' => $order?->site_name ?? '', + 'delivery_date' => $order?->delivery_date ?? '', + 'floor' => $orderItem?->floor_code ?? '', + 'symbol' => $orderItem?->symbol_code ?? '', + 'order_width' => $nodeOptions['width'] ?? 0, + 'order_height' => $nodeOptions['height'] ?? 0, + 'construction_width' => $loc->post_width ?? 0, + 'construction_height' => $loc->post_height ?? 0, + 'change_reason' => $loc->change_reason ?? '', + ]; + })->toArray(); + } + + return $result; + } + + /** + * 개소별 검사 저장 (시공후 규격 + 검사 성적서) + */ + public function inspectLocation(int $docId, int $locId, array $data) + { + $tenantId = $this->tenantId(); + + $doc = QualityDocument::where('tenant_id', $tenantId)->find($docId); + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + if ($doc->isCompleted()) { + throw new BadRequestHttpException(__('error.quality.cannot_modify_completed')); + } + + $location = QualityDocumentLocation::where('quality_document_id', $docId)->find($locId); + if (! $location) { + throw new NotFoundHttpException(__('error.not_found')); + } + + return DB::transaction(function () use ($location, $data, $doc) { + $updateData = []; + + if (isset($data['post_width'])) { + $updateData['post_width'] = $data['post_width']; + } + if (isset($data['post_height'])) { + $updateData['post_height'] = $data['post_height']; + } + if (isset($data['change_reason'])) { + $updateData['change_reason'] = $data['change_reason']; + } + if (isset($data['inspection_status'])) { + $updateData['inspection_status'] = $data['inspection_status']; + } + + if (! empty($updateData)) { + $location->update($updateData); + } + + // 상태를 진행중으로 변경 (접수 상태일 때) + if ($doc->isReceived()) { + $doc->update(['status' => QualityDocument::STATUS_IN_PROGRESS]); + } + + return $location->fresh()->toArray(); + }); + } + + /** + * 검사제품요청서 데이터 (PDF/프린트용) + */ + public function requestDocument(int $id): array + { + $tenantId = $this->tenantId(); + + $doc = QualityDocument::where('tenant_id', $tenantId) + ->with([ + 'client', + 'inspector:id,name', + 'documentOrders.order', + 'locations.orderItem.node', + ]) + ->find($id); + + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $options = $doc->options ?? []; + + return [ + 'quality_doc_number' => $doc->quality_doc_number, + 'site_name' => $doc->site_name, + 'client' => $doc->client?->name ?? '', + 'received_date' => $doc->received_date?->format('Y-m-d'), + 'inspector' => $doc->inspector?->name ?? '', + 'construction_site' => $options['construction_site'] ?? [], + 'material_distributor' => $options['material_distributor'] ?? [], + 'contractor' => $options['contractor'] ?? [], + 'supervisor' => $options['supervisor'] ?? [], + 'inspection' => $options['inspection'] ?? [], + 'site_address' => $options['site_address'] ?? [], + 'manager' => $options['manager'] ?? [], + 'items' => $doc->locations->map(function ($loc) { + $orderItem = $loc->orderItem; + $node = $orderItem?->node; + $nodeOptions = $node?->options ?? []; + $order = $loc->qualityDocumentOrder?->order; + + return [ + 'order_number' => $order?->order_no ?? '', + 'floor' => $orderItem?->floor_code ?? '', + 'symbol' => $orderItem?->symbol_code ?? '', + 'item_name' => $orderItem?->item_name ?? '', + 'specification' => $orderItem?->specification ?? '', + 'order_width' => $nodeOptions['width'] ?? 0, + 'order_height' => $nodeOptions['height'] ?? 0, + 'quantity' => $orderItem?->quantity ?? 1, + ]; + })->toArray(), + ]; + } + + /** + * 제품검사성적서 데이터 (documents EAV 연동) + */ + public function resultDocument(int $id): array + { + $tenantId = $this->tenantId(); + + $doc = QualityDocument::where('tenant_id', $tenantId) + ->with([ + 'client', + 'inspector:id,name', + 'locations.orderItem.node', + 'locations.document.data', + 'locations.document.template', + ]) + ->find($id); + + if (! $doc) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $options = $doc->options ?? []; + + return [ + 'quality_doc_number' => $doc->quality_doc_number, + 'site_name' => $doc->site_name, + 'client' => $doc->client?->name ?? '', + 'inspector' => $doc->inspector?->name ?? '', + 'status' => QualityDocument::mapStatusToFrontend($doc->status), + 'locations' => $doc->locations->map(function ($loc) { + $orderItem = $loc->orderItem; + $node = $orderItem?->node; + $nodeOptions = $node?->options ?? []; + $document = $loc->document; + + $result = [ + 'id' => $loc->id, + 'floor' => $orderItem?->floor_code ?? '', + 'symbol' => $orderItem?->symbol_code ?? '', + 'order_width' => $nodeOptions['width'] ?? 0, + 'order_height' => $nodeOptions['height'] ?? 0, + 'post_width' => $loc->post_width, + 'post_height' => $loc->post_height, + 'change_reason' => $loc->change_reason, + 'inspection_status' => $loc->inspection_status, + 'document_id' => $loc->document_id, + ]; + + // EAV 문서 데이터가 있으면 포함 + if ($document) { + $result['document'] = [ + 'id' => $document->id, + 'document_no' => $document->document_no, + 'status' => $document->status, + 'template_id' => $document->template_id, + 'data' => $document->data?->map(fn ($d) => [ + 'field_key' => $d->field_key, + 'field_value' => $d->field_value, + 'section_id' => $d->section_id, + 'column_id' => $d->column_id, + ])->toArray() ?? [], + ]; + } + + return $result; + })->toArray(), + ]; + } + + private function formatInspectionPeriod(array $options): string + { + $inspection = $options['inspection'] ?? []; + $start = $inspection['start_date'] ?? ''; + $end = $inspection['end_date'] ?? ''; + + if ($start && $end) { + return "{$start}~{$end}"; + } + + return $start ?: $end ?: ''; + } +} diff --git a/database/migrations/2026_03_05_180001_create_quality_documents_table.php b/database/migrations/2026_03_05_180001_create_quality_documents_table.php new file mode 100644 index 0000000..3f5fa13 --- /dev/null +++ b/database/migrations/2026_03_05_180001_create_quality_documents_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('tenant_id')->constrained(); + $table->string('quality_doc_number', 30)->comment('품질관리서 번호'); + $table->string('site_name')->comment('현장명'); + $table->string('status', 20)->default('received')->comment('received/in_progress/completed'); + $table->foreignId('client_id')->nullable()->constrained('clients')->comment('수주처'); + $table->foreignId('inspector_id')->nullable()->constrained('users')->comment('검사자'); + $table->date('received_date')->nullable()->comment('접수일'); + $table->json('options')->nullable()->comment('관련자정보, 검사정보, 현장주소 등'); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->unsignedBigInteger('deleted_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['tenant_id', 'quality_doc_number']); + $table->index(['tenant_id', 'status']); + $table->index(['tenant_id', 'client_id']); + $table->index(['tenant_id', 'inspector_id']); + $table->index(['tenant_id', 'received_date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('quality_documents'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_03_05_180002_create_quality_document_orders_table.php b/database/migrations/2026_03_05_180002_create_quality_document_orders_table.php new file mode 100644 index 0000000..2b9e6b2 --- /dev/null +++ b/database/migrations/2026_03_05_180002_create_quality_document_orders_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('quality_document_id')->constrained()->cascadeOnDelete(); + $table->foreignId('order_id')->constrained('orders'); + $table->timestamps(); + + $table->unique(['quality_document_id', 'order_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('quality_document_orders'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_03_05_180003_create_quality_document_locations_table.php b/database/migrations/2026_03_05_180003_create_quality_document_locations_table.php new file mode 100644 index 0000000..a3b8ba0 --- /dev/null +++ b/database/migrations/2026_03_05_180003_create_quality_document_locations_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('quality_document_id')->constrained()->cascadeOnDelete(); + $table->foreignId('quality_document_order_id')->constrained('quality_document_orders', 'id', 'qdl_qdo_id_fk')->cascadeOnDelete(); + $table->foreignId('order_item_id')->constrained('order_items'); + $table->integer('post_width')->nullable()->comment('시공후 가로'); + $table->integer('post_height')->nullable()->comment('시공후 세로'); + $table->string('change_reason')->nullable()->comment('규격 변경사유'); + $table->foreignId('document_id')->nullable()->comment('검사성적서 문서 ID'); + $table->string('inspection_status', 20)->default('pending')->comment('pending/completed'); + $table->timestamps(); + + $table->index(['quality_document_id', 'inspection_status'], 'qdl_doc_id_status_index'); + }); + } + + public function down(): void + { + Schema::dropIfExists('quality_document_locations'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_03_05_180004_create_performance_reports_table.php b/database/migrations/2026_03_05_180004_create_performance_reports_table.php new file mode 100644 index 0000000..860966d --- /dev/null +++ b/database/migrations/2026_03_05_180004_create_performance_reports_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('tenant_id')->constrained(); + $table->foreignId('quality_document_id')->constrained(); + $table->unsignedSmallInteger('year')->comment('연도'); + $table->unsignedTinyInteger('quarter')->comment('분기 1-4'); + $table->string('confirmation_status', 20)->default('unconfirmed')->comment('unconfirmed/confirmed/reported'); + $table->date('confirmed_date')->nullable(); + $table->foreignId('confirmed_by')->nullable()->constrained('users'); + $table->text('memo')->nullable(); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->unsignedBigInteger('deleted_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['tenant_id', 'quality_document_id']); + $table->index(['tenant_id', 'year', 'quarter']); + $table->index(['tenant_id', 'confirmation_status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('performance_reports'); + } +}; \ No newline at end of file diff --git a/lang/ko/error.php b/lang/ko/error.php index bdad200..7684cc9 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -444,6 +444,15 @@ 'already_completed' => '이미 완료된 검사입니다.', ], + // 품질관리서 관련 + 'quality' => [ + 'cannot_delete_completed' => '완료된 품질관리서는 삭제할 수 없습니다.', + 'already_completed' => '이미 완료된 품질관리서입니다.', + 'cannot_modify_completed' => '완료된 품질관리서는 수정할 수 없습니다.', + 'pending_locations' => '미완료 개소가 :count건 있습니다.', + 'confirm_failed' => '필수정보가 누락된 건이 있어 확정할 수 없습니다.', + ], + // 입찰 관련 'bidding' => [ 'not_found' => '입찰을 찾을 수 없습니다.', diff --git a/routes/api.php b/routes/api.php index a700278..0d329de 100644 --- a/routes/api.php +++ b/routes/api.php @@ -41,6 +41,7 @@ require __DIR__.'/api/v1/app.php'; require __DIR__.'/api/v1/audit.php'; require __DIR__.'/api/v1/esign.php'; + require __DIR__.'/api/v1/quality.php'; // 공유 링크 다운로드 (인증 불필요 - auth.apikey 그룹 밖) Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download'); diff --git a/routes/api/v1/quality.php b/routes/api/v1/quality.php new file mode 100644 index 0000000..edebe9d --- /dev/null +++ b/routes/api/v1/quality.php @@ -0,0 +1,40 @@ +group(function () { + Route::get('', [QualityDocumentController::class, 'index'])->name('v1.quality.documents.index'); + Route::get('/stats', [QualityDocumentController::class, 'stats'])->name('v1.quality.documents.stats'); + Route::get('/calendar', [QualityDocumentController::class, 'calendar'])->name('v1.quality.documents.calendar'); + Route::get('/available-orders', [QualityDocumentController::class, 'availableOrders'])->name('v1.quality.documents.available-orders'); + Route::post('', [QualityDocumentController::class, 'store'])->name('v1.quality.documents.store'); + Route::get('/{id}', [QualityDocumentController::class, 'show'])->whereNumber('id')->name('v1.quality.documents.show'); + Route::put('/{id}', [QualityDocumentController::class, 'update'])->whereNumber('id')->name('v1.quality.documents.update'); + Route::delete('/{id}', [QualityDocumentController::class, 'destroy'])->whereNumber('id')->name('v1.quality.documents.destroy'); + Route::patch('/{id}/complete', [QualityDocumentController::class, 'complete'])->whereNumber('id')->name('v1.quality.documents.complete'); + Route::post('/{id}/orders', [QualityDocumentController::class, 'attachOrders'])->whereNumber('id')->name('v1.quality.documents.attach-orders'); + Route::delete('/{id}/orders/{orderId}', [QualityDocumentController::class, 'detachOrder'])->whereNumber('id')->whereNumber('orderId')->name('v1.quality.documents.detach-order'); + Route::post('/{id}/locations/{locId}/inspect', [QualityDocumentController::class, 'inspectLocation'])->whereNumber('id')->whereNumber('locId')->name('v1.quality.documents.inspect-location'); + Route::get('/{id}/request-document', [QualityDocumentController::class, 'requestDocument'])->whereNumber('id')->name('v1.quality.documents.request-document'); + Route::get('/{id}/result-document', [QualityDocumentController::class, 'resultDocument'])->whereNumber('id')->name('v1.quality.documents.result-document'); +}); + +// 실적신고 +Route::prefix('quality/performance-reports')->group(function () { + Route::get('', [PerformanceReportController::class, 'index'])->name('v1.quality.performance-reports.index'); + Route::get('/stats', [PerformanceReportController::class, 'stats'])->name('v1.quality.performance-reports.stats'); + Route::get('/missing', [PerformanceReportController::class, 'missing'])->name('v1.quality.performance-reports.missing'); + Route::patch('/confirm', [PerformanceReportController::class, 'confirm'])->name('v1.quality.performance-reports.confirm'); + Route::patch('/unconfirm', [PerformanceReportController::class, 'unconfirm'])->name('v1.quality.performance-reports.unconfirm'); + Route::patch('/memo', [PerformanceReportController::class, 'updateMemo'])->name('v1.quality.performance-reports.memo'); +}); From 3600c7b12bdfb57825ce5ecf55e6652b2fd22cdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 5 Mar 2026 21:59:24 +0900 Subject: [PATCH 102/166] =?UTF-8?q?fix:=20[=ED=92=88=EC=A7=88=EA=B4=80?= =?UTF-8?q?=EB=A6=AC]=20=EC=88=98=EC=A3=BC=EC=84=A0=ED=83=9D=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EB=82=A9=ED=92=88=EC=9D=BC=20=ED=8F=AC=EB=A7=B7=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B0=9C=EC=86=8C=20=EC=88=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - delivery_date: ISO 타임스탬프 → Y-m-d 포맷으로 변환 - location_count: order_items 수 → order_nodes 루트 노드(개소) 수로 변경 Co-Authored-By: Claude Opus 4.6 --- app/Services/QualityDocumentService.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/Services/QualityDocumentService.php b/app/Services/QualityDocumentService.php index 66af795..313ada7 100644 --- a/app/Services/QualityDocumentService.php +++ b/app/Services/QualityDocumentService.php @@ -347,8 +347,9 @@ public function availableOrders(array $params): array $query = Order::where('tenant_id', $tenantId) ->whereNotIn('id', $linkedOrderIds) - ->with(['items']) - ->withCount('items'); + ->withCount(['nodes as location_count' => function ($q) { + $q->whereNull('parent_id'); + }]); if ($q !== '') { $query->where(function ($qq) use ($q) { @@ -364,8 +365,8 @@ public function availableOrders(array $params): array 'id' => $order->id, 'order_number' => $order->order_no, 'site_name' => $order->site_name ?? '', - 'delivery_date' => $order->delivery_date ?? '', - 'location_count' => $order->items_count, + 'delivery_date' => $order->delivery_date?->format('Y-m-d') ?? '', + 'location_count' => $order->location_count, ]) ->toArray(); } From 0f26ea546ae147871cbed830766bc3259eb1ebf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 5 Mar 2026 23:08:56 +0900 Subject: [PATCH 103/166] =?UTF-8?q?feat:=20[=ED=92=88=EC=A7=88=EA=B4=80?= =?UTF-8?q?=EB=A6=AC]=20=EC=88=98=EC=A3=BC=EC=84=A0=ED=83=9D=20API?= =?UTF-8?q?=EC=97=90=20=EB=B0=9C=EC=A3=BC=EC=B2=98(client=5Fname)=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 Co-Authored-By: Claude Opus 4.6 --- app/Services/QualityDocumentService.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Services/QualityDocumentService.php b/app/Services/QualityDocumentService.php index 313ada7..bfcaeb2 100644 --- a/app/Services/QualityDocumentService.php +++ b/app/Services/QualityDocumentService.php @@ -365,6 +365,7 @@ public function availableOrders(array $params): array 'id' => $order->id, 'order_number' => $order->order_no, 'site_name' => $order->site_name ?? '', + 'client_name' => $order->client_name ?? '', 'delivery_date' => $order->delivery_date?->format('Y-m-d') ?? '', 'location_count' => $order->location_count, ]) From a845f52fc064460b9c8db969163950a664e7cbc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 6 Mar 2026 09:25:00 +0900 Subject: [PATCH 104/166] =?UTF-8?q?fix:=20[=EC=83=9D=EC=82=B0=EC=A7=80?= =?UTF-8?q?=EC=8B=9C]=20withCount=EC=97=90=EC=84=9C=EB=8F=84=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B0=20=EA=B3=B5=EC=A0=95(=EC=9E=AC=EA=B3=A0=EC=83=9D?= =?UTF-8?q?=EC=82=B0)=20WO=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 목록 조회 시 work_orders_count에서 is_auxiliary WO 제외 - whereNotNull(process_id) + options->is_auxiliary 조건 추가 Co-Authored-By: Claude Opus 4.6 --- app/Services/ProductionOrderService.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/Services/ProductionOrderService.php b/app/Services/ProductionOrderService.php index a4889de..638ca00 100644 --- a/app/Services/ProductionOrderService.php +++ b/app/Services/ProductionOrderService.php @@ -30,7 +30,9 @@ public function index(array $params): LengthAwarePaginator ->whereIn('status_code', self::PRODUCTION_STATUSES) ->with(['client', 'workOrders.process', 'workOrders.assignees.user']) ->withCount([ - 'workOrders' => fn ($q) => $q->whereNotNull('process_id'), + 'workOrders' => fn ($q) => $q->whereNotNull('process_id') + ->where(fn ($q2) => $q2->whereNull('options->is_auxiliary') + ->orWhere('options->is_auxiliary', false)), 'nodes', ]); From 293330c4180238a675d0a9f60eb172bec55cc8d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 6 Mar 2026 15:51:26 +0900 Subject: [PATCH 105/166] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20rendered?= =?UTF-8?q?=5Fhtml=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Document 모델 $fillable에 rendered_html 추가 - DocumentService create/update에서 rendered_html 저장 - StoreRequest/UpdateRequest에 rendered_html 검증 추가 - WorkOrderService 검사문서/작업일지 생성 시 rendered_html 전달 Co-Authored-By: Claude Opus 4.6 --- app/Http/Requests/Document/StoreRequest.php | 3 +++ app/Http/Requests/Document/UpdateRequest.php | 3 +++ app/Models/Documents/Document.php | 1 + app/Services/DocumentService.php | 9 +++++-- app/Services/WorkOrderService.php | 27 +++++++++++++++----- 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/app/Http/Requests/Document/StoreRequest.php b/app/Http/Requests/Document/StoreRequest.php index a6681e1..4a4e977 100644 --- a/app/Http/Requests/Document/StoreRequest.php +++ b/app/Http/Requests/Document/StoreRequest.php @@ -28,6 +28,9 @@ public function rules(): array 'approvers.*.user_id' => 'required_with:approvers|integer|exists:users,id', 'approvers.*.role' => 'nullable|string|max:50', + // HTML 스냅샷 + 'rendered_html' => 'nullable|string', + // 문서 데이터 (EAV) 'data' => 'nullable|array', 'data.*.section_id' => 'nullable|integer', diff --git a/app/Http/Requests/Document/UpdateRequest.php b/app/Http/Requests/Document/UpdateRequest.php index 8388c00..8b07e69 100644 --- a/app/Http/Requests/Document/UpdateRequest.php +++ b/app/Http/Requests/Document/UpdateRequest.php @@ -27,6 +27,9 @@ public function rules(): array 'approvers.*.user_id' => 'required_with:approvers|integer|exists:users,id', 'approvers.*.role' => 'nullable|string|max:50', + // HTML 스냅샷 + 'rendered_html' => 'nullable|string', + // 문서 데이터 (EAV) 'data' => 'nullable|array', 'data.*.section_id' => 'nullable|integer', diff --git a/app/Models/Documents/Document.php b/app/Models/Documents/Document.php index 1f5b4ed..8ce6a10 100644 --- a/app/Models/Documents/Document.php +++ b/app/Models/Documents/Document.php @@ -73,6 +73,7 @@ class Document extends Model 'linkable_id', 'submitted_at', 'completed_at', + 'rendered_html', 'created_by', 'updated_by', 'deleted_by', diff --git a/app/Services/DocumentService.php b/app/Services/DocumentService.php index b939fe9..6877956 100644 --- a/app/Services/DocumentService.php +++ b/app/Services/DocumentService.php @@ -122,6 +122,7 @@ public function create(array $data): Document 'status' => Document::STATUS_DRAFT, 'linkable_type' => $data['linkable_type'] ?? null, 'linkable_id' => $data['linkable_id'] ?? null, + 'rendered_html' => $data['rendered_html'] ?? null, 'created_by' => $userId, 'updated_by' => $userId, ]); @@ -170,12 +171,16 @@ public function update(int $id, array $data): Document } // 기본 정보 수정 - $document->fill([ + $updateFields = [ 'title' => $data['title'] ?? $document->title, 'linkable_type' => $data['linkable_type'] ?? $document->linkable_type, 'linkable_id' => $data['linkable_id'] ?? $document->linkable_id, 'updated_by' => $userId, - ]); + ]; + if (isset($data['rendered_html'])) { + $updateFields['rendered_html'] = $data['rendered_html']; + } + $document->fill($updateFields); // 반려 상태에서 수정 시 DRAFT로 변경 if ($document->status === Document::STATUS_REJECTED) { diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index d18aa7d..ee5d342 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -2509,10 +2509,14 @@ public function createInspectionDocument(int $workOrderId, array $inspectionData ]) ->toArray(); - $document = $documentService->update($existingDocument->id, [ + $updateData = [ 'title' => $inspectionData['title'] ?? $existingDocument->title, 'data' => array_merge($existingBasicFields, $documentDataRecords), - ]); + ]; + if (isset($inspectionData['rendered_html'])) { + $updateData['rendered_html'] = $inspectionData['rendered_html']; + } + $document = $documentService->update($existingDocument->id, $updateData); $action = 'inspection_document_updated'; } else { @@ -2524,6 +2528,9 @@ public function createInspectionDocument(int $workOrderId, array $inspectionData 'data' => $documentDataRecords, 'approvers' => $inspectionData['approvers'] ?? [], ]; + if (isset($inspectionData['rendered_html'])) { + $documentData['rendered_html'] = $inspectionData['rendered_html']; + } $document = $documentService->create($documentData); $action = 'inspection_document_created'; @@ -3140,20 +3147,28 @@ public function createWorkLog(int $workOrderId, array $workLogData): array $documentDataRecords = $this->transformWorkLogDataToRecords($workLogData, $workOrder, $template); if ($existingDocument) { - $document = $documentService->update($existingDocument->id, [ + $updateData = [ 'title' => $workLogData['title'] ?? $existingDocument->title, 'data' => $documentDataRecords, - ]); + ]; + if (isset($workLogData['rendered_html'])) { + $updateData['rendered_html'] = $workLogData['rendered_html']; + } + $document = $documentService->update($existingDocument->id, $updateData); $action = 'work_log_updated'; } else { - $document = $documentService->create([ + $createData = [ 'template_id' => $templateId, 'title' => $workLogData['title'] ?? "작업일지 - {$workOrder->work_order_no}", 'linkable_type' => 'work_order', 'linkable_id' => $workOrderId, 'data' => $documentDataRecords, 'approvers' => $workLogData['approvers'] ?? [], - ]); + ]; + if (isset($workLogData['rendered_html'])) { + $createData['rendered_html'] = $workLogData['rendered_html']; + } + $document = $documentService->create($createData); $action = 'work_log_created'; } From 5ebf940873f064fdf8bd33f34617662ac3dcb4cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 6 Mar 2026 20:34:52 +0900 Subject: [PATCH 106/166] =?UTF-8?q?fix:=20[=EB=AC=B8=EC=84=9C=EC=8A=A4?= =?UTF-8?q?=EB=83=85=EC=83=B7]=20UpsertRequest=20rendered=5Fhtml=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20upsert()?= =?UTF-8?q?=20=EC=A0=84=EB=8B=AC=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UpsertRequest에 rendered_html nullable string 검증 추가 - DocumentService upsert()에서 create/update 시 rendered_html 전달 Co-Authored-By: Claude Opus 4.6 --- app/Http/Requests/Document/UpsertRequest.php | 3 +++ app/Services/DocumentService.php | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/app/Http/Requests/Document/UpsertRequest.php b/app/Http/Requests/Document/UpsertRequest.php index 99c1920..dc7eba3 100644 --- a/app/Http/Requests/Document/UpsertRequest.php +++ b/app/Http/Requests/Document/UpsertRequest.php @@ -30,6 +30,9 @@ public function rules(): array 'data.*.field_key' => 'required_with:data|string|max:100', 'data.*.field_value' => 'nullable|string', + // HTML 스냅샷 + 'rendered_html' => 'nullable|string', + // 첨부파일 'attachments' => 'nullable|array', 'attachments.*.file_id' => 'required_with:attachments|integer|exists:files,id', diff --git a/app/Services/DocumentService.php b/app/Services/DocumentService.php index 6877956..2fef635 100644 --- a/app/Services/DocumentService.php +++ b/app/Services/DocumentService.php @@ -857,24 +857,32 @@ public function upsert(array $data): Document if ($existingDocument) { // UPDATE: 기존 update 로직 재사용 - return $this->update($existingDocument->id, [ + $updatePayload = [ 'title' => $data['title'] ?? $existingDocument->title, 'linkable_type' => 'item', 'linkable_id' => $itemId, 'data' => $data['data'] ?? [], 'attachments' => $data['attachments'] ?? [], - ]); + ]; + if (isset($data['rendered_html'])) { + $updatePayload['rendered_html'] = $data['rendered_html']; + } + return $this->update($existingDocument->id, $updatePayload); } // CREATE: 기존 create 로직 재사용 - return $this->create([ + $createPayload = [ 'template_id' => $templateId, 'title' => $data['title'] ?? '', 'linkable_type' => 'item', 'linkable_id' => $itemId, 'data' => $data['data'] ?? [], 'attachments' => $data['attachments'] ?? [], - ]); + ]; + if (isset($data['rendered_html'])) { + $createPayload['rendered_html'] = $data['rendered_html']; + } + return $this->create($createPayload); }); } From c5d5b5d076a8fccac9015c6eea3782558484730e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 6 Mar 2026 20:59:17 +0900 Subject: [PATCH 107/166] =?UTF-8?q?feat:=20[=EB=AC=B8=EC=84=9C=EC=8A=A4?= =?UTF-8?q?=EB=83=85=EC=83=B7]=20Lazy=20Snapshot=20API=20-=20snapshot=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20+=20resolve?= =?UTF-8?q?=EC=97=90=20snapshot=5Fdocument=5Fid=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PATCH /documents/{id}/snapshot: canEdit 체크 없이 rendered_html만 업데이트 - DocumentService::patchSnapshot() 메서드 추가 - WorkOrderService::resolveInspectionDocument()에 snapshot_document_id 반환 (상태 무관, rendered_html NULL인 문서) Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/Documents/DocumentController.php | 16 ++++++++++++++ app/Services/DocumentService.php | 22 +++++++++++++++++++ app/Services/WorkOrderService.php | 15 +++++++++++++ routes/api/v1/documents.php | 1 + 4 files changed, 54 insertions(+) diff --git a/app/Http/Controllers/Api/V1/Documents/DocumentController.php b/app/Http/Controllers/Api/V1/Documents/DocumentController.php index 729ed46..d63bdf9 100644 --- a/app/Http/Controllers/Api/V1/Documents/DocumentController.php +++ b/app/Http/Controllers/Api/V1/Documents/DocumentController.php @@ -74,6 +74,22 @@ public function destroy(int $id): JsonResponse }, __('message.deleted')); } + /** + * rendered_html 스냅샷 저장 (Lazy Snapshot) + * PATCH /v1/documents/{id}/snapshot + */ + public function patchSnapshot(int $id, UpdateRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + $renderedHtml = $request->validated()['rendered_html'] ?? null; + if (! $renderedHtml) { + throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException('rendered_html is required'); + } + + return $this->service->patchSnapshot($id, $renderedHtml); + }, __('message.updated')); + } + // ========================================================================= // FQC 일괄생성 (제품검사) // ========================================================================= diff --git a/app/Services/DocumentService.php b/app/Services/DocumentService.php index 2fef635..82783b6 100644 --- a/app/Services/DocumentService.php +++ b/app/Services/DocumentService.php @@ -718,6 +718,28 @@ public function fqcStatus(int $orderId, int $templateId): array ]; } + // ========================================================================= + // Snapshot (Lazy Snapshot) + // ========================================================================= + + /** + * rendered_html만 업데이트 (상태 무관, canEdit 체크 없음) + * Lazy Snapshot: 조회 시 rendered_html이 없으면 프론트에서 캡처 후 저장 + */ + public function patchSnapshot(int $id, string $renderedHtml): Document + { + $tenantId = $this->tenantId(); + + $document = Document::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + $document->rendered_html = $renderedHtml; + $document->save(); + + return $document; + } + // ========================================================================= // Resolve/Upsert (React 연동용) // ========================================================================= diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index ee5d342..ba39c09 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -2431,11 +2431,26 @@ public function resolveInspectionDocument(int $workOrderId, array $params = []): ->latest() ->first(); + // Lazy Snapshot 대상: rendered_html이 없는 문서 (상태 무관) + $snapshotDocumentId = null; + $snapshotCandidate = Document::query() + ->where('tenant_id', $tenantId) + ->where('template_id', $templateId) + ->where('linkable_type', 'work_order') + ->where('linkable_id', $workOrderId) + ->whereNull('rendered_html') + ->latest() + ->value('id'); + if ($snapshotCandidate) { + $snapshotDocumentId = $snapshotCandidate; + } + return [ 'work_order_id' => $workOrderId, 'template_id' => $templateId, 'template' => $formattedTemplate, 'existing_document' => $existingDocument, + 'snapshot_document_id' => $snapshotDocumentId, 'work_order_info' => $this->buildWorkOrderInfo($workOrder), ]; } diff --git a/routes/api/v1/documents.php b/routes/api/v1/documents.php index ae56c70..2044ab4 100644 --- a/routes/api/v1/documents.php +++ b/routes/api/v1/documents.php @@ -33,6 +33,7 @@ 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::patch('/{id}/snapshot', [DocumentController::class, 'patchSnapshot'])->whereNumber('id')->name('v1.documents.snapshot'); Route::delete('/{id}', [DocumentController::class, 'destroy'])->whereNumber('id')->name('v1.documents.destroy'); // 결재 워크플로우 From f2eede6e3adb2bdf17d03cdf3eeb0cddb6dbde1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 6 Mar 2026 21:09:22 +0900 Subject: [PATCH 108/166] =?UTF-8?q?feat:=20[=ED=92=88=EC=A7=88=EA=B4=80?= =?UTF-8?q?=EB=A6=AC]=20order=5Fids=20=EC=98=81=EC=86=8D=EC=84=B1=20+=20lo?= =?UTF-8?q?cation=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StoreRequest/UpdateRequest에 order_ids 검증 추가 - UpdateRequest에 locations 검증 추가 (시공규격, 변경사유, 검사데이터) - QualityDocumentLocation에 inspection_data(JSON) fillable/cast 추가 - QualityDocumentService store()에 syncOrders 연동 - QualityDocumentService update()에 syncOrders + updateLocations 연동 - inspection_data 컬럼 추가 migration 신규 Co-Authored-By: Claude Opus 4.6 --- .../Quality/QualityDocumentStoreRequest.php | 2 + .../Quality/QualityDocumentUpdateRequest.php | 8 + .../Qualitys/QualityDocumentLocation.php | 5 + app/Services/QualityDocumentService.php | 158 ++++++++++++++++-- ...ion_data_to_quality_document_locations.php | 22 +++ 5 files changed, 182 insertions(+), 13 deletions(-) create mode 100644 database/migrations/2026_03_06_094425_add_inspection_data_to_quality_document_locations.php diff --git a/app/Http/Requests/Quality/QualityDocumentStoreRequest.php b/app/Http/Requests/Quality/QualityDocumentStoreRequest.php index ae450f4..14e8cd8 100644 --- a/app/Http/Requests/Quality/QualityDocumentStoreRequest.php +++ b/app/Http/Requests/Quality/QualityDocumentStoreRequest.php @@ -31,6 +31,8 @@ public function rules(): array 'options.material_distributor' => ['nullable', 'array'], 'options.contractor' => ['nullable', 'array'], 'options.supervisor' => ['nullable', 'array'], + 'order_ids' => ['nullable', 'array'], + 'order_ids.*' => ['integer', 'exists:orders,id'], ]; } diff --git a/app/Http/Requests/Quality/QualityDocumentUpdateRequest.php b/app/Http/Requests/Quality/QualityDocumentUpdateRequest.php index 58c7a93..763d607 100644 --- a/app/Http/Requests/Quality/QualityDocumentUpdateRequest.php +++ b/app/Http/Requests/Quality/QualityDocumentUpdateRequest.php @@ -31,6 +31,14 @@ public function rules(): array 'options.material_distributor' => ['nullable', 'array'], 'options.contractor' => ['nullable', 'array'], 'options.supervisor' => ['nullable', 'array'], + 'order_ids' => ['nullable', 'array'], + 'order_ids.*' => ['integer', 'exists:orders,id'], + 'locations' => ['nullable', 'array'], + 'locations.*.id' => ['required', 'integer'], + 'locations.*.post_width' => ['nullable', 'integer'], + 'locations.*.post_height' => ['nullable', 'integer'], + 'locations.*.change_reason' => ['nullable', 'string', 'max:500'], + 'locations.*.inspection_data' => ['nullable', 'array'], ]; } } diff --git a/app/Models/Qualitys/QualityDocumentLocation.php b/app/Models/Qualitys/QualityDocumentLocation.php index 1f7cecd..311ed9d 100644 --- a/app/Models/Qualitys/QualityDocumentLocation.php +++ b/app/Models/Qualitys/QualityDocumentLocation.php @@ -21,10 +21,15 @@ class QualityDocumentLocation extends Model 'post_width', 'post_height', 'change_reason', + 'inspection_data', 'document_id', 'inspection_status', ]; + protected $casts = [ + 'inspection_data' => 'array', + ]; + public function qualityDocument() { return $this->belongsTo(QualityDocument::class); diff --git a/app/Services/QualityDocumentService.php b/app/Services/QualityDocumentService.php index bfcaeb2..1d2336a 100644 --- a/app/Services/QualityDocumentService.php +++ b/app/Services/QualityDocumentService.php @@ -4,6 +4,7 @@ use App\Models\Orders\Order; use App\Models\Orders\OrderItem; +use App\Models\Orders\OrderNode; use App\Models\Qualitys\PerformanceReport; use App\Models\Qualitys\QualityDocument; use App\Models\Qualitys\QualityDocumentLocation; @@ -178,6 +179,10 @@ public function store(array $data) $userId = $this->apiUserId(); return DB::transaction(function () use ($data, $tenantId, $userId) { + // order_ids는 별도 처리 후 $data에서 제거 + $orderIds = $data['order_ids'] ?? null; + unset($data['order_ids']); + $data['tenant_id'] = $tenantId; $data['quality_doc_number'] = QualityDocument::generateDocNumber($tenantId); $data['status'] = QualityDocument::STATUS_RECEIVED; @@ -185,6 +190,11 @@ public function store(array $data) $doc = QualityDocument::create($data); + // 수주 연결 + if (! empty($orderIds)) { + $this->syncOrders($doc, $orderIds, $tenantId); + } + $this->auditLogger->log( $tenantId, self::AUDIT_TARGET, @@ -194,7 +204,7 @@ public function store(array $data) $doc->toArray() ); - return $this->transformToFrontend($doc->load(['client', 'inspector:id,name', 'creator:id,name'])); + return $this->transformToFrontend($doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node'])); }); } @@ -213,7 +223,12 @@ public function update(int $id, array $data) $beforeData = $doc->toArray(); - return DB::transaction(function () use ($doc, $data, $userId, $beforeData) { + return DB::transaction(function () use ($doc, $data, $userId, $beforeData, $tenantId) { + // order_ids, locations는 별도 처리 후 $data에서 제거 + $orderIds = $data['order_ids'] ?? null; + $locations = $data['locations'] ?? null; + unset($data['order_ids'], $data['locations']); + $data['updated_by'] = $userId; // options는 기존 값과 병합 @@ -224,6 +239,16 @@ public function update(int $id, array $data) $doc->update($data); + // 수주 동기화 (order_ids가 전달된 경우만) + if ($orderIds !== null) { + $this->syncOrders($doc, $orderIds, $tenantId); + } + + // 개소별 데이터 업데이트 (시공규격, 변경사유, 검사데이터) + if (! empty($locations)) { + $this->updateLocations($doc->id, $locations); + } + $this->auditLogger->log( $doc->tenant_id, self::AUDIT_TARGET, @@ -233,7 +258,7 @@ public function update(int $id, array $data) $doc->fresh()->toArray() ); - return $this->transformToFrontend($doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations'])); + return $this->transformToFrontend($doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node'])); }); } @@ -372,6 +397,101 @@ public function availableOrders(array $params): array ->toArray(); } + /** + * 개소별 데이터 업데이트 + */ + private function updateLocations(int $docId, array $locations): void + { + foreach ($locations as $locData) { + $locId = $locData['id'] ?? null; + if (! $locId) { + continue; + } + + $location = QualityDocumentLocation::where('quality_document_id', $docId)->find($locId); + if (! $location) { + continue; + } + + $updateData = []; + if (array_key_exists('post_width', $locData)) { + $updateData['post_width'] = $locData['post_width']; + } + if (array_key_exists('post_height', $locData)) { + $updateData['post_height'] = $locData['post_height']; + } + if (array_key_exists('change_reason', $locData)) { + $updateData['change_reason'] = $locData['change_reason']; + } + if (array_key_exists('inspection_data', $locData)) { + $updateData['inspection_data'] = $locData['inspection_data']; + } + + if (! empty($updateData)) { + $location->update($updateData); + } + } + } + + /** + * 수주 동기화 (update 시 사용) + */ + private function syncOrders(QualityDocument $doc, array $orderIds, int $tenantId): void + { + $existingOrderIds = QualityDocumentOrder::where('quality_document_id', $doc->id) + ->pluck('order_id') + ->toArray(); + + $toAttach = array_diff($orderIds, $existingOrderIds); + $toDetach = array_diff($existingOrderIds, $orderIds); + + // 새로 연결 + foreach ($toAttach as $orderId) { + $order = Order::where('tenant_id', $tenantId)->find($orderId); + if (! $order) { + continue; + } + + $docOrder = QualityDocumentOrder::firstOrCreate([ + 'quality_document_id' => $doc->id, + 'order_id' => $orderId, + ]); + + // 개소(root OrderNode) 기준으로 location 생성 + $rootNodes = OrderNode::where('order_id', $orderId) + ->whereNull('parent_id') + ->orderBy('sort_order') + ->get(); + + foreach ($rootNodes as $node) { + // 각 개소의 대표 OrderItem (해당 노드 하위 첫 번째 품목) + $representativeItem = OrderItem::where('order_node_id', $node->id) + ->orderBy('sort_order') + ->first(); + + if ($representativeItem) { + QualityDocumentLocation::firstOrCreate([ + 'quality_document_id' => $doc->id, + 'quality_document_order_id' => $docOrder->id, + 'order_item_id' => $representativeItem->id, + ]); + } + } + } + + // 연결 해제 + foreach ($toDetach as $orderId) { + $docOrder = QualityDocumentOrder::where('quality_document_id', $doc->id) + ->where('order_id', $orderId) + ->first(); + + if ($docOrder) { + QualityDocumentLocation::where('quality_document_order_id', $docOrder->id)->delete(); + $docOrder->delete(); + } + } + } + /** * 수주 연결 */ @@ -397,14 +517,24 @@ public function attachOrders(int $docId, array $orderIds) 'order_id' => $orderId, ]); - // 수주 연결 시 개소(order_items)를 locations에 자동 생성 - $orderItems = OrderItem::where('order_id', $orderId)->get(); - foreach ($orderItems as $item) { - QualityDocumentLocation::firstOrCreate([ - 'quality_document_id' => $doc->id, - 'quality_document_order_id' => $docOrder->id, - 'order_item_id' => $item->id, - ]); + // 수주 연결 시 개소(root OrderNode)를 locations에 자동 생성 + $rootNodes = OrderNode::where('order_id', $orderId) + ->whereNull('parent_id') + ->orderBy('sort_order') + ->get(); + + foreach ($rootNodes as $node) { + $representativeItem = OrderItem::where('order_node_id', $node->id) + ->orderBy('sort_order') + ->first(); + + if ($representativeItem) { + QualityDocumentLocation::firstOrCreate([ + 'quality_document_id' => $doc->id, + 'quality_document_order_id' => $docOrder->id, + 'order_item_id' => $representativeItem->id, + ]); + } } } @@ -535,7 +665,7 @@ private function transformToFrontend(QualityDocument $doc, bool $detail = false) 'site_address_detail' => $options['site_address']['detail'] ?? '', ]; - // 개소 목록 + // 개소 목록 (각 location은 1개 root OrderNode = 1개 개소) $result['order_items'] = $doc->locations->map(function ($loc) { $orderItem = $loc->orderItem; $node = $orderItem?->node; @@ -544,9 +674,10 @@ private function transformToFrontend(QualityDocument $doc, bool $detail = false) return [ 'id' => (string) $loc->id, + 'order_id' => $order?->id, 'order_number' => $order?->order_no ?? '', 'site_name' => $order?->site_name ?? '', - 'delivery_date' => $order?->delivery_date ?? '', + 'delivery_date' => $order?->delivery_date ? $order->delivery_date->format('Y-m-d') : '', 'floor' => $orderItem?->floor_code ?? '', 'symbol' => $orderItem?->symbol_code ?? '', 'order_width' => $nodeOptions['width'] ?? 0, @@ -554,6 +685,7 @@ private function transformToFrontend(QualityDocument $doc, bool $detail = false) 'construction_width' => $loc->post_width ?? 0, 'construction_height' => $loc->post_height ?? 0, 'change_reason' => $loc->change_reason ?? '', + 'inspection_data' => $loc->inspection_data, ]; })->toArray(); } diff --git a/database/migrations/2026_03_06_094425_add_inspection_data_to_quality_document_locations.php b/database/migrations/2026_03_06_094425_add_inspection_data_to_quality_document_locations.php new file mode 100644 index 0000000..d3e754a --- /dev/null +++ b/database/migrations/2026_03_06_094425_add_inspection_data_to_quality_document_locations.php @@ -0,0 +1,22 @@ +json('inspection_data')->nullable()->after('change_reason')->comment('검사 데이터 JSON'); + }); + } + + public function down(): void + { + Schema::table('quality_document_locations', function (Blueprint $table) { + $table->dropColumn('inspection_data'); + }); + } +}; From ff8553055c20e407827a94002bcfab3f8b0e8d24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 6 Mar 2026 21:09:40 +0900 Subject: [PATCH 109/166] =?UTF-8?q?chore:=20[API]=20logging,=20docs,=20see?= =?UTF-8?q?der=20=EB=93=B1=20=EB=B6=80=EC=88=98=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LOGICAL_RELATIONSHIPS.md 보완 - Legacy5130Calculator 수정 - logging.php 설정 추가 - KyungdongItemSeeder 수정 - docs/INDEX.md, changes 문서 경로 수정 Co-Authored-By: Claude Opus 4.6 --- .../comprehensive-analysis-fix-2025-12-27.md | 2 +- .serena/memories/db-backup-state.md | 2 +- .../memories/quote-bom-bulk-api-phase-1.2.md | 8 ++-- LOGICAL_RELATIONSHIPS.md | 44 ++++++++++++++++++- app/Helpers/Legacy5130Calculator.php | 2 +- config/logging.php | 2 + .../seeders/Kyungdong/KyungdongItemSeeder.php | 2 +- docs/INDEX.md | 4 +- ...251230_2339_quote_calculation_mng_logic.md | 2 +- ...0260102_1300_quote_bom_bulk_calculation.md | 4 +- 10 files changed, 58 insertions(+), 14 deletions(-) diff --git a/.serena/memories/comprehensive-analysis-fix-2025-12-27.md b/.serena/memories/comprehensive-analysis-fix-2025-12-27.md index 2a9077e..659a81e 100644 --- a/.serena/memories/comprehensive-analysis-fix-2025-12-27.md +++ b/.serena/memories/comprehensive-analysis-fix-2025-12-27.md @@ -54,4 +54,4 @@ ## 관련 파일 - `api/app/Services/ComprehensiveAnalysisService.php` - `api/database/seeders/ComprehensiveAnalysisSeeder.php` -- `docs/plans/react-mock-remaining-tasks.md` +- `docs/dev/dev_plans/react-mock-remaining-tasks.md` diff --git a/.serena/memories/db-backup-state.md b/.serena/memories/db-backup-state.md index 3970856..e872408 100644 --- a/.serena/memories/db-backup-state.md +++ b/.serena/memories/db-backup-state.md @@ -15,7 +15,7 @@ ## Phase 구성 - Phase 5: MNG 관리자 패널 (4항목) — mng/ /system/alerts ## 핵심 파일 -- 계획 문서: docs/plans/db-backup-system-plan.md +- 계획 문서: docs/dev/dev_plans/db-backup-system-plan.md - 개발서버: 114.203.209.83 (SSH: hskwon) - DB: sam (메인) + sam_stat (통계) - Slack 웹훅: api/.env → LOG_SLACK_WEBHOOK_URL (이미 설정됨) diff --git a/.serena/memories/quote-bom-bulk-api-phase-1.2.md b/.serena/memories/quote-bom-bulk-api-phase-1.2.md index c212391..db4e17e 100644 --- a/.serena/memories/quote-bom-bulk-api-phase-1.2.md +++ b/.serena/memories/quote-bom-bulk-api-phase-1.2.md @@ -16,7 +16,7 @@ ### 생성된 파일 | 파일 | 설명 | |------|------| | `app/Http/Requests/Quote/QuoteBomBulkCalculateRequest.php` | 다건 BOM 산출 FormRequest | -| `api/docs/changes/20260102_1300_quote_bom_bulk_calculation.md` | 변경 내용 문서 | +| `api/docs/dev/changes/20260102_1300_quote_bom_bulk_calculation.md` | 변경 내용 문서 | ### 수정된 파일 | 파일 | 설명 | @@ -93,9 +93,9 @@ ### QuoteCalculationService::calculateBomBulk() - 개별 품목 실패가 전체에 영향 없음 (예외 처리) ## 관련 문서 -- 계획 문서: `docs/plans/quote-calculation-api-plan.md` -- Phase 1.1 문서: `docs/changes/20260102_quote_bom_calculation_api.md` -- Phase 1.2 문서: `docs/changes/20260102_1300_quote_bom_bulk_calculation.md` +- 계획 문서: `docs/dev/dev_plans/quote-calculation-api-plan.md` +- Phase 1.1 문서: `docs/dev/changes/20260102_quote_bom_calculation_api.md` +- Phase 1.2 문서: `docs/dev/changes/20260102_1300_quote_bom_bulk_calculation.md` ## 다음 단계 - React 프론트엔드에서 `/calculate/bom/bulk` API 연동 diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 1cf50df..a8ccbb8 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-03-04 22:33:37 +> **자동 생성**: 2026-03-06 15:12:45 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -753,6 +753,38 @@ ### lot_sales - **lot()**: belongsTo → `lots` +### performance_reports +**모델**: `App\Models\Qualitys\PerformanceReport` + +- **qualityDocument()**: belongsTo → `quality_documents` +- **confirmer()**: belongsTo → `users` +- **creator()**: belongsTo → `users` + +### quality_documents +**모델**: `App\Models\Qualitys\QualityDocument` + +- **client()**: belongsTo → `clients` +- **inspector()**: belongsTo → `users` +- **creator()**: belongsTo → `users` +- **documentOrders()**: hasMany → `quality_document_orders` +- **locations()**: hasMany → `quality_document_locations` +- **performanceReport()**: hasOne → `performance_reports` + +### quality_document_locations +**모델**: `App\Models\Qualitys\QualityDocumentLocation` + +- **qualityDocument()**: belongsTo → `quality_documents` +- **qualityDocumentOrder()**: belongsTo → `quality_document_orders` +- **orderItem()**: belongsTo → `order_items` +- **document()**: belongsTo → `documents` + +### quality_document_orders +**모델**: `App\Models\Qualitys\QualityDocumentOrder` + +- **qualityDocument()**: belongsTo → `quality_documents` +- **order()**: belongsTo → `orders` +- **locations()**: hasMany → `quality_document_locations` + ### quotes **모델**: `App\Models\Quote\Quote` @@ -929,6 +961,16 @@ ### expense_accounts - **vendor()**: belongsTo → `clients` +### journal_entrys +**모델**: `App\Models\Tenants\JournalEntry` + +- **lines()**: hasMany → `journal_entry_lines` + +### journal_entry_lines +**모델**: `App\Models\Tenants\JournalEntryLine` + +- **journalEntry()**: belongsTo → `journal_entries` + ### leaves **모델**: `App\Models\Tenants\Leave` diff --git a/app/Helpers/Legacy5130Calculator.php b/app/Helpers/Legacy5130Calculator.php index 1d8c59e..5c2f723 100644 --- a/app/Helpers/Legacy5130Calculator.php +++ b/app/Helpers/Legacy5130Calculator.php @@ -14,7 +14,7 @@ * - 두께 매핑 (normalizeThickness) * - 면적 계산 (calculateArea) * - * @see docs/plans/5130-sam-data-migration-plan.md 섹션 4.5 + * @see docs/dev_plans/5130-sam-data-migration-plan.md 섹션 4.5 */ class Legacy5130Calculator { diff --git a/config/logging.php b/config/logging.php index 4ed83fc..7955199 100644 --- a/config/logging.php +++ b/config/logging.php @@ -67,6 +67,7 @@ 'daily' => [ 'driver' => 'daily', + 'permission' => 0664, 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'days' => env('LOG_DAILY_DAYS', 14), @@ -138,6 +139,7 @@ */ 'api' => [ 'driver' => 'daily', + 'permission' => 0664, 'path' => storage_path('logs/api/api.log'), 'level' => 'info', 'days' => env('API_LOG_DAYS', 14), diff --git a/database/seeders/Kyungdong/KyungdongItemSeeder.php b/database/seeders/Kyungdong/KyungdongItemSeeder.php index 3c0f707..ef9de1b 100644 --- a/database/seeders/Kyungdong/KyungdongItemSeeder.php +++ b/database/seeders/Kyungdong/KyungdongItemSeeder.php @@ -17,7 +17,7 @@ * 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 + * @see docs/dev_plans/kd-items-migration-plan.md */ class KyungdongItemSeeder extends Seeder { diff --git a/docs/INDEX.md b/docs/INDEX.md index 948fb65..b80a841 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -37,7 +37,7 @@ ## 🔗 프로젝트 문서 (SAM/docs로 이동됨) | 이전 위치 | 새 위치 | 설명 | |-----------|---------|------| -| `analysis/` | `docs/data/analysis/` | Item DB 분석 문서 | +| `analysis/` | `docs/dev/data/analysis/` | Item DB 분석 문서 | | `front/` | `docs/front/` | 프론트엔드 요청 문서 | | `specs/` | `docs/specs/` | 기능 스펙 문서 | @@ -49,6 +49,6 @@ ## 📝 문서 추가 가이드 |-----------|-----------| | Swagger 스펙 | `api/docs/swagger/` | | API Flow 테스트 | `api/docs/api-flows/` 또는 `flow-tests/` | -| 프로젝트 분석 | `SAM/docs/data/` | +| 프로젝트 분석 | `SAM/docs/dev/data/` | | 기능 스펙 | `SAM/docs/specs/` | | 프론트 요청 | `SAM/docs/front/` | diff --git a/docs/changes/20251230_2339_quote_calculation_mng_logic.md b/docs/changes/20251230_2339_quote_calculation_mng_logic.md index ba01653..7571199 100644 --- a/docs/changes/20251230_2339_quote_calculation_mng_logic.md +++ b/docs/changes/20251230_2339_quote_calculation_mng_logic.md @@ -74,5 +74,5 @@ ## ⚠️ 배포 시 주의사항 - 테넌트별 초기 데이터 설정 필요 ## 🔗 관련 문서 -- `docs/plans/quote-calculation-api-plan.md` +- `docs/dev/dev_plans/quote-calculation-api-plan.md` - `mng/app/Services/Quote/FormulaEvaluatorService.php` (원본) diff --git a/docs/changes/20260102_1300_quote_bom_bulk_calculation.md b/docs/changes/20260102_1300_quote_bom_bulk_calculation.md index a2fdb9c..8f5f65a 100644 --- a/docs/changes/20260102_1300_quote_bom_bulk_calculation.md +++ b/docs/changes/20260102_1300_quote_bom_bulk_calculation.md @@ -2,7 +2,7 @@ # 변경 내용 요약 **날짜:** 2026-01-02 13:00 **작업명:** Phase 1.2 입력 변수 처리 - React QuoteItem 매핑 -**계획 문서:** docs/plans/quote-calculation-api-plan.md +**계획 문서:** docs/dev/dev_plans/quote-calculation-api-plan.md ## 변경 개요 @@ -151,4 +151,4 @@ ## API 사용 예시 ## 관련 문서 - Phase 1.1: `20251230_2339_quote_calculation_mng_logic.md` (BOM 단건 산출) -- 계획 문서: `docs/plans/quote-calculation-api-plan.md` \ No newline at end of file +- 계획 문서: `docs/dev/dev_plans/quote-calculation-api-plan.md` \ No newline at end of file From 2231c9a48f9f931847329ec57f347da8cdf04086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 6 Mar 2026 21:42:51 +0900 Subject: [PATCH 110/166] =?UTF-8?q?feat:=20=EC=A0=9C=ED=92=88=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=EC=9A=94=EC=B2=AD=EC=84=9C=20Document(EAV)=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - document_template_sections에 description 컬럼 추가 (마이그레이션) - DocumentTemplateSection 모델에 description fillable 추가 - QualityDocumentService에 syncRequestDocument() 메서드 추가 - quality_document 생성/수정/수주연결 시 요청서 Document 자동생성 - 기본필드, 섹션 데이터, 사전고지 테이블 EAV 자동매핑 - rendered_html 초기화 (데이터 변경 시 재캡처 트리거) - transformToFrontend에 request_document_id 포함 Co-Authored-By: Claude Opus 4.6 --- .../Documents/DocumentTemplateSection.php | 1 + app/Services/QualityDocumentService.php | 257 +++++++++++++++++- ...cription_to_document_template_sections.php | 22 ++ 3 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 database/migrations/2026_03_06_212500_add_description_to_document_template_sections.php diff --git a/app/Models/Documents/DocumentTemplateSection.php b/app/Models/Documents/DocumentTemplateSection.php index 1346f35..de149e6 100644 --- a/app/Models/Documents/DocumentTemplateSection.php +++ b/app/Models/Documents/DocumentTemplateSection.php @@ -22,6 +22,7 @@ class DocumentTemplateSection extends Model protected $fillable = [ 'template_id', 'title', + 'description', 'image_path', 'sort_order', ]; diff --git a/app/Services/QualityDocumentService.php b/app/Services/QualityDocumentService.php index 1d2336a..74a3829 100644 --- a/app/Services/QualityDocumentService.php +++ b/app/Services/QualityDocumentService.php @@ -9,6 +9,9 @@ use App\Models\Qualitys\QualityDocument; use App\Models\Qualitys\QualityDocumentLocation; use App\Models\Qualitys\QualityDocumentOrder; +use App\Models\Documents\Document; +use App\Models\Documents\DocumentData; +use App\Models\Documents\DocumentTemplate; use App\Services\Audit\AuditLogger; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -204,7 +207,12 @@ public function store(array $data) $doc->toArray() ); - return $this->transformToFrontend($doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node'])); + $doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node']); + + // 요청서 Document(EAV) 자동생성 + $this->syncRequestDocument($doc); + + return $this->transformToFrontend($doc); }); } @@ -258,7 +266,12 @@ public function update(int $id, array $data) $doc->fresh()->toArray() ); - return $this->transformToFrontend($doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node'])); + $doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node']); + + // 요청서 Document(EAV) 동기화 + $this->syncRequestDocument($doc); + + return $this->transformToFrontend($doc); }); } @@ -543,9 +556,12 @@ public function attachOrders(int $docId, array $orderIds) $doc->update(['status' => QualityDocument::STATUS_IN_PROGRESS]); } - return $this->transformToFrontend( - $doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node']) - ); + $doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node']); + + // 요청서 Document(EAV) 동기화 (개소 추가됨) + $this->syncRequestDocument($doc); + + return $this->transformToFrontend($doc); }); } @@ -631,6 +647,14 @@ private function transformToFrontend(QualityDocument $doc, bool $detail = false) 'manager_contact' => $options['manager']['phone'] ?? '', ]; + // 요청서 Document ID (EAV) + $requestDoc = Document::where('tenant_id', $doc->tenant_id) + ->where('template_id', self::REQUEST_TEMPLATE_ID) + ->where('linkable_type', QualityDocument::class) + ->where('linkable_id', $doc->id) + ->first(); + $result['request_document_id'] = $requestDoc?->id; + if ($detail) { $result['construction_site'] = [ 'site_name' => $options['construction_site']['name'] ?? '', @@ -867,6 +891,229 @@ public function resultDocument(int $id): array ]; } + // ========================================================================= + // 제품검사 요청서 Document 자동생성/동기화 + // ========================================================================= + + private const REQUEST_TEMPLATE_ID = 66; + + /** + * 요청서 Document(EAV) 동기화 + * + * quality_document 생성/수정 시 호출. + * - Document 없으면 생성 (template_id=66, linkable=QualityDocument) + * - 기본필드 + 섹션 데이터 + 사전고지 테이블을 EAV로 매핑 + */ + private function syncRequestDocument(QualityDocument $doc): void + { + $tenantId = $doc->tenant_id; + + // 템플릿 존재 확인 + $template = DocumentTemplate::where('tenant_id', $tenantId) + ->where('id', self::REQUEST_TEMPLATE_ID) + ->with(['basicFields', 'sections.items', 'columns']) + ->first(); + + if (! $template) { + return; // 템플릿 미등록 시 스킵 + } + + // 기존 Document 조회 또는 생성 + $document = Document::where('tenant_id', $tenantId) + ->where('template_id', self::REQUEST_TEMPLATE_ID) + ->where('linkable_type', QualityDocument::class) + ->where('linkable_id', $doc->id) + ->first(); + + if (! $document) { + $documentNo = $this->generateRequestDocumentNo($tenantId); + $document = Document::create([ + 'tenant_id' => $tenantId, + 'template_id' => self::REQUEST_TEMPLATE_ID, + 'document_no' => $documentNo, + 'title' => '제품검사 요청서 - ' . ($doc->site_name ?? $doc->quality_doc_number), + 'status' => Document::STATUS_DRAFT, + 'linkable_type' => QualityDocument::class, + 'linkable_id' => $doc->id, + 'created_by' => $doc->created_by, + 'updated_by' => $doc->updated_by ?? $doc->created_by, + ]); + } else { + // rendered_html 초기화 (데이터 변경 시 재캡처 필요) + $document->update([ + 'rendered_html' => null, + 'updated_by' => $doc->updated_by ?? $doc->created_by, + ]); + } + + // 기존 EAV 데이터 삭제 후 재생성 + DocumentData::where('document_id', $document->id)->delete(); + + $options = $doc->options ?? []; + $eavData = []; + + // 1. 기본필드 매핑 (quality_document → basicFields) + $fieldMapping = $this->buildBasicFieldMapping($doc, $options); + foreach ($template->basicFields as $bf) { + $value = $fieldMapping[$bf->field_key] ?? ''; + if ($value !== '') { + $eavData[] = [ + 'document_id' => $document->id, + 'section_id' => null, + 'column_id' => null, + 'row_index' => 0, + 'field_key' => $bf->field_key, + 'field_value' => (string) $value, + ]; + } + } + + // 2. 섹션 아이템 매핑 (options → section items) + $sectionMapping = $this->buildSectionMapping($options); + foreach ($template->sections as $section) { + if ($section->items->isEmpty()) { + continue; // 사전 고지 정보 섹션은 items가 없으므로 스킵 + } + $sectionData = $sectionMapping[$section->title] ?? []; + foreach ($section->items as $item) { + $value = $sectionData[$item->item] ?? ''; + if ($value !== '') { + $eavData[] = [ + 'document_id' => $document->id, + 'section_id' => $section->id, + 'column_id' => null, + 'row_index' => 0, + 'field_key' => $item->item, // item name as key + 'field_value' => (string) $value, + ]; + } + } + } + + // 3. 사전고지 테이블 매핑 (locations → columns) + $doc->loadMissing(['locations.orderItem.node', 'locations.qualityDocumentOrder.order']); + $columns = $template->columns->sortBy('sort_order'); + + foreach ($doc->locations as $rowIdx => $loc) { + $orderItem = $loc->orderItem; + $node = $orderItem?->node; + $nodeOptions = $node?->options ?? []; + $order = $loc->qualityDocumentOrder?->order; + + $rowData = [ + 'No.' => (string) ($rowIdx + 1), + '층수' => $orderItem?->floor_code ?? '', + '부호' => $orderItem?->symbol_code ?? '', + '발주 가로' => (string) ($nodeOptions['width'] ?? ''), + '발주 세로' => (string) ($nodeOptions['height'] ?? ''), + '시공 가로' => (string) ($loc->post_width ?? ''), + '시공 세로' => (string) ($loc->post_height ?? ''), + '변경사유' => $loc->change_reason ?? '', + ]; + + foreach ($columns as $col) { + $value = $rowData[$col->label] ?? ''; + $eavData[] = [ + 'document_id' => $document->id, + 'section_id' => null, + 'column_id' => $col->id, + 'row_index' => $rowIdx, + 'field_key' => $col->label, + 'field_value' => $value, + ]; + } + } + + // EAV 일괄 삽입 + if (! empty($eavData)) { + DocumentData::insert(array_map(function ($d) { + $d['created_at'] = now(); + $d['updated_at'] = now(); + return $d; + }, $eavData)); + } + } + + /** + * 기본필드 매핑 (quality_document → template basicFields) + */ + private function buildBasicFieldMapping(QualityDocument $doc, array $options): array + { + $manager = $options['manager'] ?? []; + $inspection = $options['inspection'] ?? []; + $siteAddress = $options['site_address'] ?? []; + $order = $doc->documentOrders?->first()?->order; + + return [ + 'client' => $doc->client?->name ?? '', + 'company_name' => $manager['company'] ?? '', + 'manager' => $manager['name'] ?? '', + 'order_number' => $order?->order_no ?? '', + 'manager_contact' => $manager['phone'] ?? '', + 'site_name' => $doc->site_name ?? '', + 'delivery_date' => $order?->delivery_date?->format('Y-m-d') ?? '', + 'site_address' => trim(($siteAddress['address'] ?? '') . ' ' . ($siteAddress['detail'] ?? '')), + 'total_locations' => (string) ($doc->locations?->count() ?? 0), + 'receipt_date' => $doc->received_date?->format('Y-m-d') ?? '', + 'inspection_request_date' => $inspection['request_date'] ?? '', + ]; + } + + /** + * 섹션 데이터 매핑 (options → section items by section title) + */ + private function buildSectionMapping(array $options): array + { + $cs = $options['construction_site'] ?? []; + $md = $options['material_distributor'] ?? []; + $ct = $options['contractor'] ?? []; + $sv = $options['supervisor'] ?? []; + + return [ + '건축공사장 정보' => [ + '현장명' => $cs['name'] ?? '', + '대지위치' => $cs['land_location'] ?? '', + '지번' => $cs['lot_number'] ?? '', + ], + '자재유통업자 정보' => [ + '회사명' => $md['company'] ?? '', + '주소' => $md['address'] ?? '', + '대표자' => $md['ceo'] ?? '', + '전화번호' => $md['phone'] ?? '', + ], + '공사시공자 정보' => [ + '회사명' => $ct['company'] ?? '', + '주소' => $ct['address'] ?? '', + '성명' => $ct['name'] ?? '', + '전화번호' => $ct['phone'] ?? '', + ], + '공사감리자 정보' => [ + '사무소명' => $sv['office'] ?? '', + '주소' => $sv['address'] ?? '', + '성명' => $sv['name'] ?? '', + '전화번호' => $sv['phone'] ?? '', + ], + ]; + } + + /** + * 요청서 문서번호 생성 + */ + private function generateRequestDocumentNo(int $tenantId): string + { + $prefix = 'REQ'; + $date = now()->format('Ymd'); + + $lastNumber = Document::where('tenant_id', $tenantId) + ->where('document_no', 'like', "{$prefix}-{$date}-%") + ->orderByDesc('document_no') + ->value('document_no'); + + $sequence = $lastNumber ? (int) substr($lastNumber, -4) + 1 : 1; + + return sprintf('%s-%s-%04d', $prefix, $date, $sequence); + } + private function formatInspectionPeriod(array $options): string { $inspection = $options['inspection'] ?? []; diff --git a/database/migrations/2026_03_06_212500_add_description_to_document_template_sections.php b/database/migrations/2026_03_06_212500_add_description_to_document_template_sections.php new file mode 100644 index 0000000..50b5e71 --- /dev/null +++ b/database/migrations/2026_03_06_212500_add_description_to_document_template_sections.php @@ -0,0 +1,22 @@ +text('description')->nullable()->after('title')->comment('섹션 설명/안내문'); + }); + } + + public function down(): void + { + Schema::table('document_template_sections', function (Blueprint $table) { + $table->dropColumn('description'); + }); + } +}; From 3ac64d5b76db2de82a98d636b696bee0cf0df1a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 7 Mar 2026 01:19:04 +0900 Subject: [PATCH 111/166] =?UTF-8?q?feat:=20[=ED=92=88=EC=A7=88=EA=B2=80?= =?UTF-8?q?=EC=82=AC]=20=EC=88=98=EC=A3=BC=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EB=A7=81=20+=20=EA=B0=9C=EC=86=8C=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20+=20=EA=B2=80=EC=82=AC=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - availableOrders: client_id/item_id 필터 파라미터 지원 - availableOrders: 응답에 client_id, client_name, item_id, item_name, locations(개소 상세) 추가 - show: 개소별 데이터에 거래처/모델 정보 포함 - DocumentService: fqcStatus rootNodes 기반으로 변경 Co-Authored-By: Claude Opus 4.6 --- LOGICAL_RELATIONSHIPS.md | 2 +- app/Services/DocumentService.php | 36 +++++++++++----- app/Services/QualityDocumentService.php | 56 +++++++++++++++++++++---- 3 files changed, 73 insertions(+), 21 deletions(-) diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index a8ccbb8..6fc90b6 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-03-06 15:12:45 +> **자동 생성**: 2026-03-06 21:25:05 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 diff --git a/app/Services/DocumentService.php b/app/Services/DocumentService.php index 82783b6..ed25399 100644 --- a/app/Services/DocumentService.php +++ b/app/Services/DocumentService.php @@ -663,20 +663,32 @@ public function fqcStatus(int $orderId, int $templateId): array $tenantId = $this->tenantId(); $order = \App\Models\Orders\Order::where('tenant_id', $tenantId) - ->with('items') + ->with(['rootNodes.items' => fn ($q) => $q->orderBy('sort_order')]) ->findOrFail($orderId); - // 해당 수주의 FQC 문서 조회 + // 개소별 대표 OrderItem ID 수집 + $representativeItemIds = $order->rootNodes + ->map(fn ($node) => $node->items->first()?->id) + ->filter() + ->values(); + + // 해당 대표 품목의 FQC 문서 조회 $documents = Document::where('tenant_id', $tenantId) ->where('template_id', $templateId) ->where('linkable_type', \App\Models\Orders\OrderItem::class) - ->whereIn('linkable_id', $order->items->pluck('id')) + ->whereIn('linkable_id', $representativeItemIds) ->with('data') ->get() ->keyBy('linkable_id'); - $items = $order->items->map(function ($orderItem) use ($documents) { - $doc = $documents->get($orderItem->id); + // 개소(root node)별 진행현황 + $items = $order->rootNodes->map(function ($node) use ($documents) { + $representativeItem = $node->items->first(); + if (! $representativeItem) { + return null; + } + + $doc = $documents->get($representativeItem->id); // 종합판정 값 추출 $judgement = null; @@ -686,17 +698,17 @@ public function fqcStatus(int $orderId, int $templateId): array } return [ - 'order_item_id' => $orderItem->id, - 'floor_code' => $orderItem->floor_code, - 'symbol_code' => $orderItem->symbol_code, - 'specification' => $orderItem->specification, - 'item_name' => $orderItem->item_name, + 'order_item_id' => $representativeItem->id, + 'floor_code' => $representativeItem->floor_code, + 'symbol_code' => $representativeItem->symbol_code, + 'specification' => $representativeItem->specification, + 'item_name' => $representativeItem->item_name, 'document_id' => $doc?->id, 'document_no' => $doc?->document_no, 'status' => $doc?->status ?? 'NONE', 'judgement' => $judgement, ]; - }); + })->filter()->values(); // 통계 $total = $items->count(); @@ -889,6 +901,7 @@ public function upsert(array $data): Document if (isset($data['rendered_html'])) { $updatePayload['rendered_html'] = $data['rendered_html']; } + return $this->update($existingDocument->id, $updatePayload); } @@ -904,6 +917,7 @@ public function upsert(array $data): Document if (isset($data['rendered_html'])) { $createPayload['rendered_html'] = $data['rendered_html']; } + return $this->create($createPayload); }); } diff --git a/app/Services/QualityDocumentService.php b/app/Services/QualityDocumentService.php index 74a3829..e3142cd 100644 --- a/app/Services/QualityDocumentService.php +++ b/app/Services/QualityDocumentService.php @@ -2,6 +2,9 @@ namespace App\Services; +use App\Models\Documents\Document; +use App\Models\Documents\DocumentData; +use App\Models\Documents\DocumentTemplate; use App\Models\Orders\Order; use App\Models\Orders\OrderItem; use App\Models\Orders\OrderNode; @@ -9,9 +12,6 @@ use App\Models\Qualitys\QualityDocument; use App\Models\Qualitys\QualityDocumentLocation; use App\Models\Qualitys\QualityDocumentOrder; -use App\Models\Documents\Document; -use App\Models\Documents\DocumentData; -use App\Models\Documents\DocumentTemplate; use App\Services\Audit\AuditLogger; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -377,6 +377,8 @@ public function availableOrders(array $params): array { $tenantId = $this->tenantId(); $q = trim((string) ($params['q'] ?? '')); + $clientId = $params['client_id'] ?? null; + $itemId = $params['item_id'] ?? null; // 이미 연결된 수주 ID 목록 $linkedOrderIds = QualityDocumentOrder::whereHas('qualityDocument', function ($query) use ($tenantId) { @@ -385,6 +387,12 @@ public function availableOrders(array $params): array $query = Order::where('tenant_id', $tenantId) ->whereNotIn('id', $linkedOrderIds) + ->with(['item:id,name', 'nodes' => function ($q) { + $q->whereNull('parent_id')->orderBy('sort_order') + ->with(['items' => function ($q2) { + $q2->orderBy('sort_order')->limit(1); + }]); + }]) ->withCount(['nodes as location_count' => function ($q) { $q->whereNull('parent_id'); }]); @@ -396,6 +404,16 @@ public function availableOrders(array $params): array }); } + // 같은 거래처(발주처) 필터 + if ($clientId) { + $query->where('client_id', $clientId); + } + + // 같은 모델(품목) 필터 + if ($itemId) { + $query->where('item_id', $itemId); + } + return $query->orderByDesc('id') ->limit(50) ->get() @@ -403,9 +421,24 @@ public function availableOrders(array $params): array 'id' => $order->id, 'order_number' => $order->order_no, 'site_name' => $order->site_name ?? '', + 'client_id' => $order->client_id, 'client_name' => $order->client_name ?? '', + 'item_id' => $order->item_id, + 'item_name' => $order->item?->name ?? '', 'delivery_date' => $order->delivery_date?->format('Y-m-d') ?? '', 'location_count' => $order->location_count, + 'locations' => $order->nodes->where('parent_id', null)->map(function ($node) { + $item = $node->items->first(); + $options = $node->options ?? []; + + return [ + 'node_id' => $node->id, + 'floor' => $item?->floor_code ?? $node->code ?? '', + 'symbol' => $item?->symbol_code ?? '', + 'order_width' => $options['width'] ?? 0, + 'order_height' => $options['height'] ?? 0, + ]; + })->values()->toArray(), ]) ->toArray(); } @@ -701,6 +734,10 @@ private function transformToFrontend(QualityDocument $doc, bool $detail = false) 'order_id' => $order?->id, 'order_number' => $order?->order_no ?? '', 'site_name' => $order?->site_name ?? '', + 'client_id' => $order?->client_id, + 'client_name' => $order?->client_name ?? '', + 'item_id' => $order?->item_id, + 'item_name' => $order?->item?->name ?? '', 'delivery_date' => $order?->delivery_date ? $order->delivery_date->format('Y-m-d') : '', 'floor' => $orderItem?->floor_code ?? '', 'symbol' => $orderItem?->symbol_code ?? '', @@ -710,6 +747,7 @@ private function transformToFrontend(QualityDocument $doc, bool $detail = false) 'construction_height' => $loc->post_height ?? 0, 'change_reason' => $loc->change_reason ?? '', 'inspection_data' => $loc->inspection_data, + 'document_id' => $loc->document_id, ]; })->toArray(); } @@ -729,10 +767,6 @@ public function inspectLocation(int $docId, int $locId, array $data) throw new NotFoundHttpException(__('error.not_found')); } - if ($doc->isCompleted()) { - throw new BadRequestHttpException(__('error.quality.cannot_modify_completed')); - } - $location = QualityDocumentLocation::where('quality_document_id', $docId)->find($locId); if (! $location) { throw new NotFoundHttpException(__('error.not_found')); @@ -753,6 +787,9 @@ public function inspectLocation(int $docId, int $locId, array $data) if (isset($data['inspection_status'])) { $updateData['inspection_status'] = $data['inspection_status']; } + if (array_key_exists('inspection_data', $data)) { + $updateData['inspection_data'] = $data['inspection_data']; + } if (! empty($updateData)) { $location->update($updateData); @@ -931,7 +968,7 @@ private function syncRequestDocument(QualityDocument $doc): void 'tenant_id' => $tenantId, 'template_id' => self::REQUEST_TEMPLATE_ID, 'document_no' => $documentNo, - 'title' => '제품검사 요청서 - ' . ($doc->site_name ?? $doc->quality_doc_number), + 'title' => '제품검사 요청서 - '.($doc->site_name ?? $doc->quality_doc_number), 'status' => Document::STATUS_DRAFT, 'linkable_type' => QualityDocument::class, 'linkable_id' => $doc->id, @@ -1029,6 +1066,7 @@ private function syncRequestDocument(QualityDocument $doc): void DocumentData::insert(array_map(function ($d) { $d['created_at'] = now(); $d['updated_at'] = now(); + return $d; }, $eavData)); } @@ -1052,7 +1090,7 @@ private function buildBasicFieldMapping(QualityDocument $doc, array $options): a 'manager_contact' => $manager['phone'] ?? '', 'site_name' => $doc->site_name ?? '', 'delivery_date' => $order?->delivery_date?->format('Y-m-d') ?? '', - 'site_address' => trim(($siteAddress['address'] ?? '') . ' ' . ($siteAddress['detail'] ?? '')), + 'site_address' => trim(($siteAddress['address'] ?? '').' '.($siteAddress['detail'] ?? '')), 'total_locations' => (string) ($doc->locations?->count() ?? 0), 'receipt_date' => $doc->received_date?->format('Y-m-d') ?? '', 'inspection_request_date' => $inspection['request_date'] ?? '', From 0044779eb403883d93c7ee87da36c664dd7f35b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Sun, 8 Mar 2026 10:32:20 +0900 Subject: [PATCH 112/166] =?UTF-8?q?feat:=20[finance]=20=EA=B3=84=EC=A0=95?= =?UTF-8?q?=EA=B3=BC=EB=AA=A9=20=ED=99=95=EC=9E=A5=20=EB=B0=8F=20=EC=A0=84?= =?UTF-8?q?=ED=91=9C=20=EC=97=B0=EB=8F=99=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AccountCode 모델/서비스 확장 (업데이트, 기본 계정과목 시딩) - JournalSyncService 추가 (전표 자동 연동) - SyncsExpenseAccounts 트레이트 추가 - CardTransactionController, TaxInvoiceController 기능 확장 - expense_accounts 테이블에 전표 연결 컬럼 마이그레이션 - account_codes 테이블 확장 마이그레이션 - 전체 테넌트 기본 계정과목 시딩 마이그레이션 Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/AccountSubjectController.php | 29 +- .../Api/V1/CardTransactionController.php | 106 +++++- .../Api/V1/TaxInvoiceController.php | 78 ++++- .../StoreAccountSubjectRequest.php | 8 + .../UpdateAccountSubjectRequest.php | 36 +++ app/Models/Tenants/AccountCode.php | 55 +++- app/Models/Tenants/ExpenseAccount.php | 2 + app/Models/Tenants/JournalEntry.php | 2 + app/Services/AccountCodeService.php | 303 +++++++++++++++++- app/Services/GeneralJournalEntryService.php | 14 + app/Services/JournalSyncService.php | 214 +++++++++++++ app/Traits/SyncsExpenseAccounts.php | 98 ++++++ ...journal_link_to_expense_accounts_table.php | 28 ++ ..._06_220000_enhance_account_codes_table.php | 54 ++++ ..._default_account_codes_for_all_tenants.php | 218 +++++++++++++ routes/api/v1/finance.php | 8 + 16 files changed, 1247 insertions(+), 6 deletions(-) create mode 100644 app/Http/Requests/V1/AccountSubject/UpdateAccountSubjectRequest.php create mode 100644 app/Services/JournalSyncService.php create mode 100644 app/Traits/SyncsExpenseAccounts.php create mode 100644 database/migrations/2026_03_06_210000_add_journal_link_to_expense_accounts_table.php create mode 100644 database/migrations/2026_03_06_220000_enhance_account_codes_table.php create mode 100644 database/migrations/2026_03_06_220000_seed_default_account_codes_for_all_tenants.php diff --git a/app/Http/Controllers/Api/V1/AccountSubjectController.php b/app/Http/Controllers/Api/V1/AccountSubjectController.php index 20af5a2..4f8f094 100644 --- a/app/Http/Controllers/Api/V1/AccountSubjectController.php +++ b/app/Http/Controllers/Api/V1/AccountSubjectController.php @@ -5,6 +5,7 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Http\Requests\V1\AccountSubject\StoreAccountSubjectRequest; +use App\Http\Requests\V1\AccountSubject\UpdateAccountSubjectRequest; use App\Services\AccountCodeService; use Illuminate\Http\Request; @@ -19,7 +20,10 @@ public function __construct( */ public function index(Request $request) { - $params = $request->only(['search', 'category']); + $params = $request->only([ + 'search', 'category', 'sub_category', + 'department_type', 'depth', 'is_active', 'selectable', + ]); $subjects = $this->service->index($params); @@ -36,6 +40,16 @@ public function store(StoreAccountSubjectRequest $request) return ApiResponse::success($subject, __('message.created'), [], 201); } + /** + * 계정과목 수정 + */ + public function update(int $id, UpdateAccountSubjectRequest $request) + { + $subject = $this->service->update($id, $request->validated()); + + return ApiResponse::success($subject, __('message.updated')); + } + /** * 계정과목 활성/비활성 토글 */ @@ -57,4 +71,17 @@ public function destroy(int $id) return ApiResponse::success(null, __('message.deleted')); } + + /** + * 기본 계정과목표 일괄 생성 (더존 표준) + */ + public function seedDefaults() + { + $count = $this->service->seedDefaults(); + + return ApiResponse::success( + ['inserted_count' => $count], + __('message.created') + ); + } } diff --git a/app/Http/Controllers/Api/V1/CardTransactionController.php b/app/Http/Controllers/Api/V1/CardTransactionController.php index 25a457a..73a880a 100644 --- a/app/Http/Controllers/Api/V1/CardTransactionController.php +++ b/app/Http/Controllers/Api/V1/CardTransactionController.php @@ -4,7 +4,9 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Models\Tenants\JournalEntry; use App\Services\CardTransactionService; +use App\Services\JournalSyncService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -14,7 +16,8 @@ class CardTransactionController extends Controller { public function __construct( - protected CardTransactionService $service + protected CardTransactionService $service, + protected JournalSyncService $journalSyncService, ) {} /** @@ -148,4 +151,105 @@ public function destroy(int $id): JsonResponse return $this->service->destroy($id); }, __('message.deleted')); } + + // ========================================================================= + // 분개 (Journal Entries) + // ========================================================================= + + /** + * 카드 거래 분개 조회 + */ + public function getJournalEntries(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $sourceKey = "card_{$id}"; + $data = $this->journalSyncService->getForSource( + JournalEntry::SOURCE_CARD_TRANSACTION, + $sourceKey + ); + + if (! $data) { + return ['items' => []]; + } + + // 프론트엔드가 기대하는 items 형식으로 변환 + $items = array_map(fn ($row) => [ + 'id' => $row['id'], + 'supply_amount' => $row['debit_amount'], + 'tax_amount' => 0, + 'account_code' => $row['account_code'], + 'deduction_type' => 'deductible', + 'vendor_name' => $row['vendor_name'], + 'description' => $row['memo'], + 'memo' => '', + ], $data['rows']); + + return ['items' => $items]; + }, __('message.fetched')); + } + + /** + * 카드 거래 분개 저장 + */ + public function storeJournalEntries(Request $request, int $id): JsonResponse + { + return ApiResponse::handle(function () use ($request, $id) { + $validated = $request->validate([ + 'items' => 'required|array|min:1', + 'items.*.supply_amount' => 'required|integer|min:0', + 'items.*.tax_amount' => 'required|integer|min:0', + 'items.*.account_code' => 'required|string|max:20', + 'items.*.deduction_type' => 'nullable|string|max:20', + 'items.*.vendor_name' => 'nullable|string|max:200', + 'items.*.description' => 'nullable|string|max:500', + 'items.*.memo' => 'nullable|string|max:500', + ]); + + // 카드 거래 정보 조회 (날짜용) + $transaction = $this->service->show($id); + if (! $transaction) { + throw new \Illuminate\Database\Eloquent\ModelNotFoundException; + } + + $entryDate = $transaction->used_at + ? $transaction->used_at->format('Y-m-d') + : ($transaction->withdrawal_date?->format('Y-m-d') ?? now()->format('Y-m-d')); + + // items → journal rows 변환 (각 item을 차변 행으로) + $rows = []; + foreach ($validated['items'] as $item) { + $amount = ($item['supply_amount'] ?? 0) + ($item['tax_amount'] ?? 0); + $rows[] = [ + 'side' => 'debit', + 'account_code' => $item['account_code'], + 'debit_amount' => $amount, + 'credit_amount' => 0, + 'vendor_name' => $item['vendor_name'] ?? '', + 'memo' => $item['description'] ?? $item['memo'] ?? '', + ]; + } + + // 대변 합계 행 (카드미지급금) + $totalAmount = array_sum(array_column($rows, 'debit_amount')); + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25300', // 미지급금 (표준 코드) + 'account_name' => '미지급금', + 'debit_amount' => 0, + 'credit_amount' => $totalAmount, + 'vendor_name' => $transaction->merchant_name ?? '', + 'memo' => '카드결제', + ]; + + $sourceKey = "card_{$id}"; + + return $this->journalSyncService->saveForSource( + JournalEntry::SOURCE_CARD_TRANSACTION, + $sourceKey, + $entryDate, + "카드거래 분개 (#{$id})", + $rows, + ); + }, __('message.created')); + } } diff --git a/app/Http/Controllers/Api/V1/TaxInvoiceController.php b/app/Http/Controllers/Api/V1/TaxInvoiceController.php index 2fe8dcf..08dcdb2 100644 --- a/app/Http/Controllers/Api/V1/TaxInvoiceController.php +++ b/app/Http/Controllers/Api/V1/TaxInvoiceController.php @@ -10,12 +10,17 @@ use App\Http\Requests\TaxInvoice\TaxInvoiceSummaryRequest; use App\Http\Requests\TaxInvoice\UpdateTaxInvoiceRequest; use App\Http\Requests\V1\TaxInvoice\BulkIssueRequest; +use App\Models\Tenants\JournalEntry; +use App\Services\JournalSyncService; use App\Services\TaxInvoiceService; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; class TaxInvoiceController extends Controller { public function __construct( - private TaxInvoiceService $taxInvoiceService + private TaxInvoiceService $taxInvoiceService, + private JournalSyncService $journalSyncService, ) {} /** @@ -148,4 +153,75 @@ public function summary(TaxInvoiceSummaryRequest $request) message: __('message.fetched') ); } + + // ========================================================================= + // 분개 (Journal Entries) + // ========================================================================= + + /** + * 세금계산서 분개 조회 + */ + public function getJournalEntries(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $sourceKey = "tax_invoice_{$id}"; + $data = $this->journalSyncService->getForSource( + JournalEntry::SOURCE_TAX_INVOICE, + $sourceKey + ); + + return $data ?? ['rows' => []]; + }, __('message.fetched')); + } + + /** + * 세금계산서 분개 저장/수정 + */ + public function storeJournalEntries(Request $request, int $id): JsonResponse + { + return ApiResponse::handle(function () use ($request, $id) { + $validated = $request->validate([ + 'rows' => 'required|array|min:1', + 'rows.*.side' => 'required|in:debit,credit', + 'rows.*.account_subject' => 'required|string|max:20', + 'rows.*.debit_amount' => 'required|integer|min:0', + 'rows.*.credit_amount' => 'required|integer|min:0', + ]); + + // 세금계산서 정보 조회 (entry_date용) + $taxInvoice = $this->taxInvoiceService->show($id); + + $rows = array_map(fn ($row) => [ + 'side' => $row['side'], + 'account_code' => $row['account_subject'], + 'debit_amount' => $row['debit_amount'], + 'credit_amount' => $row['credit_amount'], + ], $validated['rows']); + + $sourceKey = "tax_invoice_{$id}"; + + return $this->journalSyncService->saveForSource( + JournalEntry::SOURCE_TAX_INVOICE, + $sourceKey, + $taxInvoice->issue_date?->format('Y-m-d') ?? now()->format('Y-m-d'), + "세금계산서 분개 (#{$id})", + $rows, + ); + }, __('message.created')); + } + + /** + * 세금계산서 분개 삭제 + */ + public function deleteJournalEntries(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $sourceKey = "tax_invoice_{$id}"; + + return $this->journalSyncService->deleteForSource( + JournalEntry::SOURCE_TAX_INVOICE, + $sourceKey + ); + }, __('message.deleted')); + } } diff --git a/app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php b/app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php index 74316ab..25fde5b 100644 --- a/app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php +++ b/app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php @@ -17,6 +17,12 @@ public function rules(): array 'code' => ['required', 'string', 'max:10'], 'name' => ['required', 'string', 'max:100'], 'category' => ['nullable', 'string', 'in:asset,liability,capital,revenue,expense'], + 'sub_category' => ['nullable', 'string', 'max:50'], + 'parent_code' => ['nullable', 'string', 'max:10'], + 'depth' => ['nullable', 'integer', 'in:1,2,3'], + 'department_type' => ['nullable', 'string', 'in:common,manufacturing,admin'], + 'description' => ['nullable', 'string', 'max:500'], + 'sort_order' => ['nullable', 'integer'], ]; } @@ -26,6 +32,8 @@ public function messages(): array 'code.required' => '계정과목 코드를 입력하세요.', 'name.required' => '계정과목명을 입력하세요.', 'category.in' => '유효한 분류를 선택하세요.', + 'depth.in' => '계층은 1(대), 2(중), 3(소) 중 하나여야 합니다.', + 'department_type.in' => '부문은 common, manufacturing, admin 중 하나여야 합니다.', ]; } } diff --git a/app/Http/Requests/V1/AccountSubject/UpdateAccountSubjectRequest.php b/app/Http/Requests/V1/AccountSubject/UpdateAccountSubjectRequest.php new file mode 100644 index 0000000..5cc78d1 --- /dev/null +++ b/app/Http/Requests/V1/AccountSubject/UpdateAccountSubjectRequest.php @@ -0,0 +1,36 @@ + ['sometimes', 'string', 'max:100'], + 'category' => ['nullable', 'string', 'in:asset,liability,capital,revenue,expense'], + 'sub_category' => ['nullable', 'string', 'max:50'], + 'parent_code' => ['nullable', 'string', 'max:10'], + 'depth' => ['nullable', 'integer', 'in:1,2,3'], + 'department_type' => ['nullable', 'string', 'in:common,manufacturing,admin'], + 'description' => ['nullable', 'string', 'max:500'], + 'sort_order' => ['nullable', 'integer'], + ]; + } + + public function messages(): array + { + return [ + 'category.in' => '유효한 분류를 선택하세요.', + 'depth.in' => '계층은 1(대), 2(중), 3(소) 중 하나여야 합니다.', + 'department_type.in' => '부문은 common, manufacturing, admin 중 하나여야 합니다.', + ]; + } +} diff --git a/app/Models/Tenants/AccountCode.php b/app/Models/Tenants/AccountCode.php index 7eb465a..7ecb1b4 100644 --- a/app/Models/Tenants/AccountCode.php +++ b/app/Models/Tenants/AccountCode.php @@ -15,16 +15,22 @@ class AccountCode extends Model 'code', 'name', 'category', + 'sub_category', + 'parent_code', + 'depth', + 'department_type', + 'description', 'sort_order', 'is_active', ]; protected $casts = [ + 'depth' => 'integer', 'sort_order' => 'integer', 'is_active' => 'boolean', ]; - // Categories + // Categories (대분류) public const CATEGORY_ASSET = 'asset'; public const CATEGORY_LIABILITY = 'liability'; public const CATEGORY_CAPITAL = 'capital'; @@ -39,6 +45,36 @@ class AccountCode extends Model self::CATEGORY_EXPENSE => '비용', ]; + // Sub-categories (중분류) + public const SUB_CATEGORIES = [ + 'current_asset' => '유동자산', + 'fixed_asset' => '비유동자산', + 'current_liability' => '유동부채', + 'long_term_liability' => '비유동부채', + 'capital' => '자본', + 'sales_revenue' => '매출', + 'other_revenue' => '영업외수익', + 'cogs' => '매출원가', + 'selling_admin' => '판매비와관리비', + 'other_expense' => '영업외비용', + ]; + + // Department types (부문) + public const DEPT_COMMON = 'common'; + public const DEPT_MANUFACTURING = 'manufacturing'; + public const DEPT_ADMIN = 'admin'; + + public const DEPARTMENT_TYPES = [ + self::DEPT_COMMON => '공통', + self::DEPT_MANUFACTURING => '제조', + self::DEPT_ADMIN => '관리', + ]; + + // Depth levels (계층) + public const DEPTH_MAJOR = 1; + public const DEPTH_MIDDLE = 2; + public const DEPTH_MINOR = 3; + /** * 활성 계정과목만 조회 */ @@ -46,4 +82,21 @@ public function scopeActive(Builder $query): Builder { return $query->where('is_active', true); } + + /** + * 소분류(입력 가능 계정)만 조회 + */ + public function scopeSelectable(Builder $query): Builder + { + return $query->where('depth', self::DEPTH_MINOR); + } + + /** + * 하위 계정과목 관계 + */ + public function children() + { + return $this->hasMany(self::class, 'parent_code', 'code') + ->where('tenant_id', $this->tenant_id); + } } diff --git a/app/Models/Tenants/ExpenseAccount.php b/app/Models/Tenants/ExpenseAccount.php index c9fe540..4351c35 100644 --- a/app/Models/Tenants/ExpenseAccount.php +++ b/app/Models/Tenants/ExpenseAccount.php @@ -35,6 +35,8 @@ class ExpenseAccount extends Model 'payment_method', 'card_no', 'loan_id', + 'journal_entry_id', + 'journal_entry_line_id', 'created_by', 'updated_by', 'deleted_by', diff --git a/app/Models/Tenants/JournalEntry.php b/app/Models/Tenants/JournalEntry.php index 17cdd6f..6a0970a 100644 --- a/app/Models/Tenants/JournalEntry.php +++ b/app/Models/Tenants/JournalEntry.php @@ -39,6 +39,8 @@ class JournalEntry extends Model // Source type public const SOURCE_MANUAL = 'manual'; public const SOURCE_BANK_TRANSACTION = 'bank_transaction'; + public const SOURCE_TAX_INVOICE = 'tax_invoice'; + public const SOURCE_CARD_TRANSACTION = 'card_transaction'; // Entry type public const TYPE_GENERAL = 'general'; diff --git a/app/Services/AccountCodeService.php b/app/Services/AccountCodeService.php index c6342db..5524b4a 100644 --- a/app/Services/AccountCodeService.php +++ b/app/Services/AccountCodeService.php @@ -4,6 +4,7 @@ use App\Models\Tenants\AccountCode; use App\Models\Tenants\JournalEntryLine; +use Illuminate\Support\Facades\DB; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; class AccountCodeService extends Service @@ -27,12 +28,37 @@ public function index(array $params): array }); } - // 분류 필터 + // 분류 필터 (대분류) if (! empty($params['category'])) { $query->where('category', $params['category']); } - return $query->orderBy('sort_order')->orderBy('code')->get()->toArray(); + // 중분류 필터 + if (! empty($params['sub_category'])) { + $query->where('sub_category', $params['sub_category']); + } + + // 부문 필터 + if (! empty($params['department_type'])) { + $query->where('department_type', $params['department_type']); + } + + // 계층 필터 + if (! empty($params['depth'])) { + $query->where('depth', (int) $params['depth']); + } + + // 활성 상태 필터 + if (isset($params['is_active'])) { + $query->where('is_active', filter_var($params['is_active'], FILTER_VALIDATE_BOOLEAN)); + } + + // 선택 가능한 계정만 (소분류만 = Select용) + if (! empty($params['selectable'])) { + $query->selectable(); + } + + return $query->orderBy('code')->orderBy('sort_order')->get()->toArray(); } /** @@ -57,6 +83,11 @@ public function store(array $data): AccountCode $accountCode->code = $data['code']; $accountCode->name = $data['name']; $accountCode->category = $data['category'] ?? null; + $accountCode->sub_category = $data['sub_category'] ?? null; + $accountCode->parent_code = $data['parent_code'] ?? null; + $accountCode->depth = $data['depth'] ?? AccountCode::DEPTH_MINOR; + $accountCode->department_type = $data['department_type'] ?? AccountCode::DEPT_COMMON; + $accountCode->description = $data['description'] ?? null; $accountCode->sort_order = $data['sort_order'] ?? 0; $accountCode->is_active = true; $accountCode->save(); @@ -64,6 +95,36 @@ public function store(array $data): AccountCode return $accountCode; } + /** + * 계정과목 수정 + */ + public function update(int $id, array $data): AccountCode + { + $tenantId = $this->tenantId(); + + $accountCode = AccountCode::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // 코드 변경 시 중복 체크 + if (isset($data['code']) && $data['code'] !== $accountCode->code) { + $exists = AccountCode::query() + ->where('tenant_id', $tenantId) + ->where('code', $data['code']) + ->where('id', '!=', $id) + ->exists(); + + if ($exists) { + throw new BadRequestHttpException(__('error.account_subject.duplicate_code')); + } + } + + $accountCode->fill($data); + $accountCode->save(); + + return $accountCode; + } + /** * 계정과목 활성/비활성 토글 */ @@ -106,4 +167,242 @@ public function destroy(int $id): bool return true; } + + /** + * 기본 계정과목표 일괄 생성 (초기 세팅) + */ + public function seedDefaults(): int + { + $tenantId = $this->tenantId(); + + $defaults = $this->getDefaultAccountCodes(); + $insertedCount = 0; + + DB::transaction(function () use ($tenantId, $defaults, &$insertedCount) { + foreach ($defaults as $item) { + $exists = AccountCode::query() + ->where('tenant_id', $tenantId) + ->where('code', $item['code']) + ->exists(); + + if (! $exists) { + AccountCode::create(array_merge($item, ['tenant_id' => $tenantId])); + $insertedCount++; + } + } + }); + + return $insertedCount; + } + + /** + * 기본 계정과목표 데이터 (더존 Smart A 표준 기반) + * + * 코드 체계: 5자리 (10100~99900) + * - 10100~24000: 자산 + * - 25000~31700: 부채 + * - 33100~38700: 자본 + * - 40100~41000: 매출 + * - 50100~53700: 매출원가/제조경비 (제조부문) + * - 80100~84800: 판매비와관리비 (관리부문) + * - 90100~99900: 영업외수익/비용 + * + * 계층: depth 1(대분류) → depth 2(중분류) → depth 3(소분류=더존 실제코드) + */ + private function getDefaultAccountCodes(): array + { + $c = fn ($code, $name, $cat, $sub, $parent, $depth, $dept, $sort) => [ + 'code' => $code, 'name' => $name, 'category' => $cat, + 'sub_category' => $sub, 'parent_code' => $parent, + 'depth' => $depth, 'department_type' => $dept, 'sort_order' => $sort, + ]; + + return [ + // ============================================================ + // 자산 (Assets) + // ============================================================ + $c('1', '자산', 'asset', null, null, 1, 'common', 100), + + // -- 유동자산 -- + $c('11', '유동자산', 'asset', 'current_asset', '1', 2, 'common', 110), + $c('10100', '현금', 'asset', 'current_asset', '11', 3, 'common', 1010), + $c('10200', '당좌예금', 'asset', 'current_asset', '11', 3, 'common', 1020), + $c('10300', '보통예금', 'asset', 'current_asset', '11', 3, 'common', 1030), + $c('10400', '기타제예금', 'asset', 'current_asset', '11', 3, 'common', 1040), + $c('10500', '정기적금', 'asset', 'current_asset', '11', 3, 'common', 1050), + $c('10800', '외상매출금', 'asset', 'current_asset', '11', 3, 'common', 1080), + $c('10900', '대손충당금(외상매출금)', 'asset', 'current_asset', '11', 3, 'common', 1090), + $c('11000', '받을어음', 'asset', 'current_asset', '11', 3, 'common', 1100), + $c('11400', '단기대여금', 'asset', 'current_asset', '11', 3, 'common', 1140), + $c('11600', '미수수익', 'asset', 'current_asset', '11', 3, 'common', 1160), + $c('12000', '미수금', 'asset', 'current_asset', '11', 3, 'common', 1200), + $c('12200', '소모품', 'asset', 'current_asset', '11', 3, 'common', 1220), + $c('12500', '미환급세금', 'asset', 'current_asset', '11', 3, 'common', 1250), + $c('13100', '선급금', 'asset', 'current_asset', '11', 3, 'common', 1310), + $c('13300', '선급비용', 'asset', 'current_asset', '11', 3, 'common', 1330), + $c('13400', '가지급금', 'asset', 'current_asset', '11', 3, 'common', 1340), + $c('13500', '부가세대급금', 'asset', 'current_asset', '11', 3, 'common', 1350), + $c('13600', '선납세금', 'asset', 'current_asset', '11', 3, 'common', 1360), + $c('14000', '선납법인세', 'asset', 'current_asset', '11', 3, 'common', 1400), + + // -- 재고자산 -- + $c('12', '재고자산', 'asset', 'current_asset', '1', 2, 'common', 120), + $c('14600', '상품', 'asset', 'current_asset', '12', 3, 'common', 1460), + $c('15000', '제품', 'asset', 'current_asset', '12', 3, 'common', 1500), + $c('15300', '원재료', 'asset', 'current_asset', '12', 3, 'common', 1530), + $c('16200', '부재료', 'asset', 'current_asset', '12', 3, 'common', 1620), + $c('16700', '저장품', 'asset', 'current_asset', '12', 3, 'common', 1670), + $c('16900', '재공품', 'asset', 'current_asset', '12', 3, 'common', 1690), + + // -- 비유동자산 -- + $c('13', '비유동자산', 'asset', 'fixed_asset', '1', 2, 'common', 130), + $c('17600', '장기성예금', 'asset', 'fixed_asset', '13', 3, 'common', 1760), + $c('17900', '장기대여금', 'asset', 'fixed_asset', '13', 3, 'common', 1790), + $c('18700', '투자부동산', 'asset', 'fixed_asset', '13', 3, 'common', 1870), + $c('19200', '단체퇴직보험예치금', 'asset', 'fixed_asset', '13', 3, 'common', 1920), + $c('20100', '토지', 'asset', 'fixed_asset', '13', 3, 'common', 2010), + $c('20200', '건물', 'asset', 'fixed_asset', '13', 3, 'common', 2020), + $c('20300', '감가상각누계액(건물)', 'asset', 'fixed_asset', '13', 3, 'common', 2030), + $c('20400', '구축물', 'asset', 'fixed_asset', '13', 3, 'common', 2040), + $c('20500', '감가상각누계액(구축물)', 'asset', 'fixed_asset', '13', 3, 'common', 2050), + $c('20600', '기계장치', 'asset', 'fixed_asset', '13', 3, 'common', 2060), + $c('20700', '감가상각누계액(기계장치)', 'asset', 'fixed_asset', '13', 3, 'common', 2070), + $c('20800', '차량운반구', 'asset', 'fixed_asset', '13', 3, 'common', 2080), + $c('20900', '감가상각누계액(차량운반구)', 'asset', 'fixed_asset', '13', 3, 'common', 2090), + $c('21000', '공구와기구', 'asset', 'fixed_asset', '13', 3, 'common', 2100), + $c('21200', '비품', 'asset', 'fixed_asset', '13', 3, 'common', 2120), + $c('21300', '건설중인자산', 'asset', 'fixed_asset', '13', 3, 'common', 2130), + $c('24000', '소프트웨어', 'asset', 'fixed_asset', '13', 3, 'common', 2400), + + // ============================================================ + // 부채 (Liabilities) + // ============================================================ + $c('2', '부채', 'liability', null, null, 1, 'common', 200), + + // -- 유동부채 -- + $c('21', '유동부채', 'liability', 'current_liability', '2', 2, 'common', 210), + $c('25100', '외상매입금', 'liability', 'current_liability', '21', 3, 'common', 2510), + $c('25200', '지급어음', 'liability', 'current_liability', '21', 3, 'common', 2520), + $c('25300', '미지급금', 'liability', 'current_liability', '21', 3, 'common', 2530), + $c('25400', '예수금', 'liability', 'current_liability', '21', 3, 'common', 2540), + $c('25500', '부가세예수금', 'liability', 'current_liability', '21', 3, 'common', 2550), + $c('25900', '선수금', 'liability', 'current_liability', '21', 3, 'common', 2590), + $c('26000', '단기차입금', 'liability', 'current_liability', '21', 3, 'common', 2600), + $c('26100', '미지급세금', 'liability', 'current_liability', '21', 3, 'common', 2610), + $c('26200', '미지급비용', 'liability', 'current_liability', '21', 3, 'common', 2620), + $c('26400', '유동성장기차입금', 'liability', 'current_liability', '21', 3, 'common', 2640), + $c('26500', '미지급배당금', 'liability', 'current_liability', '21', 3, 'common', 2650), + + // -- 비유동부채 -- + $c('22', '비유동부채', 'liability', 'long_term_liability', '2', 2, 'common', 220), + $c('29300', '장기차입금', 'liability', 'long_term_liability', '22', 3, 'common', 2930), + $c('29400', '임대보증금', 'liability', 'long_term_liability', '22', 3, 'common', 2940), + $c('29500', '퇴직급여충당부채', 'liability', 'long_term_liability', '22', 3, 'common', 2950), + $c('30700', '장기임대보증금', 'liability', 'long_term_liability', '22', 3, 'common', 3070), + + // ============================================================ + // 자본 (Capital) + // ============================================================ + $c('3', '자본', 'capital', null, null, 1, 'common', 300), + + // -- 자본금 -- + $c('31', '자본금', 'capital', 'capital', '3', 2, 'common', 310), + $c('33100', '자본금', 'capital', 'capital', '31', 3, 'common', 3310), + $c('33200', '우선주자본금', 'capital', 'capital', '31', 3, 'common', 3320), + + // -- 잉여금 -- + $c('32', '잉여금', 'capital', 'capital', '3', 2, 'common', 320), + $c('34100', '주식발행초과금', 'capital', 'capital', '32', 3, 'common', 3410), + $c('35100', '이익준비금', 'capital', 'capital', '32', 3, 'common', 3510), + $c('37500', '이월이익잉여금', 'capital', 'capital', '32', 3, 'common', 3750), + $c('37900', '당기순이익', 'capital', 'capital', '32', 3, 'common', 3790), + + // ============================================================ + // 수익 (Revenue) + // ============================================================ + $c('4', '수익', 'revenue', null, null, 1, 'common', 400), + + // -- 매출 -- + $c('41', '매출', 'revenue', 'sales_revenue', '4', 2, 'common', 410), + $c('40100', '상품매출', 'revenue', 'sales_revenue', '41', 3, 'common', 4010), + $c('40400', '제품매출', 'revenue', 'sales_revenue', '41', 3, 'common', 4040), + $c('40700', '공사수입금', 'revenue', 'sales_revenue', '41', 3, 'common', 4070), + $c('41000', '임대료수입', 'revenue', 'sales_revenue', '41', 3, 'common', 4100), + + // -- 영업외수익 -- + $c('42', '영업외수익', 'revenue', 'other_revenue', '4', 2, 'common', 420), + $c('90100', '이자수익', 'revenue', 'other_revenue', '42', 3, 'common', 9010), + $c('90300', '배당금수익', 'revenue', 'other_revenue', '42', 3, 'common', 9030), + $c('90400', '수입임대료', 'revenue', 'other_revenue', '42', 3, 'common', 9040), + $c('90700', '외환차익', 'revenue', 'other_revenue', '42', 3, 'common', 9070), + $c('93000', '잡이익', 'revenue', 'other_revenue', '42', 3, 'common', 9300), + + // ============================================================ + // 비용 (Expenses) + // ============================================================ + $c('5', '비용', 'expense', null, null, 1, 'common', 500), + + // -- 매출원가/제조원가 (제조부문) -- + $c('51', '매출원가', 'expense', 'cogs', '5', 2, 'manufacturing', 510), + $c('50100', '원재료비', 'expense', 'cogs', '51', 3, 'manufacturing', 5010), + $c('50200', '외주가공비', 'expense', 'cogs', '51', 3, 'manufacturing', 5020), + $c('50300', '급여(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5030), + $c('50400', '임금', 'expense', 'cogs', '51', 3, 'manufacturing', 5040), + $c('50500', '상여금(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5050), + $c('50800', '퇴직급여(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5080), + $c('51100', '복리후생비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5110), + $c('51200', '여비교통비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5120), + $c('51300', '접대비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5130), + $c('51400', '통신비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5140), + $c('51600', '전력비', 'expense', 'cogs', '51', 3, 'manufacturing', 5160), + $c('51700', '세금과공과금(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5170), + $c('51800', '감가상각비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5180), + $c('51900', '지급임차료(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5190), + $c('52000', '수선비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5200), + $c('52100', '보험료(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5210), + $c('52200', '차량유지비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5220), + $c('52400', '운반비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5240), + $c('53000', '소모품비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5300), + $c('53100', '지급수수료(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5310), + + // -- 판매비와관리비 (관리부문) -- + $c('52', '판매비와관리비', 'expense', 'selling_admin', '5', 2, 'admin', 520), + $c('80100', '임원급여', 'expense', 'selling_admin', '52', 3, 'admin', 8010), + $c('80200', '직원급여', 'expense', 'selling_admin', '52', 3, 'admin', 8020), + $c('80300', '상여금', 'expense', 'selling_admin', '52', 3, 'admin', 8030), + $c('80600', '퇴직급여', 'expense', 'selling_admin', '52', 3, 'admin', 8060), + $c('81100', '복리후생비', 'expense', 'selling_admin', '52', 3, 'admin', 8110), + $c('81200', '여비교통비', 'expense', 'selling_admin', '52', 3, 'admin', 8120), + $c('81300', '접대비', 'expense', 'selling_admin', '52', 3, 'admin', 8130), + $c('81400', '통신비', 'expense', 'selling_admin', '52', 3, 'admin', 8140), + $c('81500', '수도광열비', 'expense', 'selling_admin', '52', 3, 'admin', 8150), + $c('81700', '세금과공과금', 'expense', 'selling_admin', '52', 3, 'admin', 8170), + $c('81800', '감가상각비', 'expense', 'selling_admin', '52', 3, 'admin', 8180), + $c('81900', '지급임차료', 'expense', 'selling_admin', '52', 3, 'admin', 8190), + $c('82000', '수선비', 'expense', 'selling_admin', '52', 3, 'admin', 8200), + $c('82100', '보험료', 'expense', 'selling_admin', '52', 3, 'admin', 8210), + $c('82200', '차량유지비', 'expense', 'selling_admin', '52', 3, 'admin', 8220), + $c('82300', '경상연구개발비', 'expense', 'selling_admin', '52', 3, 'admin', 8230), + $c('82400', '운반비', 'expense', 'selling_admin', '52', 3, 'admin', 8240), + $c('82500', '교육훈련비', 'expense', 'selling_admin', '52', 3, 'admin', 8250), + $c('82600', '도서인쇄비', 'expense', 'selling_admin', '52', 3, 'admin', 8260), + $c('82700', '회의비', 'expense', 'selling_admin', '52', 3, 'admin', 8270), + $c('82900', '사무용품비', 'expense', 'selling_admin', '52', 3, 'admin', 8290), + $c('83000', '소모품비', 'expense', 'selling_admin', '52', 3, 'admin', 8300), + $c('83100', '지급수수료', 'expense', 'selling_admin', '52', 3, 'admin', 8310), + $c('83200', '보관료', 'expense', 'selling_admin', '52', 3, 'admin', 8320), + $c('83300', '광고선전비', 'expense', 'selling_admin', '52', 3, 'admin', 8330), + $c('83500', '대손상각비', 'expense', 'selling_admin', '52', 3, 'admin', 8350), + $c('84800', '잡비', 'expense', 'selling_admin', '52', 3, 'admin', 8480), + + // -- 영업외비용 -- + $c('53', '영업외비용', 'expense', 'other_expense', '5', 2, 'common', 530), + $c('93100', '이자비용', 'expense', 'other_expense', '53', 3, 'common', 9310), + $c('93200', '외환차손', 'expense', 'other_expense', '53', 3, 'common', 9320), + $c('93300', '기부금', 'expense', 'other_expense', '53', 3, 'common', 9330), + $c('96000', '잡손실', 'expense', 'other_expense', '53', 3, 'common', 9600), + $c('99800', '법인세', 'expense', 'other_expense', '53', 3, 'common', 9980), + $c('99900', '소득세등', 'expense', 'other_expense', '53', 3, 'common', 9990), + ]; + } } diff --git a/app/Services/GeneralJournalEntryService.php b/app/Services/GeneralJournalEntryService.php index 8056c0c..7fa3624 100644 --- a/app/Services/GeneralJournalEntryService.php +++ b/app/Services/GeneralJournalEntryService.php @@ -5,11 +5,13 @@ use App\Models\Tenants\AccountCode; use App\Models\Tenants\JournalEntry; use App\Models\Tenants\JournalEntryLine; +use App\Traits\SyncsExpenseAccounts; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; class GeneralJournalEntryService extends Service { + use SyncsExpenseAccounts; /** * 일반전표입력 통합 목록 (입금 + 출금 + 수기전표) * deposits/withdrawals는 계좌이체 건만, LEFT JOIN journal_entries로 분개 여부 표시 @@ -326,6 +328,9 @@ public function store(array $data): JournalEntry // 분개 행 생성 $this->createLines($entry, $data['rows'], $tenantId); + // expense_accounts 동기화 (복리후생비/접대비 → CEO 대시보드) + $this->syncExpenseAccounts($entry); + return $entry->load('lines'); }); } @@ -373,6 +378,9 @@ public function updateJournal(int $id, array $data): JournalEntry $entry->save(); + // expense_accounts 동기화 (복리후생비/접대비 → CEO 대시보드) + $this->syncExpenseAccounts($entry); + return $entry->load('lines'); }); } @@ -389,6 +397,9 @@ public function destroyJournal(int $id): bool ->where('tenant_id', $tenantId) ->findOrFail($id); + // expense_accounts 정리 (복리후생비/접대비 → CEO 대시보드) + $this->cleanupExpenseAccounts($tenantId, $entry->id); + // lines 먼저 삭제 (soft delete가 아니므로 물리 삭제) JournalEntryLine::query() ->where('journal_entry_id', $entry->id) @@ -503,6 +514,9 @@ private function resolveVendorName(?int $vendorId): string return $vendor ? $vendor->name : ''; } + // syncExpenseAccounts, cleanupExpenseAccounts, getExpenseAccountType + // → SyncsExpenseAccounts 트레이트로 이관 + /** * 원본 거래 정보 조회 (입금/출금) */ diff --git a/app/Services/JournalSyncService.php b/app/Services/JournalSyncService.php new file mode 100644 index 0000000..f42cd5a --- /dev/null +++ b/app/Services/JournalSyncService.php @@ -0,0 +1,214 @@ +tenantId(); + + return DB::transaction(function () use ($sourceType, $sourceKey, $entryDate, $description, $rows, $tenantId) { + // 기존 전표가 있으면 삭제 후 재생성 (교체 방식) + $existing = JournalEntry::query() + ->where('tenant_id', $tenantId) + ->where('source_type', $sourceType) + ->where('source_key', $sourceKey) + ->first(); + + if ($existing) { + $this->cleanupExpenseAccounts($tenantId, $existing->id); + JournalEntryLine::where('journal_entry_id', $existing->id)->delete(); + $existing->forceDelete(); + } + + // 합계 계산 + $totalDebit = 0; + $totalCredit = 0; + foreach ($rows as $row) { + $totalDebit += (int) ($row['debit_amount'] ?? 0); + $totalCredit += (int) ($row['credit_amount'] ?? 0); + } + + // 전표번호 생성 + $entryNo = $this->generateEntryNo($tenantId, $entryDate); + + // 전표 생성 + $entry = new JournalEntry; + $entry->tenant_id = $tenantId; + $entry->entry_no = $entryNo; + $entry->entry_date = $entryDate; + $entry->entry_type = JournalEntry::TYPE_GENERAL; + $entry->description = $description; + $entry->total_debit = $totalDebit; + $entry->total_credit = $totalCredit; + $entry->status = JournalEntry::STATUS_CONFIRMED; + $entry->source_type = $sourceType; + $entry->source_key = $sourceKey; + $entry->save(); + + // 분개 행 생성 + foreach ($rows as $index => $row) { + $accountCode = $row['account_code'] ?? ''; + $accountName = $row['account_name'] ?? $this->resolveAccountName($tenantId, $accountCode); + + $line = new JournalEntryLine; + $line->tenant_id = $tenantId; + $line->journal_entry_id = $entry->id; + $line->line_no = $index + 1; + $line->dc_type = $row['side']; + $line->account_code = $accountCode; + $line->account_name = $accountName; + $line->trading_partner_id = ! empty($row['vendor_id']) ? (int) $row['vendor_id'] : null; + $line->trading_partner_name = $row['vendor_name'] ?? ''; + $line->debit_amount = (int) ($row['debit_amount'] ?? 0); + $line->credit_amount = (int) ($row['credit_amount'] ?? 0); + $line->description = $row['memo'] ?? null; + $line->save(); + } + + // expense_accounts 동기화 (복리후생비/접대비 → CEO 대시보드) + $this->syncExpenseAccounts($entry); + + return $entry->load('lines'); + }); + } + + /** + * 소스에 대한 분개 조회 + */ + public function getForSource(string $sourceType, string $sourceKey): ?array + { + $tenantId = $this->tenantId(); + + $entry = JournalEntry::query() + ->where('tenant_id', $tenantId) + ->where('source_type', $sourceType) + ->where('source_key', $sourceKey) + ->whereNull('deleted_at') + ->with('lines') + ->first(); + + if (! $entry) { + return null; + } + + return [ + 'id' => $entry->id, + 'entry_no' => $entry->entry_no, + 'entry_date' => $entry->entry_date->format('Y-m-d'), + 'description' => $entry->description, + 'total_debit' => $entry->total_debit, + 'total_credit' => $entry->total_credit, + 'rows' => $entry->lines->map(function ($line) { + return [ + 'id' => $line->id, + 'side' => $line->dc_type, + 'account_code' => $line->account_code, + 'account_name' => $line->account_name, + 'vendor_id' => $line->trading_partner_id, + 'vendor_name' => $line->trading_partner_name ?? '', + 'debit_amount' => (int) $line->debit_amount, + 'credit_amount' => (int) $line->credit_amount, + 'memo' => $line->description ?? '', + ]; + })->toArray(), + ]; + } + + /** + * 소스에 대한 분개 삭제 + */ + public function deleteForSource(string $sourceType, string $sourceKey): bool + { + $tenantId = $this->tenantId(); + + return DB::transaction(function () use ($sourceType, $sourceKey, $tenantId) { + $entry = JournalEntry::query() + ->where('tenant_id', $tenantId) + ->where('source_type', $sourceType) + ->where('source_key', $sourceKey) + ->first(); + + if (! $entry) { + return false; + } + + $this->cleanupExpenseAccounts($tenantId, $entry->id); + JournalEntryLine::where('journal_entry_id', $entry->id)->delete(); + $entry->delete(); // soft delete + + return true; + }); + } + + /** + * 전표번호 생성: JE-YYYYMMDD-NNN + */ + private function generateEntryNo(int $tenantId, string $date): string + { + $dateStr = str_replace('-', '', substr($date, 0, 10)); + $prefix = "JE-{$dateStr}-"; + + $lastEntry = DB::table('journal_entries') + ->where('tenant_id', $tenantId) + ->where('entry_no', 'like', "{$prefix}%") + ->lockForUpdate() + ->orderBy('entry_no', 'desc') + ->first(['entry_no']); + + if ($lastEntry) { + $lastSeq = (int) substr($lastEntry->entry_no, -3); + $nextSeq = $lastSeq + 1; + } else { + $nextSeq = 1; + } + + return $prefix . str_pad($nextSeq, 3, '0', STR_PAD_LEFT); + } + + /** + * 계정과목 코드 → 이름 조회 + */ + private function resolveAccountName(int $tenantId, string $code): string + { + if (empty($code)) { + return ''; + } + + $account = AccountCode::query() + ->where('tenant_id', $tenantId) + ->where('code', $code) + ->first(['name']); + + return $account ? $account->name : $code; + } +} diff --git a/app/Traits/SyncsExpenseAccounts.php b/app/Traits/SyncsExpenseAccounts.php new file mode 100644 index 0000000..4216994 --- /dev/null +++ b/app/Traits/SyncsExpenseAccounts.php @@ -0,0 +1,98 @@ + ExpenseAccount::TYPE_WELFARE, + '접대비' => ExpenseAccount::TYPE_ENTERTAINMENT, + ]; + + /** + * 전표 저장/수정 후 expense_accounts 동기화 + * 복리후생비/접대비 계정과목이 포함된 lines → expense_accounts에 반영 + */ + protected function syncExpenseAccounts(JournalEntry $entry): void + { + $tenantId = $entry->tenant_id; + + // 1. 기존 이 전표에서 생성된 expense_accounts 삭제 + ExpenseAccount::where('tenant_id', $tenantId) + ->where('journal_entry_id', $entry->id) + ->forceDelete(); + + // 2. 현재 lines 중 복리후생비/접대비 해당하는 것만 insert + $lines = $entry->lines()->get(); + + foreach ($lines as $line) { + $accountType = $this->getExpenseAccountType($line->account_name); + if (! $accountType) { + continue; + } + + // 차변(debit)이 대변보다 큰 경우만 비용 발생으로 처리 + $amount = $line->debit_amount - $line->credit_amount; + if ($amount <= 0) { + continue; + } + + // source_type에 따라 payment_method 결정 + $paymentMethod = match ($entry->source_type) { + JournalEntry::SOURCE_CARD_TRANSACTION => ExpenseAccount::PAYMENT_CARD, + default => ExpenseAccount::PAYMENT_TRANSFER, + }; + + ExpenseAccount::create([ + 'tenant_id' => $tenantId, + 'account_type' => $accountType, + 'sub_type' => null, + 'expense_date' => $entry->entry_date, + 'amount' => $amount, + 'description' => $line->description ?? $entry->description, + 'receipt_no' => null, + 'vendor_id' => $line->trading_partner_id, + 'vendor_name' => $line->trading_partner_name, + 'payment_method' => $paymentMethod, + 'card_no' => null, + 'journal_entry_id' => $entry->id, + 'journal_entry_line_id' => $line->id, + ]); + } + } + + /** + * 전표 삭제 시 expense_accounts 정리 + */ + protected function cleanupExpenseAccounts(int $tenantId, int $entryId): void + { + ExpenseAccount::where('tenant_id', $tenantId) + ->where('journal_entry_id', $entryId) + ->forceDelete(); + } + + /** + * 계정과목명에서 비용 유형 판별 + */ + private function getExpenseAccountType(string $accountName): ?string + { + foreach (self::$expenseAccountMap as $keyword => $type) { + if (str_contains($accountName, $keyword)) { + return $type; + } + } + + return null; + } +} diff --git a/database/migrations/2026_03_06_210000_add_journal_link_to_expense_accounts_table.php b/database/migrations/2026_03_06_210000_add_journal_link_to_expense_accounts_table.php new file mode 100644 index 0000000..5a5c0a6 --- /dev/null +++ b/database/migrations/2026_03_06_210000_add_journal_link_to_expense_accounts_table.php @@ -0,0 +1,28 @@ +unsignedBigInteger('journal_entry_id')->nullable()->after('loan_id'); + $table->unsignedBigInteger('journal_entry_line_id')->nullable()->after('journal_entry_id'); + + $table->index(['tenant_id', 'journal_entry_id']); + $table->index(['journal_entry_line_id']); + }); + } + + public function down(): void + { + Schema::table('expense_accounts', function (Blueprint $table) { + $table->dropIndex(['tenant_id', 'journal_entry_id']); + $table->dropIndex(['journal_entry_line_id']); + $table->dropColumn(['journal_entry_id', 'journal_entry_line_id']); + }); + } +}; diff --git a/database/migrations/2026_03_06_220000_enhance_account_codes_table.php b/database/migrations/2026_03_06_220000_enhance_account_codes_table.php new file mode 100644 index 0000000..f7a689b --- /dev/null +++ b/database/migrations/2026_03_06_220000_enhance_account_codes_table.php @@ -0,0 +1,54 @@ +string('sub_category', 50)->nullable()->after('category') + ->comment('중분류 (current_asset, fixed_asset, selling_admin, cogs 등)'); + $table->string('parent_code', 10)->nullable()->after('sub_category') + ->comment('상위 계정과목 코드 (계층 구조)'); + $table->tinyInteger('depth')->default(3)->after('parent_code') + ->comment('계층 깊이 (1=대분류, 2=중분류, 3=소분류)'); + $table->string('department_type', 20)->default('common')->after('depth') + ->comment('부문 (common=공통, manufacturing=제조, admin=관리)'); + $table->string('description', 500)->nullable()->after('department_type') + ->comment('계정과목 설명'); + + $table->index(['tenant_id', 'category'], 'account_codes_tenant_category_idx'); + $table->index(['tenant_id', 'parent_code'], 'account_codes_tenant_parent_idx'); + $table->index(['tenant_id', 'depth'], 'account_codes_tenant_depth_idx'); + }); + } + + public function down(): void + { + Schema::table('account_codes', function (Blueprint $table) { + $table->dropIndex('account_codes_tenant_category_idx'); + $table->dropIndex('account_codes_tenant_parent_idx'); + $table->dropIndex('account_codes_tenant_depth_idx'); + + $table->dropColumn([ + 'sub_category', + 'parent_code', + 'depth', + 'department_type', + 'description', + ]); + }); + } +}; diff --git a/database/migrations/2026_03_06_220000_seed_default_account_codes_for_all_tenants.php b/database/migrations/2026_03_06_220000_seed_default_account_codes_for_all_tenants.php new file mode 100644 index 0000000..4b2745c --- /dev/null +++ b/database/migrations/2026_03_06_220000_seed_default_account_codes_for_all_tenants.php @@ -0,0 +1,218 @@ +whereNull('deleted_at') + ->pluck('id'); + + $defaults = $this->getDefaultAccountCodes(); + $now = now(); + + foreach ($tenantIds as $tenantId) { + // 이미 등록된 코드 조회 + $existingCodes = DB::table('account_codes') + ->where('tenant_id', $tenantId) + ->pluck('code') + ->toArray(); + + $inserts = []; + foreach ($defaults as $item) { + if (! in_array($item['code'], $existingCodes)) { + $inserts[] = array_merge($item, [ + 'tenant_id' => $tenantId, + 'is_active' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + if (! empty($inserts)) { + // 500건 단위 청크 insert + foreach (array_chunk($inserts, 500) as $chunk) { + DB::table('account_codes')->insert($chunk); + } + } + } + } + + public function down(): void + { + // 시드 데이터만 롤백 (수동 추가 데이터는 보호) + $defaultCodes = array_column($this->getDefaultAccountCodes(), 'code'); + + DB::table('account_codes') + ->whereIn('code', $defaultCodes) + ->delete(); + } + + private function getDefaultAccountCodes(): array + { + $c = fn ($code, $name, $cat, $sub, $parent, $depth, $dept, $sort) => [ + 'code' => $code, 'name' => $name, 'category' => $cat, + 'sub_category' => $sub, 'parent_code' => $parent, + 'depth' => $depth, 'department_type' => $dept, 'sort_order' => $sort, + ]; + + return [ + // 자산 (Assets) + $c('1', '자산', 'asset', null, null, 1, 'common', 100), + $c('11', '유동자산', 'asset', 'current_asset', '1', 2, 'common', 110), + $c('10100', '현금', 'asset', 'current_asset', '11', 3, 'common', 1010), + $c('10200', '당좌예금', 'asset', 'current_asset', '11', 3, 'common', 1020), + $c('10300', '보통예금', 'asset', 'current_asset', '11', 3, 'common', 1030), + $c('10400', '기타제예금', 'asset', 'current_asset', '11', 3, 'common', 1040), + $c('10500', '정기적금', 'asset', 'current_asset', '11', 3, 'common', 1050), + $c('10800', '외상매출금', 'asset', 'current_asset', '11', 3, 'common', 1080), + $c('10900', '대손충당금(외상매출금)', 'asset', 'current_asset', '11', 3, 'common', 1090), + $c('11000', '받을어음', 'asset', 'current_asset', '11', 3, 'common', 1100), + $c('11400', '단기대여금', 'asset', 'current_asset', '11', 3, 'common', 1140), + $c('11600', '미수수익', 'asset', 'current_asset', '11', 3, 'common', 1160), + $c('12000', '미수금', 'asset', 'current_asset', '11', 3, 'common', 1200), + $c('12200', '소모품', 'asset', 'current_asset', '11', 3, 'common', 1220), + $c('12500', '미환급세금', 'asset', 'current_asset', '11', 3, 'common', 1250), + $c('13100', '선급금', 'asset', 'current_asset', '11', 3, 'common', 1310), + $c('13300', '선급비용', 'asset', 'current_asset', '11', 3, 'common', 1330), + $c('13400', '가지급금', 'asset', 'current_asset', '11', 3, 'common', 1340), + $c('13500', '부가세대급금', 'asset', 'current_asset', '11', 3, 'common', 1350), + $c('13600', '선납세금', 'asset', 'current_asset', '11', 3, 'common', 1360), + $c('14000', '선납법인세', 'asset', 'current_asset', '11', 3, 'common', 1400), + $c('12', '재고자산', 'asset', 'current_asset', '1', 2, 'common', 120), + $c('14600', '상품', 'asset', 'current_asset', '12', 3, 'common', 1460), + $c('15000', '제품', 'asset', 'current_asset', '12', 3, 'common', 1500), + $c('15300', '원재료', 'asset', 'current_asset', '12', 3, 'common', 1530), + $c('16200', '부재료', 'asset', 'current_asset', '12', 3, 'common', 1620), + $c('16700', '저장품', 'asset', 'current_asset', '12', 3, 'common', 1670), + $c('16900', '재공품', 'asset', 'current_asset', '12', 3, 'common', 1690), + $c('13', '비유동자산', 'asset', 'fixed_asset', '1', 2, 'common', 130), + $c('17600', '장기성예금', 'asset', 'fixed_asset', '13', 3, 'common', 1760), + $c('17900', '장기대여금', 'asset', 'fixed_asset', '13', 3, 'common', 1790), + $c('18700', '투자부동산', 'asset', 'fixed_asset', '13', 3, 'common', 1870), + $c('19200', '단체퇴직보험예치금', 'asset', 'fixed_asset', '13', 3, 'common', 1920), + $c('20100', '토지', 'asset', 'fixed_asset', '13', 3, 'common', 2010), + $c('20200', '건물', 'asset', 'fixed_asset', '13', 3, 'common', 2020), + $c('20300', '감가상각누계액(건물)', 'asset', 'fixed_asset', '13', 3, 'common', 2030), + $c('20400', '구축물', 'asset', 'fixed_asset', '13', 3, 'common', 2040), + $c('20500', '감가상각누계액(구축물)', 'asset', 'fixed_asset', '13', 3, 'common', 2050), + $c('20600', '기계장치', 'asset', 'fixed_asset', '13', 3, 'common', 2060), + $c('20700', '감가상각누계액(기계장치)', 'asset', 'fixed_asset', '13', 3, 'common', 2070), + $c('20800', '차량운반구', 'asset', 'fixed_asset', '13', 3, 'common', 2080), + $c('20900', '감가상각누계액(차량운반구)', 'asset', 'fixed_asset', '13', 3, 'common', 2090), + $c('21000', '공구와기구', 'asset', 'fixed_asset', '13', 3, 'common', 2100), + $c('21200', '비품', 'asset', 'fixed_asset', '13', 3, 'common', 2120), + $c('21300', '건설중인자산', 'asset', 'fixed_asset', '13', 3, 'common', 2130), + $c('24000', '소프트웨어', 'asset', 'fixed_asset', '13', 3, 'common', 2400), + // 부채 (Liabilities) + $c('2', '부채', 'liability', null, null, 1, 'common', 200), + $c('21', '유동부채', 'liability', 'current_liability', '2', 2, 'common', 210), + $c('25100', '외상매입금', 'liability', 'current_liability', '21', 3, 'common', 2510), + $c('25200', '지급어음', 'liability', 'current_liability', '21', 3, 'common', 2520), + $c('25300', '미지급금', 'liability', 'current_liability', '21', 3, 'common', 2530), + $c('25400', '예수금', 'liability', 'current_liability', '21', 3, 'common', 2540), + $c('25500', '부가세예수금', 'liability', 'current_liability', '21', 3, 'common', 2550), + $c('25900', '선수금', 'liability', 'current_liability', '21', 3, 'common', 2590), + $c('26000', '단기차입금', 'liability', 'current_liability', '21', 3, 'common', 2600), + $c('26100', '미지급세금', 'liability', 'current_liability', '21', 3, 'common', 2610), + $c('26200', '미지급비용', 'liability', 'current_liability', '21', 3, 'common', 2620), + $c('26400', '유동성장기차입금', 'liability', 'current_liability', '21', 3, 'common', 2640), + $c('26500', '미지급배당금', 'liability', 'current_liability', '21', 3, 'common', 2650), + $c('22', '비유동부채', 'liability', 'long_term_liability', '2', 2, 'common', 220), + $c('29300', '장기차입금', 'liability', 'long_term_liability', '22', 3, 'common', 2930), + $c('29400', '임대보증금', 'liability', 'long_term_liability', '22', 3, 'common', 2940), + $c('29500', '퇴직급여충당부채', 'liability', 'long_term_liability', '22', 3, 'common', 2950), + $c('30700', '장기임대보증금', 'liability', 'long_term_liability', '22', 3, 'common', 3070), + // 자본 (Capital) + $c('3', '자본', 'capital', null, null, 1, 'common', 300), + $c('31', '자본금', 'capital', 'capital', '3', 2, 'common', 310), + $c('33100', '자본금', 'capital', 'capital', '31', 3, 'common', 3310), + $c('33200', '우선주자본금', 'capital', 'capital', '31', 3, 'common', 3320), + $c('32', '잉여금', 'capital', 'capital', '3', 2, 'common', 320), + $c('34100', '주식발행초과금', 'capital', 'capital', '32', 3, 'common', 3410), + $c('35100', '이익준비금', 'capital', 'capital', '32', 3, 'common', 3510), + $c('37500', '이월이익잉여금', 'capital', 'capital', '32', 3, 'common', 3750), + $c('37900', '당기순이익', 'capital', 'capital', '32', 3, 'common', 3790), + // 수익 (Revenue) + $c('4', '수익', 'revenue', null, null, 1, 'common', 400), + $c('41', '매출', 'revenue', 'sales_revenue', '4', 2, 'common', 410), + $c('40100', '상품매출', 'revenue', 'sales_revenue', '41', 3, 'common', 4010), + $c('40400', '제품매출', 'revenue', 'sales_revenue', '41', 3, 'common', 4040), + $c('40700', '공사수입금', 'revenue', 'sales_revenue', '41', 3, 'common', 4070), + $c('41000', '임대료수입', 'revenue', 'sales_revenue', '41', 3, 'common', 4100), + $c('42', '영업외수익', 'revenue', 'other_revenue', '4', 2, 'common', 420), + $c('90100', '이자수익', 'revenue', 'other_revenue', '42', 3, 'common', 9010), + $c('90300', '배당금수익', 'revenue', 'other_revenue', '42', 3, 'common', 9030), + $c('90400', '수입임대료', 'revenue', 'other_revenue', '42', 3, 'common', 9040), + $c('90700', '외환차익', 'revenue', 'other_revenue', '42', 3, 'common', 9070), + $c('93000', '잡이익', 'revenue', 'other_revenue', '42', 3, 'common', 9300), + // 비용 (Expenses) + $c('5', '비용', 'expense', null, null, 1, 'common', 500), + $c('51', '매출원가', 'expense', 'cogs', '5', 2, 'manufacturing', 510), + $c('50100', '원재료비', 'expense', 'cogs', '51', 3, 'manufacturing', 5010), + $c('50200', '외주가공비', 'expense', 'cogs', '51', 3, 'manufacturing', 5020), + $c('50300', '급여(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5030), + $c('50400', '임금', 'expense', 'cogs', '51', 3, 'manufacturing', 5040), + $c('50500', '상여금(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5050), + $c('50800', '퇴직급여(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5080), + $c('51100', '복리후생비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5110), + $c('51200', '여비교통비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5120), + $c('51300', '접대비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5130), + $c('51400', '통신비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5140), + $c('51600', '전력비', 'expense', 'cogs', '51', 3, 'manufacturing', 5160), + $c('51700', '세금과공과금(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5170), + $c('51800', '감가상각비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5180), + $c('51900', '지급임차료(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5190), + $c('52000', '수선비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5200), + $c('52100', '보험료(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5210), + $c('52200', '차량유지비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5220), + $c('52400', '운반비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5240), + $c('53000', '소모품비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5300), + $c('53100', '지급수수료(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5310), + $c('52', '판매비와관리비', 'expense', 'selling_admin', '5', 2, 'admin', 520), + $c('80100', '임원급여', 'expense', 'selling_admin', '52', 3, 'admin', 8010), + $c('80200', '직원급여', 'expense', 'selling_admin', '52', 3, 'admin', 8020), + $c('80300', '상여금', 'expense', 'selling_admin', '52', 3, 'admin', 8030), + $c('80600', '퇴직급여', 'expense', 'selling_admin', '52', 3, 'admin', 8060), + $c('81100', '복리후생비', 'expense', 'selling_admin', '52', 3, 'admin', 8110), + $c('81200', '여비교통비', 'expense', 'selling_admin', '52', 3, 'admin', 8120), + $c('81300', '접대비', 'expense', 'selling_admin', '52', 3, 'admin', 8130), + $c('81400', '통신비', 'expense', 'selling_admin', '52', 3, 'admin', 8140), + $c('81500', '수도광열비', 'expense', 'selling_admin', '52', 3, 'admin', 8150), + $c('81700', '세금과공과금', 'expense', 'selling_admin', '52', 3, 'admin', 8170), + $c('81800', '감가상각비', 'expense', 'selling_admin', '52', 3, 'admin', 8180), + $c('81900', '지급임차료', 'expense', 'selling_admin', '52', 3, 'admin', 8190), + $c('82000', '수선비', 'expense', 'selling_admin', '52', 3, 'admin', 8200), + $c('82100', '보험료', 'expense', 'selling_admin', '52', 3, 'admin', 8210), + $c('82200', '차량유지비', 'expense', 'selling_admin', '52', 3, 'admin', 8220), + $c('82300', '경상연구개발비', 'expense', 'selling_admin', '52', 3, 'admin', 8230), + $c('82400', '운반비', 'expense', 'selling_admin', '52', 3, 'admin', 8240), + $c('82500', '교육훈련비', 'expense', 'selling_admin', '52', 3, 'admin', 8250), + $c('82600', '도서인쇄비', 'expense', 'selling_admin', '52', 3, 'admin', 8260), + $c('82700', '회의비', 'expense', 'selling_admin', '52', 3, 'admin', 8270), + $c('82900', '사무용품비', 'expense', 'selling_admin', '52', 3, 'admin', 8290), + $c('83000', '소모품비', 'expense', 'selling_admin', '52', 3, 'admin', 8300), + $c('83100', '지급수수료', 'expense', 'selling_admin', '52', 3, 'admin', 8310), + $c('83200', '보관료', 'expense', 'selling_admin', '52', 3, 'admin', 8320), + $c('83300', '광고선전비', 'expense', 'selling_admin', '52', 3, 'admin', 8330), + $c('83500', '대손상각비', 'expense', 'selling_admin', '52', 3, 'admin', 8350), + $c('84800', '잡비', 'expense', 'selling_admin', '52', 3, 'admin', 8480), + $c('53', '영업외비용', 'expense', 'other_expense', '5', 2, 'common', 530), + $c('93100', '이자비용', 'expense', 'other_expense', '53', 3, 'common', 9310), + $c('93200', '외환차손', 'expense', 'other_expense', '53', 3, 'common', 9320), + $c('93300', '기부금', 'expense', 'other_expense', '53', 3, 'common', 9330), + $c('96000', '잡손실', 'expense', 'other_expense', '53', 3, 'common', 9600), + $c('99800', '법인세', 'expense', 'other_expense', '53', 3, 'common', 9980), + $c('99900', '소득세등', 'expense', 'other_expense', '53', 3, 'common', 9990), + ]; + } +}; diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index 64924f9..2b996bd 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -163,6 +163,8 @@ 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'); + Route::get('/{id}/journal-entries', [CardTransactionController::class, 'getJournalEntries'])->whereNumber('id')->name('v1.card-transactions.journal-entries.show'); + Route::post('/{id}/journal-entries', [CardTransactionController::class, 'storeJournalEntries'])->whereNumber('id')->name('v1.card-transactions.journal-entries.store'); }); // Bank Transaction API (은행 거래 조회) @@ -287,6 +289,10 @@ 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'); + Route::get('/{id}/journal-entries', [TaxInvoiceController::class, 'getJournalEntries'])->whereNumber('id')->name('v1.tax-invoices.journal-entries.show'); + Route::post('/{id}/journal-entries', [TaxInvoiceController::class, 'storeJournalEntries'])->whereNumber('id')->name('v1.tax-invoices.journal-entries.store'); + Route::put('/{id}/journal-entries', [TaxInvoiceController::class, 'storeJournalEntries'])->whereNumber('id')->name('v1.tax-invoices.journal-entries.update'); + Route::delete('/{id}/journal-entries', [TaxInvoiceController::class, 'deleteJournalEntries'])->whereNumber('id')->name('v1.tax-invoices.journal-entries.destroy'); }); // Bad Debt API (악성채권 추심관리) @@ -320,6 +326,8 @@ Route::prefix('account-subjects')->group(function () { Route::get('', [AccountSubjectController::class, 'index'])->name('v1.account-subjects.index'); Route::post('', [AccountSubjectController::class, 'store'])->name('v1.account-subjects.store'); + Route::post('/seed-defaults', [AccountSubjectController::class, 'seedDefaults'])->name('v1.account-subjects.seed-defaults'); + Route::put('/{id}', [AccountSubjectController::class, 'update'])->whereNumber('id')->name('v1.account-subjects.update'); Route::patch('/{id}/status', [AccountSubjectController::class, 'toggleStatus'])->whereNumber('id')->name('v1.account-subjects.toggle-status'); Route::delete('/{id}', [AccountSubjectController::class, 'destroy'])->whereNumber('id')->name('v1.account-subjects.destroy'); }); From 1d5d161e05d5537df16034f4137393cafea97823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Mon, 9 Mar 2026 10:56:49 +0900 Subject: [PATCH 113/166] =?UTF-8?q?feat:=20[finance]=20=EB=8D=94=EC=A1=B4?= =?UTF-8?q?=20Smart=20A=20=ED=91=9C=EC=A4=80=20=EA=B3=84=EC=A0=95=EA=B3=BC?= =?UTF-8?q?=EB=AA=A9=20=EC=B6=94=EA=B0=80=20=EC=8B=9C=EB=94=A9=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=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 - 기획서 14장 기준 누락분 보완 - tenant_id + code 중복 시 skip (기존 데이터 보호) Co-Authored-By: Claude Opus 4.6 --- ...ditional_account_codes_for_all_tenants.php | 467 ++++++++++++++++++ 1 file changed, 467 insertions(+) create mode 100644 database/migrations/2026_03_09_000000_seed_additional_account_codes_for_all_tenants.php diff --git a/database/migrations/2026_03_09_000000_seed_additional_account_codes_for_all_tenants.php b/database/migrations/2026_03_09_000000_seed_additional_account_codes_for_all_tenants.php new file mode 100644 index 0000000..8dca605 --- /dev/null +++ b/database/migrations/2026_03_09_000000_seed_additional_account_codes_for_all_tenants.php @@ -0,0 +1,467 @@ +whereNull('deleted_at') + ->pluck('id'); + + $defaults = $this->getAdditionalAccountCodes(); + $now = now(); + + foreach ($tenantIds as $tenantId) { + $existingCodes = DB::table('account_codes') + ->where('tenant_id', $tenantId) + ->pluck('code') + ->toArray(); + + $inserts = []; + foreach ($defaults as $item) { + if (! in_array($item['code'], $existingCodes)) { + $inserts[] = array_merge($item, [ + 'tenant_id' => $tenantId, + 'is_active' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + if (! empty($inserts)) { + foreach (array_chunk($inserts, 500) as $chunk) { + DB::table('account_codes')->insert($chunk); + } + } + } + } + + public function down(): void + { + $codes = array_column($this->getAdditionalAccountCodes(), 'code'); + DB::table('account_codes')->whereIn('code', $codes)->delete(); + } + + private function getAdditionalAccountCodes(): array + { + $c = fn ($code, $name, $cat, $sub, $parent, $depth, $dept, $sort) => [ + 'code' => $code, 'name' => $name, 'category' => $cat, + 'sub_category' => $sub, 'parent_code' => $parent, + 'depth' => $depth, 'department_type' => $dept, 'sort_order' => $sort, + ]; + + return [ + // ================================================================ + // 새 depth-2 카테고리 + // ================================================================ + $c('33', '자본조정', 'capital', 'capital_adjustment', '3', 2, 'common', 330), + $c('54', '건설원가', 'expense', 'construction_cost', '5', 2, 'construction', 540), + + // ================================================================ + // 자산 — 유동자산 추가 (parent: '11') + // ================================================================ + $c('10600', '기타단기금융상품예금', 'asset', 'current_asset', '11', 3, 'common', 1060), + $c('10700', '단기투자자산', 'asset', 'current_asset', '11', 3, 'common', 1070), + $c('11100', '대손충당금(받을어음)', 'asset', 'current_asset', '11', 3, 'common', 1110), + $c('11200', '공사미수금', 'asset', 'current_asset', '11', 3, 'common', 1120), + $c('11300', '대손충당금(공사미수금)', 'asset', 'current_asset', '11', 3, 'common', 1130), + $c('11500', '대손충당금(단기대여금)', 'asset', 'current_asset', '11', 3, 'common', 1150), + $c('11700', '대손충당금(미수수익)', 'asset', 'current_asset', '11', 3, 'common', 1170), + $c('11800', '분양미수금', 'asset', 'current_asset', '11', 3, 'common', 1180), + $c('11900', '대손충당금(분양미수금)', 'asset', 'current_asset', '11', 3, 'common', 1190), + $c('12100', '대손충당금(미수금)', 'asset', 'current_asset', '11', 3, 'common', 1210), + $c('12300', '매도가능증권', 'asset', 'current_asset', '11', 3, 'common', 1230), + $c('12400', '만기보유증권', 'asset', 'current_asset', '11', 3, 'common', 1240), + $c('13200', '대손충당금(선급금)', 'asset', 'current_asset', '11', 3, 'common', 1320), + $c('13700', '주임종단기채권', 'asset', 'current_asset', '11', 3, 'common', 1370), + $c('13800', '전도금', 'asset', 'current_asset', '11', 3, 'common', 1380), + $c('13900', '선급공사비', 'asset', 'current_asset', '11', 3, 'common', 1390), + + // ================================================================ + // 자산 — 재고자산 추가 (parent: '12') + // ================================================================ + $c('14700', '매입환출및에누리(상품)', 'asset', 'current_asset', '12', 3, 'common', 1470), + $c('14800', '매입할인(상품)', 'asset', 'current_asset', '12', 3, 'common', 1480), + $c('14900', '관세환급금(상품)', 'asset', 'current_asset', '12', 3, 'common', 1490), + $c('15100', '관세환급금(제품)', 'asset', 'current_asset', '12', 3, 'common', 1510), + $c('15200', '완성건물', 'asset', 'current_asset', '12', 3, 'common', 1520), + $c('15400', '매입환출및에누리(원재료)', 'asset', 'current_asset', '12', 3, 'common', 1540), + $c('15500', '매입할인(원재료)', 'asset', 'current_asset', '12', 3, 'common', 1550), + $c('15600', '원재료(도급)', 'asset', 'current_asset', '12', 3, 'common', 1560), + $c('15700', '매입환출및에누리(원재료-도급)', 'asset', 'current_asset', '12', 3, 'common', 1570), + $c('15800', '매입할인(원재료-도급)', 'asset', 'current_asset', '12', 3, 'common', 1580), + $c('15900', '원재료(분양)', 'asset', 'current_asset', '12', 3, 'common', 1590), + $c('16000', '매입환출및에누리(원재료-분양)', 'asset', 'current_asset', '12', 3, 'common', 1600), + $c('16100', '매입할인(원재료-분양)', 'asset', 'current_asset', '12', 3, 'common', 1610), + $c('16300', '매입환출및에누리(부재료)', 'asset', 'current_asset', '12', 3, 'common', 1630), + $c('16400', '매입할인(부재료)', 'asset', 'current_asset', '12', 3, 'common', 1640), + $c('16500', '건설용지', 'asset', 'current_asset', '12', 3, 'common', 1650), + $c('16600', '가설재', 'asset', 'current_asset', '12', 3, 'common', 1660), + $c('16800', '미착품', 'asset', 'current_asset', '12', 3, 'common', 1680), + $c('17000', '미완성공사(도급)', 'asset', 'current_asset', '12', 3, 'common', 1700), + $c('17100', '미완성공사(분양)', 'asset', 'current_asset', '12', 3, 'common', 1710), + + // ================================================================ + // 자산 — 비유동자산 추가 (parent: '13') + // ================================================================ + $c('17700', '특정현금과예금', 'asset', 'fixed_asset', '13', 3, 'common', 1770), + $c('17800', '장기투자증권', 'asset', 'fixed_asset', '13', 3, 'common', 1780), + $c('18000', '대손충당금(장기대여금)', 'asset', 'fixed_asset', '13', 3, 'common', 1800), + $c('18100', '만기보유증권(장기)', 'asset', 'fixed_asset', '13', 3, 'common', 1810), + $c('18200', '지분법적용투자주식', 'asset', 'fixed_asset', '13', 3, 'common', 1820), + $c('19100', '출자금', 'asset', 'fixed_asset', '13', 3, 'common', 1910), + $c('19700', '투자임대계약자산', 'asset', 'fixed_asset', '13', 3, 'common', 1970), + $c('19800', '출자금(장기)', 'asset', 'fixed_asset', '13', 3, 'common', 1980), + $c('19900', '퇴직보험예치금', 'asset', 'fixed_asset', '13', 3, 'common', 1990), + $c('20000', '국민연금전환금', 'asset', 'fixed_asset', '13', 3, 'common', 2000), + $c('21100', '감가상각누계액(공구와기구)', 'asset', 'fixed_asset', '13', 3, 'common', 2110), + $c('21400', '건설중인자산(임대)', 'asset', 'fixed_asset', '13', 3, 'common', 2140), + $c('21500', '미착기계', 'asset', 'fixed_asset', '13', 3, 'common', 2150), + $c('21600', '감가상각누계액(비품)', 'asset', 'fixed_asset', '13', 3, 'common', 2160), + // 무형자산 + $c('23100', '영업권', 'asset', 'fixed_asset', '13', 3, 'common', 2310), + $c('23200', '특허권', 'asset', 'fixed_asset', '13', 3, 'common', 2320), + $c('23300', '상표권', 'asset', 'fixed_asset', '13', 3, 'common', 2330), + $c('23400', '실용신안권', 'asset', 'fixed_asset', '13', 3, 'common', 2340), + $c('23500', '의장권', 'asset', 'fixed_asset', '13', 3, 'common', 2350), + $c('23600', '면허권', 'asset', 'fixed_asset', '13', 3, 'common', 2360), + $c('23700', '광업권', 'asset', 'fixed_asset', '13', 3, 'common', 2370), + $c('23800', '창업비', 'asset', 'fixed_asset', '13', 3, 'common', 2380), + $c('23900', '개발비', 'asset', 'fixed_asset', '13', 3, 'common', 2390), + // 기타비유동자산 (96xxx-97xxx) + $c('96100', '이연법인세자산', 'asset', 'fixed_asset', '13', 3, 'common', 9610), + $c('96200', '임차보증금', 'asset', 'fixed_asset', '13', 3, 'common', 9620), + $c('96300', '전세금', 'asset', 'fixed_asset', '13', 3, 'common', 9630), + $c('96400', '기타보증금', 'asset', 'fixed_asset', '13', 3, 'common', 9640), + $c('96500', '장기외상매출금', 'asset', 'fixed_asset', '13', 3, 'common', 9650), + $c('96600', '현재가치할인차금(장기외상매출금)', 'asset', 'fixed_asset', '13', 3, 'common', 9660), + $c('96700', '대손충당금(장기외상매출금)', 'asset', 'fixed_asset', '13', 3, 'common', 9670), + $c('96800', '장기받을어음', 'asset', 'fixed_asset', '13', 3, 'common', 9680), + $c('96900', '현재가치할인차금(장기받을어음)', 'asset', 'fixed_asset', '13', 3, 'common', 9690), + $c('97000', '대손충당금(장기받을어음)', 'asset', 'fixed_asset', '13', 3, 'common', 9700), + $c('97100', '장기미수금', 'asset', 'fixed_asset', '13', 3, 'common', 9710), + $c('97200', '현재가치할인차금(장기미수금)', 'asset', 'fixed_asset', '13', 3, 'common', 9720), + $c('97300', '대손충당금(장기미수금)', 'asset', 'fixed_asset', '13', 3, 'common', 9730), + $c('97400', '장기선급비용', 'asset', 'fixed_asset', '13', 3, 'common', 9740), + $c('97500', '장기선급금', 'asset', 'fixed_asset', '13', 3, 'common', 9750), + $c('97600', '부도어음과수표', 'asset', 'fixed_asset', '13', 3, 'common', 9760), + $c('97700', '대손충당금(부도어음)', 'asset', 'fixed_asset', '13', 3, 'common', 9770), + $c('97800', '전신전화가입권', 'asset', 'fixed_asset', '13', 3, 'common', 9780), + + // ================================================================ + // 부채 — 유동부채 추가 (parent: '21') + // ================================================================ + $c('25600', '당좌차월', 'liability', 'current_liability', '21', 3, 'common', 2560), + $c('25700', '가수금', 'liability', 'current_liability', '21', 3, 'common', 2570), + $c('25800', '예수보증금', 'liability', 'current_liability', '21', 3, 'common', 2580), + $c('26300', '수입금', 'liability', 'current_liability', '21', 3, 'common', 2630), + $c('26600', '지급보증채무', 'liability', 'current_liability', '21', 3, 'common', 2660), + $c('26700', '수출금융', 'liability', 'current_liability', '21', 3, 'common', 2670), + $c('26800', '수입금융', 'liability', 'current_liability', '21', 3, 'common', 2680), + $c('26900', '공사손실충당금', 'liability', 'current_liability', '21', 3, 'common', 2690), + $c('27000', '하자보수충당금', 'liability', 'current_liability', '21', 3, 'common', 2700), + $c('27100', '공사선수금', 'liability', 'current_liability', '21', 3, 'common', 2710), + $c('27200', '분양선수금', 'liability', 'current_liability', '21', 3, 'common', 2720), + $c('27300', '이연법인세부채', 'liability', 'current_liability', '21', 3, 'common', 2730), + + // ================================================================ + // 부채 — 비유동부채 추가 (parent: '22') + // ================================================================ + $c('29100', '사채', 'liability', 'long_term_liability', '22', 3, 'common', 2910), + $c('29200', '사채할인발행차금', 'liability', 'long_term_liability', '22', 3, 'common', 2920), + $c('29600', '퇴직보험충당부채', 'liability', 'long_term_liability', '22', 3, 'common', 2960), + $c('29700', '중소기업투자준비금', 'liability', 'long_term_liability', '22', 3, 'common', 2970), + $c('29800', '기술개발준비금', 'liability', 'long_term_liability', '22', 3, 'common', 2980), + $c('29900', '해외시장개척준비금', 'liability', 'long_term_liability', '22', 3, 'common', 2990), + $c('30100', '지방이전준비금', 'liability', 'long_term_liability', '22', 3, 'common', 3010), + $c('30200', '수출손실준비금', 'liability', 'long_term_liability', '22', 3, 'common', 3020), + $c('30300', '주임종장기차입금', 'liability', 'long_term_liability', '22', 3, 'common', 3030), + $c('30400', '관계회사장기차입금', 'liability', 'long_term_liability', '22', 3, 'common', 3040), + $c('30500', '외화장기차입금', 'liability', 'long_term_liability', '22', 3, 'common', 3050), + $c('30600', '공사선수금(장기)', 'liability', 'long_term_liability', '22', 3, 'common', 3060), + $c('30800', '장기성지급어음', 'liability', 'long_term_liability', '22', 3, 'common', 3080), + $c('30900', '환율조정대', 'liability', 'long_term_liability', '22', 3, 'common', 3090), + $c('31000', '이연법인세대', 'liability', 'long_term_liability', '22', 3, 'common', 3100), + $c('31100', '신주인수권부사채', 'liability', 'long_term_liability', '22', 3, 'common', 3110), + $c('31200', '전환사채', 'liability', 'long_term_liability', '22', 3, 'common', 3120), + $c('31300', '사채할증발행차금', 'liability', 'long_term_liability', '22', 3, 'common', 3130), + $c('31400', '장기제품보증부채', 'liability', 'long_term_liability', '22', 3, 'common', 3140), + + // ================================================================ + // 자본 — 잉여금 추가 (parent: '32') + // ================================================================ + $c('34200', '감자차익', 'capital', 'capital', '32', 3, 'common', 3420), + $c('34300', '자기주식처분이익', 'capital', 'capital', '32', 3, 'common', 3430), + $c('34900', '기타자본잉여금', 'capital', 'capital', '32', 3, 'common', 3490), + $c('35000', '재평가적립금', 'capital', 'capital', '32', 3, 'common', 3500), + $c('35200', '기업합리화적립금', 'capital', 'capital', '32', 3, 'common', 3520), + $c('35300', '법정적립금', 'capital', 'capital', '32', 3, 'common', 3530), + $c('35400', '재무구조개선적립금', 'capital', 'capital', '32', 3, 'common', 3540), + $c('35500', '임의적립금', 'capital', 'capital', '32', 3, 'common', 3550), + $c('35600', '사업확장적립금', 'capital', 'capital', '32', 3, 'common', 3560), + $c('35700', '감채적립금', 'capital', 'capital', '32', 3, 'common', 3570), + $c('35800', '배당평균적립금', 'capital', 'capital', '32', 3, 'common', 3580), + $c('35900', '주식할인발행차손', 'capital', 'capital', '32', 3, 'common', 3590), + $c('36000', '배당건설이자상각', 'capital', 'capital', '32', 3, 'common', 3600), + $c('36100', '자기주식상환액', 'capital', 'capital', '32', 3, 'common', 3610), + $c('36200', '자기주식처분차금', 'capital', 'capital', '32', 3, 'common', 3620), + $c('36300', '중소기업투자준비금(자본)', 'capital', 'capital', '32', 3, 'common', 3630), + $c('36400', '기술개발준비금(자본)', 'capital', 'capital', '32', 3, 'common', 3640), + $c('36500', '해외시장개척준비금(자본)', 'capital', 'capital', '32', 3, 'common', 3650), + $c('36600', '지방이전준비금(자본)', 'capital', 'capital', '32', 3, 'common', 3660), + $c('36700', '수출손실준비금(자본)', 'capital', 'capital', '32', 3, 'common', 3670), + $c('36800', '기타임의적립금', 'capital', 'capital', '32', 3, 'common', 3680), + $c('36900', '회계변경의누적효과', 'capital', 'capital', '32', 3, 'common', 3690), + $c('37000', '전기오류수정이익', 'capital', 'capital', '32', 3, 'common', 3700), + $c('37100', '전기오류수정손실', 'capital', 'capital', '32', 3, 'common', 3710), + $c('37200', '중간배당금', 'capital', 'capital', '32', 3, 'common', 3720), + $c('37400', '기타이익잉여금', 'capital', 'capital', '32', 3, 'common', 3740), + $c('37600', '이월결손금', 'capital', 'capital', '32', 3, 'common', 3760), + $c('37800', '처분전이익잉여금', 'capital', 'capital', '32', 3, 'common', 3780), + + // ================================================================ + // 자본 — 자본조정 (parent: '33') + // ================================================================ + $c('38000', '당기순손실', 'capital', 'capital_adjustment', '33', 3, 'common', 3800), + $c('38100', '주식할인발행차금', 'capital', 'capital_adjustment', '33', 3, 'common', 3810), + $c('38200', '배당건설이자', 'capital', 'capital_adjustment', '33', 3, 'common', 3820), + $c('38300', '자기주식', 'capital', 'capital_adjustment', '33', 3, 'common', 3830), + $c('38400', '환전대가', 'capital', 'capital_adjustment', '33', 3, 'common', 3840), + $c('38500', '신주인수권대가', 'capital', 'capital_adjustment', '33', 3, 'common', 3850), + $c('38600', '신주발행비', 'capital', 'capital_adjustment', '33', 3, 'common', 3860), + $c('38700', '미교부주식배당금', 'capital', 'capital_adjustment', '33', 3, 'common', 3870), + $c('38800', '신주청약증거금', 'capital', 'capital_adjustment', '33', 3, 'common', 3880), + $c('39200', '국고보조금', 'capital', 'capital_adjustment', '33', 3, 'common', 3920), + $c('39300', '공사부담금', 'capital', 'capital_adjustment', '33', 3, 'common', 3930), + $c('39400', '감자차손', 'capital', 'capital_adjustment', '33', 3, 'common', 3940), + $c('39500', '자기주식처분손실', 'capital', 'capital_adjustment', '33', 3, 'common', 3950), + $c('39600', '주식매입선택권', 'capital', 'capital_adjustment', '33', 3, 'common', 3960), + // 기타포괄손익누계액 + $c('98100', '매도가능증권평가이익', 'capital', 'capital_adjustment', '33', 3, 'common', 9810), + $c('98200', '매도가능증권평가손실', 'capital', 'capital_adjustment', '33', 3, 'common', 9820), + $c('98300', '해외사업환산이익', 'capital', 'capital_adjustment', '33', 3, 'common', 9830), + $c('98400', '해외사업환산손실', 'capital', 'capital_adjustment', '33', 3, 'common', 9840), + $c('98500', '파생상품평가이익', 'capital', 'capital_adjustment', '33', 3, 'common', 9850), + $c('98600', '파생상품평가손실', 'capital', 'capital_adjustment', '33', 3, 'common', 9860), + + // ================================================================ + // 수익 — 매출 추가 (parent: '41') + // ================================================================ + $c('40200', '매출환입및에누리(상품)', 'revenue', 'sales_revenue', '41', 3, 'common', 4020), + $c('40300', '매출할인(상품)', 'revenue', 'sales_revenue', '41', 3, 'common', 4030), + $c('40500', '매출환입및에누리(제품)', 'revenue', 'sales_revenue', '41', 3, 'common', 4050), + $c('40600', '매출할인(제품)', 'revenue', 'sales_revenue', '41', 3, 'common', 4060), + $c('40800', '매출할인(공사)', 'revenue', 'sales_revenue', '41', 3, 'common', 4080), + $c('40900', '완성건물매출', 'revenue', 'sales_revenue', '41', 3, 'common', 4090), + + // ================================================================ + // 수익 — 영업외수익 추가 (parent: '42') + // ================================================================ + $c('90200', '만기보유증권이자', 'revenue', 'other_revenue', '42', 3, 'common', 9020), + $c('90500', '단기투자자산평가이익', 'revenue', 'other_revenue', '42', 3, 'common', 9050), + $c('90600', '단기투자자산처분이익', 'revenue', 'other_revenue', '42', 3, 'common', 9060), + $c('90800', '대손충당금환입', 'revenue', 'other_revenue', '42', 3, 'common', 9080), + $c('90900', '수입수수료', 'revenue', 'other_revenue', '42', 3, 'common', 9090), + $c('91000', '외화환산이익', 'revenue', 'other_revenue', '42', 3, 'common', 9100), + $c('91100', '사채상환이익', 'revenue', 'other_revenue', '42', 3, 'common', 9110), + $c('91200', '전기오류수정이익', 'revenue', 'other_revenue', '42', 3, 'common', 9120), + $c('91300', '하자보수충당금환입', 'revenue', 'other_revenue', '42', 3, 'common', 9130), + $c('91400', '유형자산처분이익', 'revenue', 'other_revenue', '42', 3, 'common', 9140), + $c('91500', '투자자산처분이익', 'revenue', 'other_revenue', '42', 3, 'common', 9150), + $c('91600', '상각채권추심이익', 'revenue', 'other_revenue', '42', 3, 'common', 9160), + $c('91700', '자산수증이익', 'revenue', 'other_revenue', '42', 3, 'common', 9170), + $c('91800', '채무면제이익', 'revenue', 'other_revenue', '42', 3, 'common', 9180), + $c('92000', '투자증권손상차환입', 'revenue', 'other_revenue', '42', 3, 'common', 9200), + $c('92100', '지분법이익', 'revenue', 'other_revenue', '42', 3, 'common', 9210), + $c('92400', '중소투자준비금환입', 'revenue', 'other_revenue', '42', 3, 'common', 9240), + $c('92500', '기술개발준비금환입', 'revenue', 'other_revenue', '42', 3, 'common', 9250), + $c('92600', '해외개척준비금환입', 'revenue', 'other_revenue', '42', 3, 'common', 9260), + $c('92700', '지방이전준비금환입', 'revenue', 'other_revenue', '42', 3, 'common', 9270), + $c('92800', '수출손실준비금환입', 'revenue', 'other_revenue', '42', 3, 'common', 9280), + + // ================================================================ + // 비용 — 매출원가 추가: 45xxx (parent: '51') + // ================================================================ + $c('45100', '상품매출원가', 'expense', 'cogs', '51', 3, 'common', 4510), + $c('45200', '도급공사매출원가', 'expense', 'cogs', '51', 3, 'common', 4520), + $c('45300', '분양공사매출원가', 'expense', 'cogs', '51', 3, 'common', 4530), + $c('45500', '제품매출원가', 'expense', 'cogs', '51', 3, 'common', 4550), + + // ================================================================ + // 비용 — 제조원가 추가: 50xxx-53xxx (parent: '51') + // ================================================================ + $c('50600', '제수당(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5060), + $c('50700', '잡급(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5070), + $c('50900', '퇴직보험충당금전입(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5090), + $c('51000', '퇴직금여(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5100), + $c('51500', '가스수도료(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5150), + $c('52300', '경상연구개발비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5230), + $c('52500', '교육훈련비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5250), + $c('52600', '도서인쇄비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5260), + $c('52700', '회의비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5270), + $c('52800', '포장비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5280), + $c('52900', '사무용품비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5290), + $c('53200', '보관료(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5320), + $c('53300', '외주가공비(제조경비)', 'expense', 'cogs', '51', 3, 'manufacturing', 5330), + $c('53400', '시험비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5340), + $c('53500', '기밀비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5350), + $c('53600', '잡비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5360), + $c('53700', '폐기물처리비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5370), + + // ================================================================ + // 비용 — 건설원가 60xxx (parent: '54') + // ================================================================ + $c('60100', '원재료비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6010), + $c('60200', '외주비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6020), + $c('60300', '급여(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6030), + $c('60400', '임금(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6040), + $c('60500', '상여금(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6050), + $c('60600', '잡급(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6060), + $c('60700', '퇴직급여(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6070), + $c('60800', '퇴직보험충당금전입(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6080), + $c('60900', '퇴직금여(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6090), + $c('61000', '중기및운반비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6100), + $c('61100', '복리후생비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6110), + $c('61200', '여비교통비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6120), + $c('61300', '접대비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6130), + $c('61400', '통신비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6140), + $c('61500', '가스수도료(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6150), + $c('61600', '전력비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6160), + $c('61700', '세금과공과금(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6170), + $c('61800', '감가상각비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6180), + $c('61900', '지급임차료(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6190), + $c('62000', '수선비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6200), + $c('62100', '보험료(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6210), + $c('62200', '차량유지비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6220), + $c('62300', '운반비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6230), + $c('62400', '잡자재대(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6240), + $c('62500', '교육훈련비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6250), + $c('62600', '도서인쇄비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6260), + $c('62700', '회의비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6270), + $c('62800', '포장비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6280), + $c('62900', '사무용품비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6290), + $c('63000', '소모품비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6300), + $c('63100', '지급수수료(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6310), + $c('63200', '보관료(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6320), + $c('63300', '외주용역비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6330), + $c('63400', '장비사용료(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6340), + $c('63500', '설계용역비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6350), + $c('63600', '광고선전비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6360), + $c('63700', '소모공구비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6370), + $c('63800', '외주시공비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6380), + $c('63900', '협비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6390), + $c('64000', '잡비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6400), + $c('64100', '공사손실충당금전입(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6410), + $c('64200', '공사손실충당금환입(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6420), + $c('64300', '외주비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 6430), + $c('64400', '유류비(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 6440), + + // ================================================================ + // 비용 — 건설원가 70xxx (parent: '54') + // ================================================================ + $c('70100', '원재료비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7010), + $c('70200', '중기및운반비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7020), + $c('70300', '급여(건설노무비)', 'expense', 'construction_cost', '54', 3, 'construction', 7030), + $c('70400', '임금(건설노무비)', 'expense', 'construction_cost', '54', 3, 'construction', 7040), + $c('70500', '상여금(건설노무비)', 'expense', 'construction_cost', '54', 3, 'construction', 7050), + $c('70600', '제수당(건설노무비)', 'expense', 'construction_cost', '54', 3, 'construction', 7060), + $c('70700', '퇴직급여(건설노무비)', 'expense', 'construction_cost', '54', 3, 'construction', 7070), + $c('70800', '퇴직보험충당금전입(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7080), + $c('70900', '퇴직금여(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7090), + $c('71000', '건설용지비', 'expense', 'construction_cost', '54', 3, 'construction', 7100), + $c('71100', '복리후생비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7110), + $c('71200', '여비교통비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7120), + $c('71300', '접대비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7130), + $c('71400', '통신비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7140), + $c('71500', '가스수도료(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7150), + $c('71600', '전력비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7160), + $c('71700', '세금과공과금(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7170), + $c('71800', '감가상각비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7180), + $c('71900', '지급임차료(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7190), + $c('72000', '수선비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7200), + $c('72100', '보험료(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7210), + $c('72200', '차량유지비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7220), + $c('72300', '경상연구개발비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7230), + $c('72400', '잡자재대(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7240), + $c('72500', '교육훈련비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7250), + $c('72600', '도서인쇄비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7260), + $c('72700', '회의비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7270), + $c('72800', '포장비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7280), + $c('72900', '사무용품비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7290), + $c('73000', '소모품비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7300), + $c('73100', '지급수수료(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7310), + $c('73200', '보관료(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7320), + $c('73300', '외주가공비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7330), + $c('73400', '시험비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7340), + $c('73500', '설계용역비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7350), + $c('73600', '가설재손료(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7360), + $c('73700', '잡비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7370), + $c('73800', '폐기물처리비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7380), + $c('73900', '장비사용료(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7390), + $c('74100', '공사손실충당금전입(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7410), + $c('74200', '공사손실충당금환입(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7420), + $c('74300', '외주비(건설경비)', 'expense', 'construction_cost', '54', 3, 'construction', 7430), + $c('74900', '명예퇴직금(건설)', 'expense', 'construction_cost', '54', 3, 'construction', 7490), + + // ================================================================ + // 비용 — 판관비 추가 (parent: '52') + // ================================================================ + $c('80400', '제수당', 'expense', 'selling_admin', '52', 3, 'admin', 8040), + $c('80500', '잡급', 'expense', 'selling_admin', '52', 3, 'admin', 8050), + $c('80700', '퇴직보험충당금전입', 'expense', 'selling_admin', '52', 3, 'admin', 8070), + $c('80800', '퇴직금여', 'expense', 'selling_admin', '52', 3, 'admin', 8080), + $c('81600', '전력비', 'expense', 'selling_admin', '52', 3, 'admin', 8160), + $c('82800', '포장비', 'expense', 'selling_admin', '52', 3, 'admin', 8280), + $c('83400', '판매촉진비', 'expense', 'selling_admin', '52', 3, 'admin', 8340), + $c('83600', '기밀비', 'expense', 'selling_admin', '52', 3, 'admin', 8360), + $c('83700', '건물관리비', 'expense', 'selling_admin', '52', 3, 'admin', 8370), + $c('83800', '수출제비용', 'expense', 'selling_admin', '52', 3, 'admin', 8380), + $c('83900', '판매수수료', 'expense', 'selling_admin', '52', 3, 'admin', 8390), + $c('84000', '무형고정자산상각', 'expense', 'selling_admin', '52', 3, 'admin', 8400), + $c('84100', '환가료', 'expense', 'selling_admin', '52', 3, 'admin', 8410), + $c('84200', '견본비', 'expense', 'selling_admin', '52', 3, 'admin', 8420), + $c('84300', '해외접대비', 'expense', 'selling_admin', '52', 3, 'admin', 8430), + $c('84400', '해외시장개척비', 'expense', 'selling_admin', '52', 3, 'admin', 8440), + $c('84500', '미분양주택관리비', 'expense', 'selling_admin', '52', 3, 'admin', 8450), + $c('84600', '수주비', 'expense', 'selling_admin', '52', 3, 'admin', 8460), + $c('84700', '하자보수충당금전입', 'expense', 'selling_admin', '52', 3, 'admin', 8470), + $c('84900', '명예퇴직금', 'expense', 'selling_admin', '52', 3, 'admin', 8490), + + // ================================================================ + // 비용 — 영업외비용 추가 (parent: '53') + // ================================================================ + $c('93400', '기타의대손상각비', 'expense', 'other_expense', '53', 3, 'common', 9340), + $c('93500', '외화환산손실', 'expense', 'other_expense', '53', 3, 'common', 9350), + $c('93600', '매출채권처분손실', 'expense', 'other_expense', '53', 3, 'common', 9360), + $c('93700', '단기투자자산평가손실', 'expense', 'other_expense', '53', 3, 'common', 9370), + $c('93800', '단기투자자산처분손실', 'expense', 'other_expense', '53', 3, 'common', 9380), + $c('93900', '재고자산감모손실', 'expense', 'other_expense', '53', 3, 'common', 9390), + $c('94000', '재고자산평가손실', 'expense', 'other_expense', '53', 3, 'common', 9400), + $c('94100', '재해손실', 'expense', 'other_expense', '53', 3, 'common', 9410), + $c('94200', '전기오류수정손실', 'expense', 'other_expense', '53', 3, 'common', 9420), + $c('94300', '투자증권손상차손', 'expense', 'other_expense', '53', 3, 'common', 9430), + $c('94700', '사채상환손실', 'expense', 'other_expense', '53', 3, 'common', 9470), + $c('95000', '투자자산처분손실', 'expense', 'other_expense', '53', 3, 'common', 9500), + $c('95100', '중소투자준비금전입', 'expense', 'other_expense', '53', 3, 'common', 9510), + $c('95200', '기술개발준비금전입', 'expense', 'other_expense', '53', 3, 'common', 9520), + $c('95300', '해외개척준비금전입', 'expense', 'other_expense', '53', 3, 'common', 9530), + $c('95400', '지방이전준비금전입', 'expense', 'other_expense', '53', 3, 'common', 9540), + $c('95500', '수출손실준비금전입', 'expense', 'other_expense', '53', 3, 'common', 9550), + $c('95700', '특별상각', 'expense', 'other_expense', '53', 3, 'common', 9570), + // 중단사업 + $c('99100', '사업중단직접비', 'expense', 'other_expense', '53', 3, 'common', 9910), + $c('99200', '중단사업자산손상차손', 'expense', 'other_expense', '53', 3, 'common', 9920), + $c('99300', '중단사업손상차환입', 'expense', 'other_expense', '53', 3, 'common', 9930), + $c('99700', '중단손익', 'expense', 'other_expense', '53', 3, 'common', 9970), + ]; + } +}; From e6f13e387068ed67e3c7b9a5c04a15724ec7c83e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Mon, 9 Mar 2026 11:21:20 +0900 Subject: [PATCH 114/166] =?UTF-8?q?refactor:=20[=EC=84=B8=EA=B8=88?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EC=84=9C/=EB=B0=94=EB=A1=9C=EB=B9=8C]=20ApiR?= =?UTF-8?q?esponse::handle()=20=ED=81=B4=EB=A1=9C=EC=A0=80=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BarobillSettingController: show/save/testConnection 클로저 방식 전환 - TaxInvoiceController: 전체 액션(index/show/store/update/destroy/issue/bulkIssue/cancel/checkStatus/summary) 클로저 방식 전환 - 중간 변수 할당 제거, 일관된 응답 패턴 적용 Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/BarobillSettingController.php | 27 ++---- .../Api/V1/TaxInvoiceController.php | 91 +++++++------------ 2 files changed, 40 insertions(+), 78 deletions(-) diff --git a/app/Http/Controllers/Api/V1/BarobillSettingController.php b/app/Http/Controllers/Api/V1/BarobillSettingController.php index 9980397..ba16b88 100644 --- a/app/Http/Controllers/Api/V1/BarobillSettingController.php +++ b/app/Http/Controllers/Api/V1/BarobillSettingController.php @@ -18,12 +18,9 @@ public function __construct( */ public function show() { - $setting = $this->barobillService->getSetting(); - - return ApiResponse::handle( - data: $setting, - message: __('message.fetched') - ); + return ApiResponse::handle(function () { + return $this->barobillService->getSetting(); + }, __('message.fetched')); } /** @@ -31,12 +28,9 @@ public function show() */ public function save(SaveBarobillSettingRequest $request) { - $setting = $this->barobillService->saveSetting($request->validated()); - - return ApiResponse::handle( - data: $setting, - message: __('message.saved') - ); + return ApiResponse::handle(function () use ($request) { + return $this->barobillService->saveSetting($request->validated()); + }, __('message.saved')); } /** @@ -44,11 +38,8 @@ public function save(SaveBarobillSettingRequest $request) */ public function testConnection() { - $result = $this->barobillService->testConnection(); - - return ApiResponse::handle( - data: $result, - message: __('message.barobill.connection_success') - ); + return ApiResponse::handle(function () { + return $this->barobillService->testConnection(); + }, __('message.barobill.connection_success')); } } diff --git a/app/Http/Controllers/Api/V1/TaxInvoiceController.php b/app/Http/Controllers/Api/V1/TaxInvoiceController.php index 08dcdb2..1d65ada 100644 --- a/app/Http/Controllers/Api/V1/TaxInvoiceController.php +++ b/app/Http/Controllers/Api/V1/TaxInvoiceController.php @@ -28,12 +28,9 @@ public function __construct( */ public function index(TaxInvoiceListRequest $request) { - $taxInvoices = $this->taxInvoiceService->list($request->validated()); - - return ApiResponse::handle( - data: $taxInvoices, - message: __('message.fetched') - ); + return ApiResponse::handle(function () use ($request) { + return $this->taxInvoiceService->list($request->validated()); + }, __('message.fetched')); } /** @@ -41,12 +38,9 @@ public function index(TaxInvoiceListRequest $request) */ public function show(int $id) { - $taxInvoice = $this->taxInvoiceService->show($id); - - return ApiResponse::handle( - data: $taxInvoice, - message: __('message.fetched') - ); + return ApiResponse::handle(function () use ($id) { + return $this->taxInvoiceService->show($id); + }, __('message.fetched')); } /** @@ -54,13 +48,9 @@ public function show(int $id) */ public function store(CreateTaxInvoiceRequest $request) { - $taxInvoice = $this->taxInvoiceService->create($request->validated()); - - return ApiResponse::handle( - data: $taxInvoice, - message: __('message.created'), - status: 201 - ); + return ApiResponse::handle(function () use ($request) { + return $this->taxInvoiceService->create($request->validated()); + }, __('message.created')); } /** @@ -68,12 +58,9 @@ public function store(CreateTaxInvoiceRequest $request) */ public function update(UpdateTaxInvoiceRequest $request, int $id) { - $taxInvoice = $this->taxInvoiceService->update($id, $request->validated()); - - return ApiResponse::handle( - data: $taxInvoice, - message: __('message.updated') - ); + return ApiResponse::handle(function () use ($request, $id) { + return $this->taxInvoiceService->update($id, $request->validated()); + }, __('message.updated')); } /** @@ -81,12 +68,11 @@ public function update(UpdateTaxInvoiceRequest $request, int $id) */ public function destroy(int $id) { - $this->taxInvoiceService->delete($id); + return ApiResponse::handle(function () use ($id) { + $this->taxInvoiceService->delete($id); - return ApiResponse::handle( - data: null, - message: __('message.deleted') - ); + return null; + }, __('message.deleted')); } /** @@ -94,12 +80,9 @@ public function destroy(int $id) */ public function issue(int $id) { - $taxInvoice = $this->taxInvoiceService->issue($id); - - return ApiResponse::handle( - data: $taxInvoice, - message: __('message.tax_invoice.issued') - ); + return ApiResponse::handle(function () use ($id) { + return $this->taxInvoiceService->issue($id); + }, __('message.tax_invoice.issued')); } /** @@ -107,12 +90,9 @@ public function issue(int $id) */ public function bulkIssue(BulkIssueRequest $request) { - $result = $this->taxInvoiceService->bulkIssue($request->getIds()); - - return ApiResponse::handle( - data: $result, - message: __('message.tax_invoice.bulk_issued') - ); + return ApiResponse::handle(function () use ($request) { + return $this->taxInvoiceService->bulkIssue($request->getIds()); + }, __('message.tax_invoice.bulk_issued')); } /** @@ -120,12 +100,9 @@ public function bulkIssue(BulkIssueRequest $request) */ public function cancel(CancelTaxInvoiceRequest $request, int $id) { - $taxInvoice = $this->taxInvoiceService->cancel($id, $request->validated()['reason']); - - return ApiResponse::handle( - data: $taxInvoice, - message: __('message.tax_invoice.cancelled') - ); + return ApiResponse::handle(function () use ($request, $id) { + return $this->taxInvoiceService->cancel($id, $request->validated()['reason']); + }, __('message.tax_invoice.cancelled')); } /** @@ -133,12 +110,9 @@ public function cancel(CancelTaxInvoiceRequest $request, int $id) */ public function checkStatus(int $id) { - $taxInvoice = $this->taxInvoiceService->checkStatus($id); - - return ApiResponse::handle( - data: $taxInvoice, - message: __('message.fetched') - ); + return ApiResponse::handle(function () use ($id) { + return $this->taxInvoiceService->checkStatus($id); + }, __('message.fetched')); } /** @@ -146,12 +120,9 @@ public function checkStatus(int $id) */ public function summary(TaxInvoiceSummaryRequest $request) { - $summary = $this->taxInvoiceService->summary($request->validated()); - - return ApiResponse::handle( - data: $summary, - message: __('message.fetched') - ); + return ApiResponse::handle(function () use ($request) { + return $this->taxInvoiceService->summary($request->validated()); + }, __('message.fetched')); } // ========================================================================= From c62e59ad1767d1169165613838348d4113c72653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Mon, 9 Mar 2026 11:35:39 +0900 Subject: [PATCH 115/166] =?UTF-8?q?fix:=20[=EC=84=B8=EA=B8=88=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EC=84=9C]=20=EB=A7=A4=EC=9E=85/=EB=A7=A4=EC=B6=9C=20?= =?UTF-8?q?=EB=B0=A9=ED=96=A5=EB=B3=84=20=EA=B3=B5=EA=B8=89=EC=9E=90=C2=B7?= =?UTF-8?q?=EA=B3=B5=EA=B8=89=EB=B0=9B=EB=8A=94=EC=9E=90=20=ED=95=84?= =?UTF-8?q?=EC=88=98=EA=B0=92=20=EC=A1=B0=EA=B1=B4=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 매입(purchases): supplier 정보 필수, buyer 선택 - 매출(sales): buyer 정보 필수, supplier 선택 - required → required_if:direction 조건부 검증으로 변경 Co-Authored-By: Claude Opus 4.6 --- .../Requests/TaxInvoice/CreateTaxInvoiceRequest.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/Http/Requests/TaxInvoice/CreateTaxInvoiceRequest.php b/app/Http/Requests/TaxInvoice/CreateTaxInvoiceRequest.php index d65e989..b776870 100644 --- a/app/Http/Requests/TaxInvoice/CreateTaxInvoiceRequest.php +++ b/app/Http/Requests/TaxInvoice/CreateTaxInvoiceRequest.php @@ -20,18 +20,18 @@ public function rules(): array 'issue_type' => ['required', 'string', Rule::in(TaxInvoice::ISSUE_TYPES)], 'direction' => ['required', 'string', Rule::in(TaxInvoice::DIRECTIONS)], - // 공급자 정보 - 'supplier_corp_num' => ['required', 'string', 'max:20'], - 'supplier_corp_name' => ['required', 'string', 'max:100'], + // 공급자 정보 (매입 시 필수, 매출 시 선택) + 'supplier_corp_num' => ['required_if:direction,purchases', 'nullable', 'string', 'max:20'], + 'supplier_corp_name' => ['required_if:direction,purchases', 'nullable', 'string', 'max:100'], 'supplier_ceo_name' => ['nullable', 'string', 'max:50'], 'supplier_addr' => ['nullable', 'string', 'max:200'], 'supplier_biz_type' => ['nullable', 'string', 'max:100'], 'supplier_biz_class' => ['nullable', 'string', 'max:100'], 'supplier_contact_id' => ['nullable', 'string', 'email', 'max:100'], - // 공급받는자 정보 - 'buyer_corp_num' => ['required', 'string', 'max:20'], - 'buyer_corp_name' => ['required', 'string', 'max:100'], + // 공급받는자 정보 (매출 시 필수, 매입 시 선택) + 'buyer_corp_num' => ['required_if:direction,sales', 'nullable', 'string', 'max:20'], + 'buyer_corp_name' => ['required_if:direction,sales', 'nullable', 'string', 'max:100'], 'buyer_ceo_name' => ['nullable', 'string', 'max:50'], 'buyer_addr' => ['nullable', 'string', 'max:200'], 'buyer_biz_type' => ['nullable', 'string', 'max:100'], From 1861f4daf200b5b041dd8a213e18dc2a83ec245c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Mon, 9 Mar 2026 12:40:08 +0900 Subject: [PATCH 116/166] =?UTF-8?q?fix:=20[=EC=84=B8=EA=B8=88=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EC=84=9C]=20NOT=20NULL=20=EC=BB=AC=EB=9F=BC=20null=20?= =?UTF-8?q?=EB=B0=A9=EC=96=B4=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - supplier/buyer corp_num, corp_name null→빈문자열 보정 - Laravel ConvertEmptyStringsToNull 미들웨어로 인한 DB 에러 방지 Co-Authored-By: Claude Opus 4.6 --- app/Services/TaxInvoiceService.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/Services/TaxInvoiceService.php b/app/Services/TaxInvoiceService.php index 19cd6c8..58718ac 100644 --- a/app/Services/TaxInvoiceService.php +++ b/app/Services/TaxInvoiceService.php @@ -112,6 +112,12 @@ public function create(array $data): TaxInvoice // 합계금액 계산 $data['total_amount'] = ($data['supply_amount'] ?? 0) + ($data['tax_amount'] ?? 0); + // NOT NULL 컬럼: Laravel ConvertEmptyStringsToNull 미들웨어가 ''→null 변환하므로 보정 + $data['supplier_corp_num'] = $data['supplier_corp_num'] ?? ''; + $data['supplier_corp_name'] = $data['supplier_corp_name'] ?? ''; + $data['buyer_corp_num'] = $data['buyer_corp_num'] ?? ''; + $data['buyer_corp_name'] = $data['buyer_corp_name'] ?? ''; + $taxInvoice = TaxInvoice::create(array_merge($data, [ 'tenant_id' => $tenantId, 'status' => TaxInvoice::STATUS_DRAFT, From 60c4256bd0453050b12b7233f65539de25634135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Mon, 9 Mar 2026 13:20:30 +0900 Subject: [PATCH 117/166] =?UTF-8?q?feat:=20[=EB=B3=B5=EB=A6=AC=ED=9B=84?= =?UTF-8?q?=EC=83=9D]=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=BB=A4=EC=8A=A4=ED=85=80=20=EB=82=A0=EC=A7=9C=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=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 - start_date, end_date 쿼리 파라미터 추가 - 커스텀 날짜 범위 지정 시 해당 범위로 일별 사용 내역 조회 - 미지정 시 기존 분기 기준 유지 Co-Authored-By: Claude Opus 4.6 --- app/Http/Controllers/Api/V1/WelfareController.php | 8 ++++++-- app/Services/WelfareService.php | 10 +++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/app/Http/Controllers/Api/V1/WelfareController.php b/app/Http/Controllers/Api/V1/WelfareController.php index 64795f4..055bfcd 100644 --- a/app/Http/Controllers/Api/V1/WelfareController.php +++ b/app/Http/Controllers/Api/V1/WelfareController.php @@ -61,14 +61,18 @@ public function detail(Request $request): JsonResponse : 0.05; $year = $request->query('year') ? (int) $request->query('year') : null; $quarter = $request->query('quarter') ? (int) $request->query('quarter') : null; + $startDate = $request->query('start_date'); + $endDate = $request->query('end_date'); - return ApiResponse::handle(function () use ($calculationType, $fixedAmountPerMonth, $ratio, $year, $quarter) { + return ApiResponse::handle(function () use ($calculationType, $fixedAmountPerMonth, $ratio, $year, $quarter, $startDate, $endDate) { return $this->welfareService->getDetail( $calculationType, $fixedAmountPerMonth, $ratio, $year, - $quarter + $quarter, + $startDate, + $endDate ); }, __('message.fetched')); } diff --git a/app/Services/WelfareService.php b/app/Services/WelfareService.php index 6231f9f..683ba0d 100644 --- a/app/Services/WelfareService.php +++ b/app/Services/WelfareService.php @@ -498,7 +498,9 @@ public function getDetail( ?int $fixedAmountPerMonth = 200000, ?float $ratio = 0.05, ?int $year = null, - ?int $quarter = null + ?int $quarter = null, + ?string $startDate = null, + ?string $endDate = null ): array { $tenantId = $this->tenantId(); $now = Carbon::now(); @@ -562,8 +564,10 @@ public function getDetail( // 3. 항목별 분포 $categoryDistribution = $this->getCategoryDistribution($tenantId, $annualStartDate, $annualEndDate); - // 4. 일별 사용 내역 - $transactions = $this->getTransactions($tenantId, $quarterStartDate, $quarterEndDate); + // 4. 일별 사용 내역 (커스텀 날짜 범위가 있으면 해당 범위, 없으면 분기 기준) + $txStartDate = $startDate ?? $quarterStartDate; + $txEndDate = $endDate ?? $quarterEndDate; + $transactions = $this->getTransactions($tenantId, $txStartDate, $txEndDate); // 5. 계산 정보 $calculation = [ From 56c60ec3dfd8c23d8b1b7c51811d7980374aeb0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Mon, 9 Mar 2026 16:32:58 +0900 Subject: [PATCH 118/166] =?UTF-8?q?feat:=20[=ED=98=84=ED=99=A9=ED=8C=90/?= =?UTF-8?q?=EC=95=85=EC=84=B1=EC=B1=84=EA=B6=8C]=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=EB=B3=84=20sub=5Flabel(=EB=8C=80=ED=91=9C=20=EA=B1=B0=EB=9E=98?= =?UTF-8?q?=EC=B2=98=EB=AA=85=20+=20=EA=B1=B4=EC=88=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 - BadDebtService: summary에 카드별(전체/추심중/법적조치/회수완료) sub_labels 추가 - StatusBoardService: 악성채권·신규거래처·결재 카드에 sub_label 추가 - 악성채권: 최다 금액 거래처명 - 신규거래처: 최근 등록 업체명 - 결재: 최근 결재 제목 Co-Authored-By: Claude Opus 4.6 --- app/Services/BadDebtService.php | 50 ++++++++++++++++++++ app/Services/StatusBoardService.php | 73 ++++++++++++++++++++++++----- 2 files changed, 111 insertions(+), 12 deletions(-) diff --git a/app/Services/BadDebtService.php b/app/Services/BadDebtService.php index 9b04565..063155c 100644 --- a/app/Services/BadDebtService.php +++ b/app/Services/BadDebtService.php @@ -110,6 +110,9 @@ public function summary(array $params = []): array ->distinct('client_id') ->count('client_id'); + // per-card sub_label: 각 상태별 최다 금액 거래처명 + 건수 + $subLabels = $this->buildPerCardSubLabels($query); + return [ 'total_amount' => (float) $totalAmount, 'collecting_amount' => (float) $collectingAmount, @@ -117,9 +120,56 @@ public function summary(array $params = []): array 'recovered_amount' => (float) $recoveredAmount, 'bad_debt_amount' => (float) $badDebtAmount, 'client_count' => $clientCount, + 'sub_labels' => $subLabels, ]; } + /** + * 카드별 sub_label 생성 (최다 금액 거래처명 + 건수) + */ + private function buildPerCardSubLabels($baseQuery): array + { + $result = []; + $statusScopes = [ + 'dc1' => null, // 전체 (누적) + 'dc2' => 'collecting', // 추심중 + 'dc3' => 'legalAction', // 법적조치 + 'dc4' => 'recovered', // 회수완료 + ]; + + foreach ($statusScopes as $cardId => $scope) { + $q = clone $baseQuery; + if ($scope) { + $q = $q->$scope(); + } + + $clientCount = (clone $q)->distinct('client_id')->count('client_id'); + + if ($clientCount <= 0) { + $result[$cardId] = null; + + continue; + } + + $topClient = (clone $q) + ->join('clients', 'bad_debts.client_id', '=', 'clients.id') + ->selectRaw('clients.name, SUM(bad_debts.debt_amount) as total_amount') + ->groupBy('clients.id', 'clients.name') + ->orderByDesc('total_amount') + ->first(); + + if ($topClient) { + $result[$cardId] = $clientCount > 1 + ? $topClient->name.' 외 '.($clientCount - 1).'건' + : $topClient->name; + } else { + $result[$cardId] = null; + } + } + + return $result; + } + /** * 악성채권 상세 조회 */ diff --git a/app/Services/StatusBoardService.php b/app/Services/StatusBoardService.php index b38dca5..4a26290 100644 --- a/app/Services/StatusBoardService.php +++ b/app/Services/StatusBoardService.php @@ -67,16 +67,35 @@ private function getOrdersStatus(int $tenantId, Carbon $today): array */ private function getBadDebtStatus(int $tenantId): array { - $count = BadDebt::query() + $query = BadDebt::query() ->where('tenant_id', $tenantId) - ->where('status', BadDebt::STATUS_COLLECTING) // 추심 진행 중 - ->where('is_active', true) // 활성 채권만 (목록 페이지와 일치) - ->count(); + ->where('status', BadDebt::STATUS_COLLECTING) + ->where('is_active', true); + + $count = (clone $query)->count(); + + // 최다 금액 거래처명 조회 + $subLabel = null; + if ($count > 0) { + $topClient = (clone $query) + ->join('clients', 'bad_debts.client_id', '=', 'clients.id') + ->selectRaw('clients.name, SUM(bad_debts.debt_amount) as total_amount') + ->groupBy('clients.id', 'clients.name') + ->orderByDesc('total_amount') + ->first(); + + if ($topClient) { + $subLabel = $count > 1 + ? $topClient->name.' 외 '.($count - 1).'건' + : $topClient->name; + } + } return [ 'id' => 'bad_debts', 'label' => __('message.status_board.bad_debts'), 'count' => $count, + 'sub_label' => $subLabel, 'path' => '/accounting/bad-debt-collection', 'isHighlighted' => false, ]; @@ -152,15 +171,31 @@ private function getTaxDeadlineStatus(int $tenantId, Carbon $today): array */ private function getNewClientStatus(int $tenantId, Carbon $today): array { - $count = Client::query() + $query = Client::query() ->where('tenant_id', $tenantId) - ->where('created_at', '>=', $today->copy()->subDays(7)) - ->count(); + ->where('created_at', '>=', $today->copy()->subDays(7)); + + $count = (clone $query)->count(); + + // 가장 최근 등록 업체명 조회 + $subLabel = null; + if ($count > 0) { + $latestClient = (clone $query) + ->orderByDesc('created_at') + ->first(); + + if ($latestClient) { + $subLabel = $count > 1 + ? $latestClient->name.' 외 '.($count - 1).'건' + : $latestClient->name; + } + } return [ 'id' => 'new_clients', 'label' => __('message.status_board.new_clients'), 'count' => $count, + 'sub_label' => $subLabel, 'path' => '/accounting/vendors', 'isHighlighted' => false, ]; @@ -211,19 +246,33 @@ private function getPurchaseStatus(int $tenantId): array */ private function getApprovalStatus(int $tenantId, int $userId): array { - $count = ApprovalStep::query() - ->whereHas('approval', function ($query) use ($tenantId) { - $query->where('tenant_id', $tenantId) + $query = ApprovalStep::query() + ->whereHas('approval', function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId) ->where('status', 'pending'); }) ->where('approver_id', $userId) - ->where('status', 'pending') - ->count(); + ->where('status', 'pending'); + + $count = (clone $query)->count(); + + // 최근 결재 유형 조회 + $subLabel = null; + if ($count > 0) { + $latestStep = (clone $query)->with('approval')->latest()->first(); + if ($latestStep && $latestStep->approval) { + $typeLabel = $latestStep->approval->title ?? '결재'; + $subLabel = $count > 1 + ? $typeLabel.' 외 '.($count - 1).'건' + : $typeLabel; + } + } return [ 'id' => 'approvals', 'label' => __('message.status_board.approvals'), 'count' => $count, + 'sub_label' => $subLabel, 'path' => '/approval/inbox', 'isHighlighted' => $count > 0, ]; From 3929c5fd1e34a0dad0f6b556e30f8b3fae72a7a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Mon, 9 Mar 2026 16:38:17 +0900 Subject: [PATCH 119/166] =?UTF-8?q?fix:=20[=EC=95=85=EC=84=B1=EC=B1=84?= =?UTF-8?q?=EA=B6=8C]=20tenant=5Fid=20=EC=BB=AC=EB=9F=BC=20ambiguous=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JOIN 쿼리에서 bad_debts.tenant_id로 테이블 명시 - BadDebtService, StatusBoardService 동일 수정 Co-Authored-By: Claude Opus 4.6 --- app/Services/BadDebtService.php | 2 +- app/Services/StatusBoardService.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Services/BadDebtService.php b/app/Services/BadDebtService.php index 063155c..abf2724 100644 --- a/app/Services/BadDebtService.php +++ b/app/Services/BadDebtService.php @@ -86,7 +86,7 @@ public function summary(array $params = []): array // is_active=true인 악성채권만 통계 $query = BadDebt::query() - ->where('tenant_id', $tenantId) + ->where('bad_debts.tenant_id', $tenantId) ->where('is_active', true); // 거래처 필터 diff --git a/app/Services/StatusBoardService.php b/app/Services/StatusBoardService.php index 4a26290..837d109 100644 --- a/app/Services/StatusBoardService.php +++ b/app/Services/StatusBoardService.php @@ -68,7 +68,7 @@ private function getOrdersStatus(int $tenantId, Carbon $today): array private function getBadDebtStatus(int $tenantId): array { $query = BadDebt::query() - ->where('tenant_id', $tenantId) + ->where('bad_debts.tenant_id', $tenantId) ->where('status', BadDebt::STATUS_COLLECTING) ->where('is_active', true); From ca259ccb1800b879737cabbc73cda72f73bbb1ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Mon, 9 Mar 2026 16:43:12 +0900 Subject: [PATCH 120/166] =?UTF-8?q?fix:=20[=EC=95=85=EC=84=B1=EC=B1=84?= =?UTF-8?q?=EA=B6=8C]=20JOIN=20=EC=BF=BC=EB=A6=AC=20=EB=82=98=EB=A8=B8?= =?UTF-8?q?=EC=A7=80=20=EC=BB=AC=EB=9F=BC=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?prefix=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - is_active, status 컬럼에도 bad_debts. prefix 추가 - BadDebtService, StatusBoardService 동일 수정 Co-Authored-By: Claude Opus 4.6 --- app/Services/BadDebtService.php | 2 +- app/Services/StatusBoardService.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/Services/BadDebtService.php b/app/Services/BadDebtService.php index abf2724..b4383e0 100644 --- a/app/Services/BadDebtService.php +++ b/app/Services/BadDebtService.php @@ -87,7 +87,7 @@ public function summary(array $params = []): array // is_active=true인 악성채권만 통계 $query = BadDebt::query() ->where('bad_debts.tenant_id', $tenantId) - ->where('is_active', true); + ->where('bad_debts.is_active', true); // 거래처 필터 if (! empty($params['client_id'])) { diff --git a/app/Services/StatusBoardService.php b/app/Services/StatusBoardService.php index 837d109..48d3875 100644 --- a/app/Services/StatusBoardService.php +++ b/app/Services/StatusBoardService.php @@ -69,8 +69,8 @@ private function getBadDebtStatus(int $tenantId): array { $query = BadDebt::query() ->where('bad_debts.tenant_id', $tenantId) - ->where('status', BadDebt::STATUS_COLLECTING) - ->where('is_active', true); + ->where('bad_debts.status', BadDebt::STATUS_COLLECTING) + ->where('bad_debts.is_active', true); $count = (clone $query)->count(); From ee9f4d0b8f932d747786ec9b8a3c175d4d46a730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Mon, 9 Mar 2026 19:00:40 +0900 Subject: [PATCH 121/166] =?UTF-8?q?fix:=20[=ED=98=84=ED=99=A9=ED=8C=90]=20?= =?UTF-8?q?=EA=B2=B0=EC=9E=AC=20=EC=B9=B4=EB=93=9C=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=97=90=20approvalOnly=20=EC=8A=A4=EC=BD=94=ED=94=84=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 - ApprovalStep 쿼리에 approvalOnly() 스코프 적용 - 결재 유형만 필터링되도록 보정 Co-Authored-By: Claude Opus 4.6 --- app/Services/StatusBoardService.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Services/StatusBoardService.php b/app/Services/StatusBoardService.php index 48d3875..1d3545f 100644 --- a/app/Services/StatusBoardService.php +++ b/app/Services/StatusBoardService.php @@ -252,7 +252,8 @@ private function getApprovalStatus(int $tenantId, int $userId): array ->where('status', 'pending'); }) ->where('approver_id', $userId) - ->where('status', 'pending'); + ->where('status', 'pending') + ->approvalOnly(); $count = (clone $query)->count(); From 3fc5f511bc08bf26c933c9affad983efaab98ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Mon, 9 Mar 2026 17:43:15 +0900 Subject: [PATCH 122/166] =?UTF-8?q?feat:=20[quality]=20=EA=B2=80=EC=82=AC?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EC=9E=90=EB=8F=99=20=EC=9E=AC=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20+=20=EC=88=98=EC=A3=BC=EC=B2=98=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 개소별 inspection_status를 검사 데이터 내용 기반으로 자동 판정 (15개 판정필드 + 사진 유무 → pending/in_progress/completed) - 문서 status를 개소 상태 집계로 자동 재계산 - inspectLocation, updateLocations 모두 적용 - QualityDocumentLocation에 STATUS_IN_PROGRESS 상수 추가 - transformToFrontend에 client_id 매핑 추가 --- .../Qualitys/QualityDocumentLocation.php | 2 + app/Services/QualityDocumentService.php | 96 +++++++++++++++++-- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/app/Models/Qualitys/QualityDocumentLocation.php b/app/Models/Qualitys/QualityDocumentLocation.php index 311ed9d..fd362bc 100644 --- a/app/Models/Qualitys/QualityDocumentLocation.php +++ b/app/Models/Qualitys/QualityDocumentLocation.php @@ -12,6 +12,8 @@ class QualityDocumentLocation extends Model const STATUS_PENDING = 'pending'; + const STATUS_IN_PROGRESS = 'in_progress'; + const STATUS_COMPLETED = 'completed'; protected $fillable = [ diff --git a/app/Services/QualityDocumentService.php b/app/Services/QualityDocumentService.php index e3142cd..2391b2f 100644 --- a/app/Services/QualityDocumentService.php +++ b/app/Services/QualityDocumentService.php @@ -257,6 +257,9 @@ public function update(int $id, array $data) $this->updateLocations($doc->id, $locations); } + // 개소 상태 기반 문서 상태 재계산 + $this->recalculateDocumentStatus($doc); + $this->auditLogger->log( $doc->tenant_id, self::AUDIT_TARGET, @@ -476,9 +479,87 @@ private function updateLocations(int $docId, array $locations): void if (! empty($updateData)) { $location->update($updateData); } + + // 검사 데이터 내용 기반 inspection_status 재계산 + $location->refresh(); + $newStatus = $this->determineLocationStatus($location->inspection_data); + + if ($location->inspection_status !== $newStatus) { + $location->update(['inspection_status' => $newStatus]); + } } } + /** + * 개소 상태 기반 문서 상태 재계산 + */ + private function recalculateDocumentStatus(QualityDocument $doc): void + { + $doc->load('locations'); + $total = $doc->locations->count(); + + if ($total === 0) { + $doc->update(['status' => QualityDocument::STATUS_RECEIVED]); + + return; + } + + $completedCount = $doc->locations + ->where('inspection_status', QualityDocumentLocation::STATUS_COMPLETED) + ->count(); + $inProgressCount = $doc->locations + ->where('inspection_status', QualityDocumentLocation::STATUS_IN_PROGRESS) + ->count(); + + if ($completedCount === $total) { + $doc->update(['status' => QualityDocument::STATUS_COMPLETED]); + } elseif ($completedCount > 0 || $inProgressCount > 0) { + $doc->update(['status' => QualityDocument::STATUS_IN_PROGRESS]); + } else { + $doc->update(['status' => QualityDocument::STATUS_RECEIVED]); + } + } + + /** + * 검사 데이터 내용 기반 개소 상태 판정 + * + * - 데이터 없음 or 검사항목 0개+사진 없음 → pending + * - 검사항목 일부 or 사진 없음 → in_progress + * - 15개 검사항목 전부 + 사진 있음 → completed + */ + private function determineLocationStatus(?array $inspectionData): string + { + if (empty($inspectionData)) { + return QualityDocumentLocation::STATUS_PENDING; + } + + $judgmentFields = [ + 'appearanceProcessing', 'appearanceSewing', 'appearanceAssembly', + 'appearanceSmokeBarrier', 'appearanceBottomFinish', 'motor', 'material', + 'lengthJudgment', 'heightJudgment', 'guideRailGap', 'bottomFinishGap', + 'fireResistanceTest', 'smokeLeakageTest', 'openCloseTest', 'impactTest', + ]; + + $inspected = 0; + foreach ($judgmentFields as $field) { + if (isset($inspectionData[$field]) && $inspectionData[$field] !== null && $inspectionData[$field] !== '') { + $inspected++; + } + } + + $hasPhotos = ! empty($inspectionData['productImages']) && is_array($inspectionData['productImages']) && count($inspectionData['productImages']) > 0; + + if ($inspected === 0 && ! $hasPhotos) { + return QualityDocumentLocation::STATUS_PENDING; + } + + if ($inspected < count($judgmentFields) || ! $hasPhotos) { + return QualityDocumentLocation::STATUS_IN_PROGRESS; + } + + return QualityDocumentLocation::STATUS_COMPLETED; + } + /** * 수주 동기화 (update 시 사용) */ @@ -668,6 +749,7 @@ private function transformToFrontend(QualityDocument $doc, bool $detail = false) 'id' => $doc->id, 'quality_doc_number' => $doc->quality_doc_number, 'site_name' => $doc->site_name, + 'client_id' => $doc->client_id, 'client' => $doc->client?->name ?? '', 'location_count' => $doc->locations?->count() ?? 0, 'required_info' => $this->calculateRequiredInfo($doc), @@ -784,9 +866,6 @@ public function inspectLocation(int $docId, int $locId, array $data) if (isset($data['change_reason'])) { $updateData['change_reason'] = $data['change_reason']; } - if (isset($data['inspection_status'])) { - $updateData['inspection_status'] = $data['inspection_status']; - } if (array_key_exists('inspection_data', $data)) { $updateData['inspection_data'] = $data['inspection_data']; } @@ -795,11 +874,16 @@ public function inspectLocation(int $docId, int $locId, array $data) $location->update($updateData); } - // 상태를 진행중으로 변경 (접수 상태일 때) - if ($doc->isReceived()) { - $doc->update(['status' => QualityDocument::STATUS_IN_PROGRESS]); + // 검사 데이터 기반 개소 상태 자동 판정 + $location->refresh(); + $newLocStatus = $this->determineLocationStatus($location->inspection_data); + if ($location->inspection_status !== $newLocStatus) { + $location->update(['inspection_status' => $newLocStatus]); } + // 문서 상태 재계산 + $this->recalculateDocumentStatus($doc); + return $location->fresh()->toArray(); }); } From 45a207d4a8201aa839681f0736b130c453c340db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Mon, 9 Mar 2026 21:06:10 +0900 Subject: [PATCH 123/166] =?UTF-8?q?feat:=20[=EA=B2=B0=EC=9E=AC]=20?= =?UTF-8?q?=ED=85=8C=EB=84=8C=ED=8A=B8=20=EB=B6=80=ED=8A=B8=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=9E=A9=EC=97=90=20=EA=B8=B0=EB=B3=B8=20=EA=B2=B0?= =?UTF-8?q?=EC=9E=AC=20=EC=96=91=EC=8B=9D=20=EC=9E=90=EB=8F=99=20=EC=8B=9C?= =?UTF-8?q?=EB=94=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ApprovalFormsStep 신규 생성 (proposal, expenseReport, expenseEstimate, attendance_request, reason_report) - RecipeRegistry STANDARD 레시피에 등록 - 테넌트 생성 시 TenantObserver → TenantBootstrapper로 자동 실행 - 기존 테넌트는 php artisan tenants:bootstrap --all로 적용 Co-Authored-By: Claude Opus 4.6 --- .../TenantBootstrap/RecipeRegistry.php | 2 + .../Steps/ApprovalFormsStep.php | 105 ++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 app/Services/TenantBootstrap/Steps/ApprovalFormsStep.php diff --git a/app/Services/TenantBootstrap/RecipeRegistry.php b/app/Services/TenantBootstrap/RecipeRegistry.php index 9ca16eb..cef7992 100644 --- a/app/Services/TenantBootstrap/RecipeRegistry.php +++ b/app/Services/TenantBootstrap/RecipeRegistry.php @@ -2,6 +2,7 @@ namespace App\Services\TenantBootstrap; +use App\Services\TenantBootstrap\Steps\ApprovalFormsStep; use App\Services\TenantBootstrap\Steps\CapabilityProfilesStep; use App\Services\TenantBootstrap\Steps\CategoriesStep; use App\Services\TenantBootstrap\Steps\MenusStep; @@ -24,6 +25,7 @@ public function steps(string $recipe = 'STANDARD'): array new CategoriesStep, // new MenusStep, // Disabled: Use MenuBootstrapService in RegisterService instead new SettingsStep, + new ApprovalFormsStep, ], }; } diff --git a/app/Services/TenantBootstrap/Steps/ApprovalFormsStep.php b/app/Services/TenantBootstrap/Steps/ApprovalFormsStep.php new file mode 100644 index 0000000..5127e8e --- /dev/null +++ b/app/Services/TenantBootstrap/Steps/ApprovalFormsStep.php @@ -0,0 +1,105 @@ +hasTable('approval_forms')) { + return; + } + + $now = now(); + $forms = [ + [ + 'name' => '품의서', + 'code' => 'proposal', + 'category' => '일반', + 'template' => json_encode([ + 'fields' => [ + ['name' => 'title', 'type' => 'text', 'label' => '제목', 'required' => true], + ['name' => 'vendor', 'type' => 'text', 'label' => '거래처', 'required' => false], + ['name' => 'description', 'type' => 'textarea', 'label' => '내용', 'required' => true], + ['name' => 'reason', 'type' => 'textarea', 'label' => '사유', 'required' => true], + ['name' => 'estimatedCost', 'type' => 'number', 'label' => '예상비용', 'required' => false], + ], + ]), + ], + [ + 'name' => '지출결의서', + 'code' => 'expenseReport', + 'category' => '경비', + 'template' => json_encode([ + 'fields' => [ + ['name' => 'requestDate', 'type' => 'date', 'label' => '신청일', 'required' => true], + ['name' => 'paymentDate', 'type' => 'date', 'label' => '지급일', 'required' => true], + ['name' => 'items', 'type' => 'array', 'label' => '지출항목', 'required' => true], + ['name' => 'totalAmount', 'type' => 'number', 'label' => '총액', 'required' => true], + ], + ]), + ], + [ + 'name' => '비용견적서', + 'code' => 'expenseEstimate', + 'category' => '경비', + 'template' => json_encode([ + 'fields' => [ + ['name' => 'items', 'type' => 'array', 'label' => '비용항목', 'required' => true], + ['name' => 'totalExpense', 'type' => 'number', 'label' => '총지출', 'required' => true], + ['name' => 'accountBalance', 'type' => 'number', 'label' => '계좌잔액', 'required' => true], + ], + ]), + ], + [ + 'name' => '근태신청', + 'code' => 'attendance_request', + 'category' => '일반', + 'template' => json_encode([ + 'fields' => [ + ['name' => 'user_name', 'type' => 'text', 'label' => '신청자', 'required' => true], + ['name' => 'request_type', 'type' => 'select', 'label' => '신청유형', 'required' => true], + ['name' => 'period', 'type' => 'daterange', 'label' => '기간', 'required' => true], + ['name' => 'days', 'type' => 'number', 'label' => '일수', 'required' => true], + ['name' => 'reason', 'type' => 'textarea', 'label' => '사유', 'required' => true], + ], + ]), + ], + [ + 'name' => '사유서', + 'code' => 'reason_report', + 'category' => '일반', + 'template' => json_encode([ + 'fields' => [ + ['name' => 'user_name', 'type' => 'text', 'label' => '작성자', 'required' => true], + ['name' => 'report_type', 'type' => 'select', 'label' => '사유유형', 'required' => true], + ['name' => 'target_date', 'type' => 'date', 'label' => '대상일', 'required' => true], + ['name' => 'reason', 'type' => 'textarea', 'label' => '사유', 'required' => true], + ], + ]), + ], + ]; + + foreach ($forms as $form) { + DB::table('approval_forms')->updateOrInsert( + ['tenant_id' => $tenantId, 'code' => $form['code']], + [ + 'name' => $form['name'], + 'category' => $form['category'], + 'template' => $form['template'], + 'is_active' => true, + 'updated_at' => $now, + 'created_at' => $now, + ] + ); + } + } +} From 74e3c21ee07420923a681f5ccbb1e40132f06db8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Mon, 9 Mar 2026 23:13:31 +0900 Subject: [PATCH 124/166] =?UTF-8?q?feat:=20[database]=20codebridge=20?= =?UTF-8?q?=EC=9D=B4=EA=B4=80=20=EC=99=84=EB=A3=8C=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=2058=EA=B0=9C=20=EC=82=AD=EC=A0=9C=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=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 - sam DB에서 codebridge DB로 이관된 58개 테이블 DROP - FK 체크 비활성화 후 일괄 삭제 - 복원: ~/backups/sam_codebridge_tables_20260309.sql --- ...230000_drop_codebridge_tables_from_sam.php | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 database/migrations/2026_03_09_230000_drop_codebridge_tables_from_sam.php diff --git a/database/migrations/2026_03_09_230000_drop_codebridge_tables_from_sam.php b/database/migrations/2026_03_09_230000_drop_codebridge_tables_from_sam.php new file mode 100644 index 0000000..54e4612 --- /dev/null +++ b/database/migrations/2026_03_09_230000_drop_codebridge_tables_from_sam.php @@ -0,0 +1,108 @@ +tables as $table) { + Schema::dropIfExists($table); + } + + DB::statement('SET FOREIGN_KEY_CHECKS = 1'); + } + + public function down(): void + { + // 복원은 백업 파일로 수행 + // mysql -u codebridge -p sam < ~/backups/sam_codebridge_tables_20260309.sql + } +}; From d8f2361c88ff1d17562683fafb96a42865f96e67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Mar 2026 01:20:32 +0900 Subject: [PATCH 125/166] =?UTF-8?q?feat:=20[payroll]=20payrolls=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EC=97=90=20options=20JSON=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이메일 발송 이력 등 확장 속성 저장용 --- ...0_010000_add_options_to_payrolls_table.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 database/migrations/2026_03_10_010000_add_options_to_payrolls_table.php diff --git a/database/migrations/2026_03_10_010000_add_options_to_payrolls_table.php b/database/migrations/2026_03_10_010000_add_options_to_payrolls_table.php new file mode 100644 index 0000000..3a364c1 --- /dev/null +++ b/database/migrations/2026_03_10_010000_add_options_to_payrolls_table.php @@ -0,0 +1,22 @@ +json('options')->nullable()->after('note'); + }); + } + + public function down(): void + { + Schema::table('payrolls', function (Blueprint $table) { + $table->dropColumn('options'); + }); + } +}; From 6f0ad1cf2dcce9cf46e620121da1c1f7aadf6a9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Tue, 10 Mar 2026 10:59:19 +0900 Subject: [PATCH 126/166] =?UTF-8?q?feat:=20[=EC=BA=98=EB=A6=B0=EB=8D=94]?= =?UTF-8?q?=20=EC=96=B4=EC=9D=8C=20=EB=A7=8C=EA=B8=B0=EC=9D=BC=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EC=97=B0=EB=8F=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bill 모델 기반 만기일 일정 조회 (getBillSchedules) - type 필터에 'bill' 추가, null(전체)일 때도 포함 - 완료/부도 상태 제외, 만기일 기준 정렬 - 표시 형식: [만기] 거래처명 금액원 Co-Authored-By: Claude Opus 4.6 --- app/Services/CalendarService.php | 53 +++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/app/Services/CalendarService.php b/app/Services/CalendarService.php index bdd87ae..9d44415 100644 --- a/app/Services/CalendarService.php +++ b/app/Services/CalendarService.php @@ -4,6 +4,7 @@ use App\Models\Construction\Contract; use App\Models\Production\WorkOrder; +use App\Models\Tenants\Bill; use App\Models\Tenants\Leave; use App\Models\Tenants\Schedule; use Illuminate\Support\Collection; @@ -16,6 +17,7 @@ * - 계약(Contract): 시공 일정 * - 휴가(Leave): 직원 휴가 일정 * - 일정(Schedule): 본사 공통 일정 + 테넌트 일정 (세금 신고, 공휴일 등) + * - 어음(Bill): 어음 만기일 일정 */ class CalendarService extends Service { @@ -24,7 +26,7 @@ class CalendarService extends Service * * @param string $startDate 조회 시작일 (Y-m-d) * @param string $endDate 조회 종료일 (Y-m-d) - * @param string|null $type 일정 타입 필터 (schedule|order|construction|other|null=전체) + * @param string|null $type 일정 타입 필터 (schedule|order|construction|other|bill|null=전체) * @param string|null $departmentFilter 부서 필터 (all|department|personal) */ public function getSchedules( @@ -64,6 +66,13 @@ public function getSchedules( ); } + // 어음 만기일 + if ($type === null || $type === 'bill') { + $schedules = $schedules->merge( + $this->getBillSchedules($tenantId, $startDate, $endDate) + ); + } + // startDate 기준 정렬 $sortedSchedules = $schedules ->sortBy('startDate') @@ -331,4 +340,46 @@ private function getGeneralSchedules( ]; }); } + + /** + * 어음 만기일 일정 조회 + */ + private function getBillSchedules( + int $tenantId, + string $startDate, + string $endDate + ): Collection { + $excludedStatuses = [ + 'paymentComplete', + 'dishonored', + ]; + + $bills = Bill::query() + ->where('tenant_id', $tenantId) + ->whereNotNull('maturity_date') + ->where('maturity_date', '>=', $startDate) + ->where('maturity_date', '<=', $endDate) + ->whereNotIn('status', $excludedStatuses) + ->orderBy('maturity_date') + ->limit(100) + ->get(); + + return $bills->map(function ($bill) { + $clientName = $bill->display_client_name ?? $bill->client_name ?? ''; + + return [ + 'id' => 'bill_'.$bill->id, + 'title' => '[만기] '.$clientName.' '.number_format($bill->amount).'원', + 'startDate' => $bill->maturity_date->format('Y-m-d'), + 'endDate' => $bill->maturity_date->format('Y-m-d'), + 'startTime' => null, + 'endTime' => null, + 'isAllDay' => true, + 'type' => 'bill', + 'department' => null, + 'personName' => null, + 'color' => null, + ]; + }); + } } From 22f72f1bbc058f7a84c9271a3dc002c1aea9a9bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 10 Mar 2026 09:40:55 +0900 Subject: [PATCH 127/166] =?UTF-8?q?fix:=20[stats]=20QuoteStatService=20cod?= =?UTF-8?q?ebridge=20DB=20=EC=BB=A4=EB=84=A5=EC=85=98=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - codebridge DB로 이관된 테이블(sales_prospect_consultations, sales_prospects) 커넥션을 mysql → codebridge로 변경 - config/database.php에 codebridge 커넥션 추가 - quote_daily 집계 실패 해결 Co-Authored-By: Claude Opus 4.6 --- app/Services/Stats/QuoteStatService.php | 10 +++++----- config/database.php | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/app/Services/Stats/QuoteStatService.php b/app/Services/Stats/QuoteStatService.php index 49c6694..c802787 100644 --- a/app/Services/Stats/QuoteStatService.php +++ b/app/Services/Stats/QuoteStatService.php @@ -44,14 +44,14 @@ public function aggregateDaily(int $tenantId, Carbon $date): int ") ->first(); - // 상담 (sales_prospect_consultations) - $consultationCount = DB::connection('mysql') + // 상담 (sales_prospect_consultations) - codebridge DB로 이관됨 + $consultationCount = DB::connection('codebridge') ->table('sales_prospect_consultations') ->whereDate('created_at', $dateStr) ->count(); - // 영업 기회 (sales_prospects - tenant_id 없음, created_at 기반) - $prospectStats = DB::connection('mysql') + // 영업 기회 (sales_prospects) - codebridge DB로 이관됨 + $prospectStats = DB::connection('codebridge') ->table('sales_prospects') ->whereDate('created_at', $dateStr) ->whereNull('deleted_at') @@ -74,7 +74,7 @@ public function aggregateDaily(int $tenantId, Carbon $date): int '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에 금액 컬럼 없음 + 'prospect_amount' => 0, 'bidding_count' => $biddingStats->cnt ?? 0, 'bidding_won_count' => $biddingStats->won_count ?? 0, 'bidding_amount' => $biddingStats->total_amount ?? 0, diff --git a/config/database.php b/config/database.php index 82a16ef..8a59ad0 100644 --- a/config/database.php +++ b/config/database.php @@ -82,6 +82,26 @@ ]) : [], ], + // Codebridge DB (이관된 Sales/Finance/Admin 등) + 'codebridge' => [ + 'driver' => 'mysql', + 'host' => env('CODEBRIDGE_DB_HOST', env('DB_HOST', '127.0.0.1')), + 'port' => env('CODEBRIDGE_DB_PORT', env('DB_PORT', '3306')), + 'database' => env('CODEBRIDGE_DB_DATABASE', 'codebridge'), + 'username' => env('CODEBRIDGE_DB_USERNAME', env('DB_USERNAME', 'root')), + 'password' => env('CODEBRIDGE_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', From 6d1925fcd1b836297a5d2272a37d2b91ce50383b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Tue, 10 Mar 2026 11:44:43 +0900 Subject: [PATCH 128/166] =?UTF-8?q?feat:=20[=EC=BA=98=EB=A6=B0=EB=8D=94]?= =?UTF-8?q?=20=EB=A7=A4=EC=9E=85=EA=B2=B0=EC=A0=9C=C2=B7=EC=88=98=EC=A3=BC?= =?UTF-8?q?=EB=82=A9=EA=B8=B0=C2=B7=EC=B6=9C=EA=B3=A0=20=EC=98=88=EC=A0=95?= =?UTF-8?q?=EC=9D=BC=20=EC=9D=BC=EC=A0=95=20=EC=97=B0=EB=8F=99=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 - expected_expense: 매입 결제 예정일 (미결제 건만) - delivery: 수주 납기일 (활성 상태 수주만) - shipment: 출고 예정일 (scheduled/ready 상태만) - type 필터에 3개 타입 추가, null(전체)일 때 모두 포함 Co-Authored-By: Claude Opus 4.6 --- app/Services/CalendarService.php | 150 ++++++++++++++++++++++++++++++- 1 file changed, 149 insertions(+), 1 deletion(-) diff --git a/app/Services/CalendarService.php b/app/Services/CalendarService.php index 9d44415..798e950 100644 --- a/app/Services/CalendarService.php +++ b/app/Services/CalendarService.php @@ -4,9 +4,12 @@ use App\Models\Construction\Contract; use App\Models\Production\WorkOrder; +use App\Models\Orders\Order; use App\Models\Tenants\Bill; +use App\Models\Tenants\ExpectedExpense; use App\Models\Tenants\Leave; use App\Models\Tenants\Schedule; +use App\Models\Tenants\Shipment; use Illuminate\Support\Collection; /** @@ -26,7 +29,7 @@ class CalendarService extends Service * * @param string $startDate 조회 시작일 (Y-m-d) * @param string $endDate 조회 종료일 (Y-m-d) - * @param string|null $type 일정 타입 필터 (schedule|order|construction|other|bill|null=전체) + * @param string|null $type 일정 타입 필터 (schedule|order|construction|other|bill|expected_expense|delivery|shipment|null=전체) * @param string|null $departmentFilter 부서 필터 (all|department|personal) */ public function getSchedules( @@ -73,6 +76,27 @@ public function getSchedules( ); } + // 매입 결제 예정일 + if ($type === null || $type === 'expected_expense') { + $schedules = $schedules->merge( + $this->getExpectedExpenseSchedules($tenantId, $startDate, $endDate) + ); + } + + // 수주 납기일 + if ($type === null || $type === 'delivery') { + $schedules = $schedules->merge( + $this->getDeliverySchedules($tenantId, $startDate, $endDate) + ); + } + + // 출고 예정일 + if ($type === null || $type === 'shipment') { + $schedules = $schedules->merge( + $this->getShipmentSchedules($tenantId, $startDate, $endDate) + ); + } + // startDate 기준 정렬 $sortedSchedules = $schedules ->sortBy('startDate') @@ -382,4 +406,128 @@ private function getBillSchedules( ]; }); } + + /** + * 매입 결제 예정일 조회 + */ + private function getExpectedExpenseSchedules( + int $tenantId, + string $startDate, + string $endDate + ): Collection { + $expenses = ExpectedExpense::query() + ->where('tenant_id', $tenantId) + ->whereNotNull('expected_payment_date') + ->where('expected_payment_date', '>=', $startDate) + ->where('expected_payment_date', '<=', $endDate) + ->where('payment_status', '!=', 'paid') + ->with(['client:id,name']) + ->orderBy('expected_payment_date') + ->limit(100) + ->get(); + + return $expenses->map(function ($expense) { + $clientName = $expense->client?->name ?? $expense->client_name ?? ''; + + return [ + 'id' => 'expense_'.$expense->id, + 'title' => '[결제] '.$clientName.' '.number_format($expense->amount).'원', + 'startDate' => $expense->expected_payment_date->format('Y-m-d'), + 'endDate' => $expense->expected_payment_date->format('Y-m-d'), + 'startTime' => null, + 'endTime' => null, + 'isAllDay' => true, + 'type' => 'expected_expense', + 'department' => null, + 'personName' => null, + 'color' => null, + ]; + }); + } + + /** + * 수주 납기일 조회 + */ + private function getDeliverySchedules( + int $tenantId, + string $startDate, + string $endDate + ): Collection { + $activeStatuses = [ + 'CONFIRMED', + 'IN_PROGRESS', + 'IN_PRODUCTION', + 'PRODUCED', + 'SHIPPING', + ]; + + $orders = Order::query() + ->where('tenant_id', $tenantId) + ->whereNotNull('delivery_date') + ->where('delivery_date', '>=', $startDate) + ->where('delivery_date', '<=', $endDate) + ->whereIn('status_code', $activeStatuses) + ->with(['client:id,name']) + ->orderBy('delivery_date') + ->limit(100) + ->get(); + + return $orders->map(function ($order) { + $clientName = $order->client?->name ?? $order->client_name ?? ''; + $siteName = $order->site_name ?? $order->order_no; + + return [ + 'id' => 'delivery_'.$order->id, + 'title' => '[납기] '.$clientName.' '.$siteName, + 'startDate' => $order->delivery_date->format('Y-m-d'), + 'endDate' => $order->delivery_date->format('Y-m-d'), + 'startTime' => null, + 'endTime' => null, + 'isAllDay' => true, + 'type' => 'delivery', + 'department' => null, + 'personName' => null, + 'color' => null, + ]; + }); + } + + /** + * 출고 예정일 조회 + */ + private function getShipmentSchedules( + int $tenantId, + string $startDate, + string $endDate + ): Collection { + $shipments = Shipment::query() + ->where('tenant_id', $tenantId) + ->whereNotNull('scheduled_date') + ->where('scheduled_date', '>=', $startDate) + ->where('scheduled_date', '<=', $endDate) + ->whereIn('status', ['scheduled', 'ready']) + ->with(['client:id,name', 'order:id,site_name']) + ->orderBy('scheduled_date') + ->limit(100) + ->get(); + + return $shipments->map(function ($shipment) { + $clientName = $shipment->client?->name ?? $shipment->customer_name ?? ''; + $siteName = $shipment->site_name ?? $shipment->order?->site_name ?? $shipment->shipment_no; + + return [ + 'id' => 'shipment_'.$shipment->id, + 'title' => '[출고] '.$clientName.' '.$siteName, + 'startDate' => $shipment->scheduled_date->format('Y-m-d'), + 'endDate' => $shipment->scheduled_date->format('Y-m-d'), + 'startTime' => null, + 'endTime' => null, + 'isAllDay' => true, + 'type' => 'shipment', + 'department' => null, + 'personName' => null, + 'color' => null, + ]; + }); + } } From 4e192d1c0083ebdfd695e4805481d9f577ba322e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Tue, 10 Mar 2026 14:07:16 +0900 Subject: [PATCH 129/166] =?UTF-8?q?feat:=20[=EB=A1=9C=EA=B7=B8=EC=9D=B8]?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A0=95=EB=B3=B4=EC=97=90=20?= =?UTF-8?q?department=5Fid=20=EC=B6=94=EA=B0=80=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getUserInfoForLogin에서 department_id도 함께 반환 Co-Authored-By: Claude Opus 4.6 --- app/Services/MemberService.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Services/MemberService.php b/app/Services/MemberService.php index be607e6..adcb1ef 100644 --- a/app/Services/MemberService.php +++ b/app/Services/MemberService.php @@ -231,6 +231,7 @@ public static function getUserInfoForLogin(int $userId): array $dept = DB::table('departments')->where('id', $profile->department_id)->first(); if ($dept) { $userInfo['department'] = $dept->name; + $userInfo['department_id'] = $dept->id; } } From e372b9543b252a639cc750d64ab7c158aaba8e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 10 Mar 2026 11:35:58 +0900 Subject: [PATCH 130/166] =?UTF-8?q?fix:=20[stats]=20QuoteStatService?= =?UTF-8?q?=EC=97=90=EC=84=9C=20codebridge=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sales_prospect_consultations, sales_prospects 쿼리 제거 - codebridge DB에 이관된 테이블이며 tenant_id 없어 테넌트별 집계 불가 - prospect_*, consultation_count 필드는 DB default(0) 처리 Co-Authored-By: Claude Opus 4.6 --- app/Services/Stats/QuoteStatService.php | 26 +++---------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/app/Services/Stats/QuoteStatService.php b/app/Services/Stats/QuoteStatService.php index c802787..8d5e2e8 100644 --- a/app/Services/Stats/QuoteStatService.php +++ b/app/Services/Stats/QuoteStatService.php @@ -44,23 +44,8 @@ public function aggregateDaily(int $tenantId, Carbon $date): int ") ->first(); - // 상담 (sales_prospect_consultations) - codebridge DB로 이관됨 - $consultationCount = DB::connection('codebridge') - ->table('sales_prospect_consultations') - ->whereDate('created_at', $dateStr) - ->count(); - - // 영업 기회 (sales_prospects) - codebridge DB로 이관됨 - $prospectStats = DB::connection('codebridge') - ->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(); + // sales_prospect_consultations, sales_prospects는 codebridge DB에 이관되었고 + // tenant_id가 없어 테넌트별 집계 불가 → 제외 StatQuotePipelineDaily::updateOrCreate( ['tenant_id' => $tenantId, 'stat_date' => $dateStr], @@ -71,14 +56,9 @@ public function aggregateDaily(int $tenantId, Carbon $date): int '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, 'bidding_count' => $biddingStats->cnt ?? 0, 'bidding_won_count' => $biddingStats->won_count ?? 0, 'bidding_amount' => $biddingStats->total_amount ?? 0, - 'consultation_count' => $consultationCount, ] ); @@ -90,4 +70,4 @@ public function aggregateMonthly(int $tenantId, int $year, int $month): int // 견적 도메인은 일간 테이블만 운영 (월간은 Phase 4에서 필요시 추가) return 0; } -} +} \ No newline at end of file From 334e39d2de560ee66da2feb0863cf8a17fff77b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 10 Mar 2026 16:38:21 +0900 Subject: [PATCH 131/166] =?UTF-8?q?feat:=20[QMS]=202=EC=9D=BC=EC=B0=A8=20?= =?UTF-8?q?=EB=A1=9C=ED=8A=B8=20=EC=B6=94=EC=A0=81=20=EC=8B=AC=EC=82=AC=20?= =?UTF-8?q?API=20=EA=B5=AC=ED=98=84=20(Phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QmsLotAuditService: 품질관리서 목록/상세, 8종 서류 조합, 서류 상세(2단계 로딩), 확인 토글 - QmsLotAuditController: 5개 엔드포인트 (index, show, routeDocuments, documentDetail, confirm) - FormRequest 3개: Index, Confirm, DocumentDetail 파라미터 검증 - QualityDocumentLocation: options JSON 컬럼 추가 (마이그레이션 + 모델 casts) - IQC 추적: WorkOrderMaterialInput → StockLot → lot_no → Inspection(IQC) 경로 - 비관적 업데이트: DB::transaction + lockForUpdate() 원자성 보장 Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/QmsLotAuditController.php | 65 +++ .../Qms/QmsLotAuditConfirmRequest.php | 28 + .../Qms/QmsLotAuditDocumentDetailRequest.php | 34 ++ .../Requests/Qms/QmsLotAuditIndexRequest.php | 23 + .../Qualitys/QualityDocumentLocation.php | 2 + app/Services/QmsLotAuditService.php | 517 ++++++++++++++++++ ..._options_to_quality_document_locations.php | 22 + routes/api/v1/quality.php | 10 + 8 files changed, 701 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/QmsLotAuditController.php create mode 100644 app/Http/Requests/Qms/QmsLotAuditConfirmRequest.php create mode 100644 app/Http/Requests/Qms/QmsLotAuditDocumentDetailRequest.php create mode 100644 app/Http/Requests/Qms/QmsLotAuditIndexRequest.php create mode 100644 app/Services/QmsLotAuditService.php create mode 100644 database/migrations/2026_03_10_100000_add_options_to_quality_document_locations.php diff --git a/app/Http/Controllers/Api/V1/QmsLotAuditController.php b/app/Http/Controllers/Api/V1/QmsLotAuditController.php new file mode 100644 index 0000000..72afcce --- /dev/null +++ b/app/Http/Controllers/Api/V1/QmsLotAuditController.php @@ -0,0 +1,65 @@ +service->index($request->validated()); + }, __('message.fetched')); + } + + /** + * 품질관리서 상세 — 수주/개소 목록 + */ + public function show(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.fetched')); + } + + /** + * 수주 루트별 8종 서류 목록 + */ + public function routeDocuments(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->routeDocuments($id); + }, __('message.fetched')); + } + + /** + * 서류 상세 조회 (2단계 로딩) + */ + public function documentDetail(QmsLotAuditDocumentDetailRequest $request, string $type, int $id) + { + return ApiResponse::handle(function () use ($type, $id) { + return $this->service->documentDetail($type, $id); + }, __('message.fetched')); + } + + /** + * 개소별 로트 심사 확인 토글 + */ + public function confirm(QmsLotAuditConfirmRequest $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->confirm($id, $request->validated()); + }, __('message.updated')); + } +} diff --git a/app/Http/Requests/Qms/QmsLotAuditConfirmRequest.php b/app/Http/Requests/Qms/QmsLotAuditConfirmRequest.php new file mode 100644 index 0000000..f2c3dff --- /dev/null +++ b/app/Http/Requests/Qms/QmsLotAuditConfirmRequest.php @@ -0,0 +1,28 @@ + 'required|boolean', + ]; + } + + public function messages(): array + { + return [ + 'confirmed.required' => __('validation.required', ['attribute' => '확인 상태']), + 'confirmed.boolean' => __('validation.boolean', ['attribute' => '확인 상태']), + ]; + } +} diff --git a/app/Http/Requests/Qms/QmsLotAuditDocumentDetailRequest.php b/app/Http/Requests/Qms/QmsLotAuditDocumentDetailRequest.php new file mode 100644 index 0000000..560609a --- /dev/null +++ b/app/Http/Requests/Qms/QmsLotAuditDocumentDetailRequest.php @@ -0,0 +1,34 @@ +merge([ + 'type' => $this->route('type'), + ]); + } + + public function rules(): array + { + return [ + 'type' => 'required|string|in:import,order,log,report,confirmation,shipping,product,quality', + ]; + } + + public function messages(): array + { + return [ + 'type.in' => __('validation.in', ['attribute' => '서류 타입']), + ]; + } +} diff --git a/app/Http/Requests/Qms/QmsLotAuditIndexRequest.php b/app/Http/Requests/Qms/QmsLotAuditIndexRequest.php new file mode 100644 index 0000000..e881467 --- /dev/null +++ b/app/Http/Requests/Qms/QmsLotAuditIndexRequest.php @@ -0,0 +1,23 @@ + 'nullable|integer|min:2020|max:2100', + 'quarter' => 'nullable|integer|in:1,2,3,4', + 'q' => 'nullable|string|max:100', + 'per_page' => 'nullable|integer|min:1|max:100', + ]; + } +} diff --git a/app/Models/Qualitys/QualityDocumentLocation.php b/app/Models/Qualitys/QualityDocumentLocation.php index fd362bc..b5ae001 100644 --- a/app/Models/Qualitys/QualityDocumentLocation.php +++ b/app/Models/Qualitys/QualityDocumentLocation.php @@ -26,10 +26,12 @@ class QualityDocumentLocation extends Model 'inspection_data', 'document_id', 'inspection_status', + 'options', ]; protected $casts = [ 'inspection_data' => 'array', + 'options' => 'array', ]; public function qualityDocument() diff --git a/app/Services/QmsLotAuditService.php b/app/Services/QmsLotAuditService.php new file mode 100644 index 0000000..dbd367c --- /dev/null +++ b/app/Services/QmsLotAuditService.php @@ -0,0 +1,517 @@ + fn ($q) => $q->whereNull('parent_id'), + 'documentOrders.order.nodes.items.item', + 'locations', + 'performanceReport', + ]) + ->where('status', QualityDocument::STATUS_COMPLETED); + + // 연도 필터 + if (! empty($params['year'])) { + $year = (int) $params['year']; + $query->where(function ($q) use ($year) { + $q->whereHas('performanceReport', fn ($pr) => $pr->where('year', $year)) + ->orWhereDoesntHave('performanceReport'); + }); + } + + // 분기 필터 + if (! empty($params['quarter'])) { + $quarter = (int) $params['quarter']; + $query->whereHas('performanceReport', fn ($pr) => $pr->where('quarter', $quarter)); + } + + // 검색어 필터 + if (! empty($params['q'])) { + $term = trim($params['q']); + $query->where(function ($q) use ($term) { + $q->where('quality_doc_number', 'like', "%{$term}%") + ->orWhere('site_name', 'like', "%{$term}%"); + }); + } + + $query->orderByDesc('id'); + $perPage = (int) ($params['per_page'] ?? 20); + $paginated = $query->paginate($perPage); + + $items = $paginated->getCollection()->map(fn ($doc) => $this->transformReportToFrontend($doc)); + + return [ + 'items' => $items, + 'current_page' => $paginated->currentPage(), + 'last_page' => $paginated->lastPage(), + 'per_page' => $paginated->perPage(), + 'total' => $paginated->total(), + ]; + } + + /** + * 품질관리서 상세 — 수주/개소 목록 (RouteItem[]) + */ + public function show(int $id): array + { + $doc = QualityDocument::with([ + 'documentOrders.order', + 'documentOrders.locations.orderItem', + ])->findOrFail($id); + + return $doc->documentOrders->map(fn ($docOrder) => $this->transformRouteToFrontend($docOrder, $doc))->values()->all(); + } + + /** + * 수주 루트별 8종 서류 목록 (Document[]) + */ + public function routeDocuments(int $qualityDocumentOrderId): array + { + $docOrder = QualityDocumentOrder::with([ + 'order.workOrders.process', + 'locations', + 'qualityDocument', + ])->findOrFail($qualityDocumentOrderId); + + $order = $docOrder->order; + $qualityDoc = $docOrder->qualityDocument; + $workOrders = $order->workOrders; + + $documents = []; + + // 1. 수입검사 성적서 (IQC): WorkOrderMaterialInput → StockLot → lot_no → Inspection(IQC) + $investedLotIds = WorkOrderMaterialInput::whereIn('work_order_id', $workOrders->pluck('id')) + ->pluck('stock_lot_id') + ->unique(); + + $investedLotNos = StockLot::whereIn('id', $investedLotIds) + ->whereNotNull('lot_no') + ->pluck('lot_no') + ->unique(); + + $iqcInspections = Inspection::where('inspection_type', 'IQC') + ->whereIn('lot_no', $investedLotNos) + ->where('status', 'completed') + ->get(); + + $documents[] = $this->formatDocument('import', '수입검사 성적서', $iqcInspections); + + // 2. 수주서 + $documents[] = $this->formatDocument('order', '수주서', collect([$order])); + + // 3. 작업일지 (subType: process.process_name 기반) + $documents[] = $this->formatDocumentWithSubType('log', '작업일지', $workOrders); + + // 4. 중간검사 성적서 (PQC) + $pqcInspections = Inspection::where('inspection_type', 'PQC') + ->whereIn('work_order_id', $workOrders->pluck('id')) + ->where('status', 'completed') + ->with('workOrder.process') + ->get(); + + $documents[] = $this->formatDocumentWithSubType('report', '중간검사 성적서', $pqcInspections, 'workOrder'); + + // 5. 납품확인서 + $shipments = $order->shipments()->get(); + $documents[] = $this->formatDocument('confirmation', '납품확인서', $shipments); + + // 6. 출고증 + $documents[] = $this->formatDocument('shipping', '출고증', $shipments); + + // 7. 제품검사 성적서 + $locationsWithDoc = $docOrder->locations->filter(fn ($loc) => $loc->document_id); + $documents[] = $this->formatDocument('product', '제품검사 성적서', $locationsWithDoc); + + // 8. 품질관리서 + $documents[] = $this->formatDocument('quality', '품질관리서', collect([$qualityDoc])); + + return $documents; + } + + /** + * 서류 상세 조회 (2단계 로딩 — 모달 렌더링용) + */ + public function documentDetail(string $type, int $id): array + { + return match ($type) { + 'import' => $this->getInspectionDetail($id, 'IQC'), + 'order' => $this->getOrderDetail($id), + 'log' => $this->getWorkOrderLogDetail($id), + 'report' => $this->getInspectionDetail($id, 'PQC'), + 'confirmation', 'shipping' => $this->getShipmentDetail($id), + 'product' => $this->getLocationDetail($id), + 'quality' => $this->getQualityDocDetail($id), + default => throw new NotFoundHttpException(__('error.not_found')), + }; + } + + /** + * 개소별 로트 심사 확인 토글 + */ + public function confirm(int $locationId, array $data): array + { + $location = QualityDocumentLocation::findOrFail($locationId); + $confirmed = (bool) $data['confirmed']; + $userId = $this->apiUserId(); + + DB::transaction(function () use ($location, $confirmed, $userId) { + $location->lockForUpdate(); + + $options = $location->options ?? []; + $options['lot_audit_confirmed'] = $confirmed; + $options['lot_audit_confirmed_at'] = $confirmed ? now()->toIso8601String() : null; + $options['lot_audit_confirmed_by'] = $confirmed ? $userId : null; + $location->options = $options; + $location->save(); + }); + + $location->refresh(); + + return [ + 'id' => (string) $location->id, + 'name' => $this->buildLocationName($location), + 'location' => $this->buildLocationCode($location), + 'is_completed' => (bool) data_get($location->options, 'lot_audit_confirmed', false), + ]; + } + + // ===== Private: Transform Methods ===== + + private function transformReportToFrontend(QualityDocument $doc): array + { + $performanceReport = $doc->performanceReport; + $confirmedCount = $doc->locations->filter(function ($loc) { + return data_get($loc->options, 'lot_audit_confirmed', false); + })->count(); + + return [ + 'id' => (string) $doc->id, + 'code' => $doc->quality_doc_number, + 'site_name' => $doc->site_name, + 'item' => $this->getFgProductName($doc), + 'route_count' => $confirmedCount, + 'total_routes' => $doc->locations->count(), + 'quarter' => $performanceReport + ? $performanceReport->year.'년 '.$performanceReport->quarter.'분기' + : '', + 'year' => $performanceReport?->year ?? now()->year, + 'quarter_num' => $performanceReport?->quarter ?? 0, + ]; + } + + /** + * BOM 최상위(FG) 제품명 추출 + * Order → root OrderNode(parent_id=null) → 대표 OrderItem → Item(FG).name + */ + private function getFgProductName(QualityDocument $doc): string + { + $firstDocOrder = $doc->documentOrders->first(); + if (! $firstDocOrder) { + return ''; + } + + $order = $firstDocOrder->order; + if (! $order) { + return ''; + } + + // eager loaded with whereNull('parent_id') filter + $rootNode = $order->nodes->first(); + if (! $rootNode) { + return ''; + } + + $representativeItem = $rootNode->items->first(); + + return $representativeItem?->item?->name ?? ''; + } + + private function transformRouteToFrontend(QualityDocumentOrder $docOrder, QualityDocument $qualityDoc): array + { + return [ + 'id' => (string) $docOrder->id, + 'code' => $docOrder->order->order_no, + 'date' => $docOrder->order->received_at?->toDateString(), + 'site' => $docOrder->order->site_name ?? '', + 'location_count' => $docOrder->locations->count(), + 'sub_items' => $docOrder->locations->values()->map(fn ($loc, $idx) => [ + 'id' => (string) $loc->id, + 'name' => $qualityDoc->quality_doc_number.'-'.str_pad($idx + 1, 2, '0', STR_PAD_LEFT), + 'location' => $this->buildLocationCode($loc), + 'is_completed' => (bool) data_get($loc->options, 'lot_audit_confirmed', false), + ])->all(), + ]; + } + + private function buildLocationName(QualityDocumentLocation $location): string + { + $qualityDoc = $location->qualityDocument; + if (! $qualityDoc) { + return ''; + } + + // location의 순번을 구하기 위해 같은 문서의 location 목록 조회 + $locations = QualityDocumentLocation::where('quality_document_id', $qualityDoc->id) + ->orderBy('id') + ->pluck('id'); + + $index = $locations->search($location->id); + + return $qualityDoc->quality_doc_number.'-'.str_pad(($index !== false ? $index + 1 : 1), 2, '0', STR_PAD_LEFT); + } + + private function buildLocationCode(QualityDocumentLocation $location): string + { + $orderItem = $location->orderItem; + if (! $orderItem) { + return ''; + } + + return trim(($orderItem->floor_code ?? '').' '.($orderItem->symbol_code ?? '')); + } + + // ===== Private: Document Format Helpers ===== + + private function formatDocument(string $type, string $title, $collection): array + { + return [ + 'id' => $type, + 'type' => $type, + 'title' => $title, + 'count' => $collection->count(), + 'items' => $collection->values()->map(fn ($item) => $this->formatDocumentItem($type, $item))->all(), + ]; + } + + private function formatDocumentWithSubType(string $type, string $title, $collection, ?string $workOrderRelation = null): array + { + return [ + 'id' => $type, + 'type' => $type, + 'title' => $title, + 'count' => $collection->count(), + 'items' => $collection->values()->map(function ($item) use ($type, $workOrderRelation) { + $formatted = $this->formatDocumentItem($type, $item); + + // subType: process.process_name 기반 + $workOrder = $workOrderRelation ? $item->{$workOrderRelation} : $item; + if ($workOrder instanceof WorkOrder) { + $processName = $workOrder->process?->process_name; + $formatted['sub_type'] = $this->mapProcessToSubType($processName); + } + + return $formatted; + })->all(), + ]; + } + + private function formatDocumentItem(string $type, $item): array + { + return match ($type) { + 'import', 'report' => [ + 'id' => (string) $item->id, + 'title' => $item->inspection_no ?? '', + 'date' => $item->inspection_date?->toDateString() ?? $item->request_date?->toDateString() ?? '', + 'code' => $item->inspection_no ?? '', + ], + 'order' => [ + 'id' => (string) $item->id, + 'title' => $item->order_no, + 'date' => $item->received_at?->toDateString() ?? '', + 'code' => $item->order_no, + ], + 'log' => [ + 'id' => (string) $item->id, + 'title' => $item->project_name ?? '작업일지', + 'date' => $item->created_at?->toDateString() ?? '', + 'code' => $item->id, + ], + 'confirmation', 'shipping' => [ + 'id' => (string) $item->id, + 'title' => $item->shipment_no ?? '', + 'date' => $item->scheduled_date?->toDateString() ?? '', + 'code' => $item->shipment_no ?? '', + ], + 'product' => [ + 'id' => (string) $item->id, + 'title' => '제품검사 성적서', + 'date' => $item->updated_at?->toDateString() ?? '', + 'code' => '', + ], + 'quality' => [ + 'id' => (string) $item->id, + 'title' => $item->quality_doc_number ?? '', + 'date' => $item->received_date?->toDateString() ?? '', + 'code' => $item->quality_doc_number ?? '', + ], + default => [ + 'id' => (string) $item->id, + 'title' => '', + 'date' => '', + ], + }; + } + + /** + * process_name → subType 매핑 + */ + private function mapProcessToSubType(?string $processName): ?string + { + if (! $processName) { + return null; + } + + $name = mb_strtolower($processName); + + return match (true) { + str_contains($name, 'screen') || str_contains($name, '스크린') => 'screen', + str_contains($name, 'bending') || str_contains($name, '절곡') => 'bending', + str_contains($name, 'slat') || str_contains($name, '슬랫') => 'slat', + str_contains($name, 'jointbar') || str_contains($name, '조인트바') || str_contains($name, 'joint') => 'jointbar', + default => null, + }; + } + + // ===== Private: Document Detail Methods (2단계 로딩) ===== + + private function getInspectionDetail(int $id, string $type): array + { + $inspection = Inspection::where('inspection_type', $type) + ->with(['item', 'workOrder.process']) + ->findOrFail($id); + + return [ + 'type' => $type === 'IQC' ? 'import' : 'report', + 'data' => [ + 'id' => $inspection->id, + 'inspection_no' => $inspection->inspection_no, + 'inspection_type' => $inspection->inspection_type, + 'status' => $inspection->status, + 'result' => $inspection->result, + 'request_date' => $inspection->request_date?->toDateString(), + 'inspection_date' => $inspection->inspection_date?->toDateString(), + 'lot_no' => $inspection->lot_no, + 'item_name' => $inspection->item?->name, + 'process_name' => $inspection->workOrder?->process?->process_name, + 'meta' => $inspection->meta, + 'items' => $inspection->items, + 'attachments' => $inspection->attachments, + 'extra' => $inspection->extra, + ], + ]; + } + + private function getOrderDetail(int $id): array + { + $order = Order::with(['nodes' => fn ($q) => $q->whereNull('parent_id')])->findOrFail($id); + + return [ + 'type' => 'order', + 'data' => [ + 'id' => $order->id, + 'order_no' => $order->order_no, + 'status' => $order->status, + 'received_at' => $order->received_at?->toDateString(), + 'site_name' => $order->site_name, + 'nodes_count' => $order->nodes->count(), + ], + ]; + } + + private function getWorkOrderLogDetail(int $id): array + { + $workOrder = WorkOrder::with('process')->findOrFail($id); + + return [ + 'type' => 'log', + 'data' => [ + 'id' => $workOrder->id, + 'project_name' => $workOrder->project_name, + 'status' => $workOrder->status, + 'process_name' => $workOrder->process?->process_name, + 'options' => $workOrder->options, + 'created_at' => $workOrder->created_at?->toDateString(), + ], + ]; + } + + private function getShipmentDetail(int $id): array + { + $shipment = Shipment::findOrFail($id); + + return [ + 'type' => 'shipping', + 'data' => [ + 'id' => $shipment->id, + 'shipment_no' => $shipment->shipment_no, + 'status' => $shipment->status, + 'scheduled_date' => $shipment->scheduled_date?->toDateString(), + 'customer_name' => $shipment->customer_name, + 'site_name' => $shipment->site_name, + 'delivery_address' => $shipment->delivery_address, + 'delivery_method' => $shipment->delivery_method, + 'vehicle_no' => $shipment->vehicle_no, + 'driver_name' => $shipment->driver_name, + 'remarks' => $shipment->remarks, + ], + ]; + } + + private function getLocationDetail(int $id): array + { + $location = QualityDocumentLocation::with(['orderItem', 'document'])->findOrFail($id); + + return [ + 'type' => 'product', + 'data' => [ + 'id' => $location->id, + 'inspection_status' => $location->inspection_status, + 'inspection_data' => $location->inspection_data, + 'post_width' => $location->post_width, + 'post_height' => $location->post_height, + 'floor_code' => $location->orderItem?->floor_code, + 'symbol_code' => $location->orderItem?->symbol_code, + 'document_id' => $location->document_id, + ], + ]; + } + + private function getQualityDocDetail(int $id): array + { + $doc = QualityDocument::with(['client', 'inspector:id,name'])->findOrFail($id); + + return [ + 'type' => 'quality', + 'data' => [ + 'id' => $doc->id, + 'quality_doc_number' => $doc->quality_doc_number, + 'site_name' => $doc->site_name, + 'status' => $doc->status, + 'received_date' => $doc->received_date?->toDateString(), + 'client_name' => $doc->client?->name, + 'inspector_name' => $doc->inspector?->name, + 'options' => $doc->options, + ], + ]; + } +} diff --git a/database/migrations/2026_03_10_100000_add_options_to_quality_document_locations.php b/database/migrations/2026_03_10_100000_add_options_to_quality_document_locations.php new file mode 100644 index 0000000..7f99a24 --- /dev/null +++ b/database/migrations/2026_03_10_100000_add_options_to_quality_document_locations.php @@ -0,0 +1,22 @@ +json('options')->nullable()->after('inspection_status')->comment('QMS 심사 확인 등 추가 데이터'); + }); + } + + public function down(): void + { + Schema::table('quality_document_locations', function (Blueprint $table) { + $table->dropColumn('options'); + }); + } +}; diff --git a/routes/api/v1/quality.php b/routes/api/v1/quality.php index edebe9d..8100eb7 100644 --- a/routes/api/v1/quality.php +++ b/routes/api/v1/quality.php @@ -8,6 +8,7 @@ */ use App\Http\Controllers\Api\V1\PerformanceReportController; +use App\Http\Controllers\Api\V1\QmsLotAuditController; use App\Http\Controllers\Api\V1\QualityDocumentController; use Illuminate\Support\Facades\Route; @@ -38,3 +39,12 @@ Route::patch('/unconfirm', [PerformanceReportController::class, 'unconfirm'])->name('v1.quality.performance-reports.unconfirm'); Route::patch('/memo', [PerformanceReportController::class, 'updateMemo'])->name('v1.quality.performance-reports.memo'); }); + +// QMS 로트 추적 심사 +Route::prefix('qms/lot-audit')->group(function () { + Route::get('/reports', [QmsLotAuditController::class, 'index'])->name('v1.qms.lot-audit.reports'); + Route::get('/reports/{id}', [QmsLotAuditController::class, 'show'])->whereNumber('id')->name('v1.qms.lot-audit.reports.show'); + Route::get('/routes/{id}/documents', [QmsLotAuditController::class, 'routeDocuments'])->whereNumber('id')->name('v1.qms.lot-audit.routes.documents'); + Route::get('/documents/{type}/{id}', [QmsLotAuditController::class, 'documentDetail'])->whereNumber('id')->name('v1.qms.lot-audit.documents.detail'); + Route::patch('/units/{id}/confirm', [QmsLotAuditController::class, 'confirm'])->whereNumber('id')->name('v1.qms.lot-audit.units.confirm'); +}); From 30c2484440bf301688beb4026d7d9d424d9d40ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 10 Mar 2026 16:41:20 +0900 Subject: [PATCH 132/166] =?UTF-8?q?feat:=20[QMS]=201=EC=9D=BC=EC=B0=A8=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80/=EB=A7=A4=EB=89=B4=EC=96=BC=20=EC=8B=AC?= =?UTF-8?q?=EC=82=AC=20=EB=B0=B1=EC=97=94=EB=93=9C=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 - 마이그레이션: audit_checklists, audit_checklist_categories, audit_checklist_items, audit_standard_documents (4테이블) - 모델 4개: AuditChecklist, AuditChecklistCategory, AuditChecklistItem, AuditStandardDocument - AuditChecklistService: CRUD, 완료처리, 항목 토글(lockForUpdate), 기준 문서 연결/해제, 카테고리+항목 일괄 동기화 - AuditChecklistController: 9개 엔드포인트 - FormRequest 2개: Store(카테고리+항목 중첩 검증), Update - 라우트 9개 등록 (/api/v1/qms/checklists, checklist-items) Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/AuditChecklistController.php | 112 +++++ .../Qms/AuditChecklistStoreRequest.php | 39 ++ .../Qms/AuditChecklistUpdateRequest.php | 28 ++ app/Models/Qualitys/AuditChecklist.php | 57 +++ .../Qualitys/AuditChecklistCategory.php | 35 ++ app/Models/Qualitys/AuditChecklistItem.php | 47 +++ app/Models/Qualitys/AuditStandardDocument.php | 37 ++ app/Services/AuditChecklistService.php | 392 ++++++++++++++++++ ..._110000_create_audit_checklists_tables.php | 90 ++++ routes/api/v1/quality.php | 14 + 10 files changed, 851 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/AuditChecklistController.php create mode 100644 app/Http/Requests/Qms/AuditChecklistStoreRequest.php create mode 100644 app/Http/Requests/Qms/AuditChecklistUpdateRequest.php create mode 100644 app/Models/Qualitys/AuditChecklist.php create mode 100644 app/Models/Qualitys/AuditChecklistCategory.php create mode 100644 app/Models/Qualitys/AuditChecklistItem.php create mode 100644 app/Models/Qualitys/AuditStandardDocument.php create mode 100644 app/Services/AuditChecklistService.php create mode 100644 database/migrations/2026_03_10_110000_create_audit_checklists_tables.php diff --git a/app/Http/Controllers/Api/V1/AuditChecklistController.php b/app/Http/Controllers/Api/V1/AuditChecklistController.php new file mode 100644 index 0000000..4fb26d7 --- /dev/null +++ b/app/Http/Controllers/Api/V1/AuditChecklistController.php @@ -0,0 +1,112 @@ +service->index($request->all()); + }, __('message.fetched')); + } + + /** + * 점검표 생성 (카테고리+항목 일괄) + */ + public function store(AuditChecklistStoreRequest $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->store($request->validated()); + }, __('message.created')); + } + + /** + * 점검표 상세 + */ + public function show(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.fetched')); + } + + /** + * 점검표 수정 + */ + public function update(AuditChecklistUpdateRequest $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->update($id, $request->validated()); + }, __('message.updated')); + } + + /** + * 점검표 완료 처리 + */ + public function complete(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->complete($id); + }, __('message.updated')); + } + + /** + * 항목 완료/미완료 토글 + */ + public function toggleItem(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->toggleItem($id); + }, __('message.updated')); + } + + /** + * 항목별 기준 문서 조회 + */ + public function itemDocuments(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->itemDocuments($id); + }, __('message.fetched')); + } + + /** + * 기준 문서 연결 + */ + public function attachDocument(Request $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->attachDocument($id, $request->validate([ + 'title' => 'required|string|max:200', + 'version' => 'nullable|string|max:20', + 'date' => 'nullable|date', + 'document_id' => 'nullable|integer|exists:documents,id', + ])); + }, __('message.created')); + } + + /** + * 기준 문서 연결 해제 + */ + public function detachDocument(int $id, int $docId) + { + return ApiResponse::handle(function () use ($id, $docId) { + $this->service->detachDocument($id, $docId); + + return null; + }, __('message.deleted')); + } +} diff --git a/app/Http/Requests/Qms/AuditChecklistStoreRequest.php b/app/Http/Requests/Qms/AuditChecklistStoreRequest.php new file mode 100644 index 0000000..7d3e64c --- /dev/null +++ b/app/Http/Requests/Qms/AuditChecklistStoreRequest.php @@ -0,0 +1,39 @@ + 'required|integer|min:2020|max:2100', + 'quarter' => 'required|integer|in:1,2,3,4', + 'type' => 'nullable|string|max:30', + 'categories' => 'required|array|min:1', + 'categories.*.title' => 'required|string|max:200', + 'categories.*.sort_order' => 'nullable|integer|min:0', + 'categories.*.items' => 'required|array|min:1', + 'categories.*.items.*.name' => 'required|string|max:200', + 'categories.*.items.*.description' => 'nullable|string', + 'categories.*.items.*.sort_order' => 'nullable|integer|min:0', + ]; + } + + public function messages(): array + { + return [ + 'categories.required' => __('validation.required', ['attribute' => '카테고리']), + 'categories.*.title.required' => __('validation.required', ['attribute' => '카테고리 제목']), + 'categories.*.items.required' => __('validation.required', ['attribute' => '점검 항목']), + 'categories.*.items.*.name.required' => __('validation.required', ['attribute' => '항목명']), + ]; + } +} diff --git a/app/Http/Requests/Qms/AuditChecklistUpdateRequest.php b/app/Http/Requests/Qms/AuditChecklistUpdateRequest.php new file mode 100644 index 0000000..f68811c --- /dev/null +++ b/app/Http/Requests/Qms/AuditChecklistUpdateRequest.php @@ -0,0 +1,28 @@ + 'sometimes|array|min:1', + 'categories.*.id' => 'nullable|integer|exists:audit_checklist_categories,id', + 'categories.*.title' => 'required|string|max:200', + 'categories.*.sort_order' => 'nullable|integer|min:0', + 'categories.*.items' => 'required|array|min:1', + 'categories.*.items.*.id' => 'nullable|integer|exists:audit_checklist_items,id', + 'categories.*.items.*.name' => 'required|string|max:200', + 'categories.*.items.*.description' => 'nullable|string', + 'categories.*.items.*.sort_order' => 'nullable|integer|min:0', + ]; + } +} diff --git a/app/Models/Qualitys/AuditChecklist.php b/app/Models/Qualitys/AuditChecklist.php new file mode 100644 index 0000000..af878d5 --- /dev/null +++ b/app/Models/Qualitys/AuditChecklist.php @@ -0,0 +1,57 @@ + 'integer', + 'quarter' => 'integer', + 'options' => 'array', + ]; + + public function categories(): HasMany + { + return $this->hasMany(AuditChecklistCategory::class, 'checklist_id')->orderBy('sort_order'); + } + + public function isDraft(): bool + { + return $this->status === self::STATUS_DRAFT; + } + + public function isCompleted(): bool + { + return $this->status === self::STATUS_COMPLETED; + } +} diff --git a/app/Models/Qualitys/AuditChecklistCategory.php b/app/Models/Qualitys/AuditChecklistCategory.php new file mode 100644 index 0000000..d0f9a76 --- /dev/null +++ b/app/Models/Qualitys/AuditChecklistCategory.php @@ -0,0 +1,35 @@ + 'integer', + 'options' => 'array', + ]; + + public function checklist(): BelongsTo + { + return $this->belongsTo(AuditChecklist::class, 'checklist_id'); + } + + public function items(): HasMany + { + return $this->hasMany(AuditChecklistItem::class, 'category_id')->orderBy('sort_order'); + } +} diff --git a/app/Models/Qualitys/AuditChecklistItem.php b/app/Models/Qualitys/AuditChecklistItem.php new file mode 100644 index 0000000..b94b7bd --- /dev/null +++ b/app/Models/Qualitys/AuditChecklistItem.php @@ -0,0 +1,47 @@ + 'boolean', + 'completed_at' => 'datetime', + 'sort_order' => 'integer', + 'options' => 'array', + ]; + + public function category(): BelongsTo + { + return $this->belongsTo(AuditChecklistCategory::class, 'category_id'); + } + + public function completedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'completed_by'); + } + + public function standardDocuments(): HasMany + { + return $this->hasMany(AuditStandardDocument::class, 'checklist_item_id'); + } +} diff --git a/app/Models/Qualitys/AuditStandardDocument.php b/app/Models/Qualitys/AuditStandardDocument.php new file mode 100644 index 0000000..3dcd63c --- /dev/null +++ b/app/Models/Qualitys/AuditStandardDocument.php @@ -0,0 +1,37 @@ + 'date', + 'options' => 'array', + ]; + + public function checklistItem(): BelongsTo + { + return $this->belongsTo(AuditChecklistItem::class, 'checklist_item_id'); + } + + public function document(): BelongsTo + { + return $this->belongsTo(Document::class); + } +} diff --git a/app/Services/AuditChecklistService.php b/app/Services/AuditChecklistService.php new file mode 100644 index 0000000..2c3f48e --- /dev/null +++ b/app/Services/AuditChecklistService.php @@ -0,0 +1,392 @@ +where('year', (int) $params['year']); + } + if (! empty($params['quarter'])) { + $query->where('quarter', (int) $params['quarter']); + } + if (! empty($params['type'])) { + $query->where('type', $params['type']); + } + + $query->orderByDesc('year')->orderByDesc('quarter'); + $perPage = (int) ($params['per_page'] ?? 20); + $paginated = $query->paginate($perPage); + + $items = $paginated->getCollection()->map(fn ($checklist) => $this->transformListItem($checklist)); + + return [ + 'items' => $items, + 'current_page' => $paginated->currentPage(), + 'last_page' => $paginated->lastPage(), + 'per_page' => $paginated->perPage(), + 'total' => $paginated->total(), + ]; + } + + /** + * 점검표 생성 (카테고리+항목 일괄) + */ + public function store(array $data): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 중복 체크 + $exists = AuditChecklist::where('year', $data['year']) + ->where('quarter', $data['quarter']) + ->where('type', $data['type'] ?? AuditChecklist::TYPE_STANDARD_MANUAL) + ->exists(); + + if ($exists) { + throw new BadRequestHttpException(__('error.duplicate', ['attribute' => '해당 분기 점검표'])); + } + + return DB::transaction(function () use ($data, $tenantId, $userId) { + $checklist = AuditChecklist::create([ + 'tenant_id' => $tenantId, + 'year' => $data['year'], + 'quarter' => $data['quarter'], + 'type' => $data['type'] ?? AuditChecklist::TYPE_STANDARD_MANUAL, + 'status' => AuditChecklist::STATUS_DRAFT, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + $this->syncCategories($checklist, $data['categories'], $tenantId); + + return $this->show($checklist->id); + }); + } + + /** + * 점검표 상세 (카테고리→항목→문서 중첩) + */ + public function show(int $id): array + { + $checklist = AuditChecklist::with([ + 'categories.items.standardDocuments.document', + ])->findOrFail($id); + + return $this->transformDetail($checklist); + } + + /** + * 점검표 수정 + */ + public function update(int $id, array $data): array + { + $tenantId = $this->tenantId(); + + $checklist = AuditChecklist::findOrFail($id); + + if ($checklist->isCompleted()) { + throw new BadRequestHttpException('완료된 점검표는 수정할 수 없습니다.'); + } + + return DB::transaction(function () use ($checklist, $data, $tenantId) { + $checklist->update([ + 'updated_by' => $this->apiUserId(), + ]); + + if (isset($data['categories'])) { + $this->syncCategories($checklist, $data['categories'], $tenantId); + } + + return $this->show($checklist->id); + }); + } + + /** + * 점검표 완료 처리 + */ + public function complete(int $id): array + { + $checklist = AuditChecklist::with('categories.items')->findOrFail($id); + + // 미완료 항목 확인 + $totalItems = 0; + $completedItems = 0; + foreach ($checklist->categories as $category) { + foreach ($category->items as $item) { + $totalItems++; + if ($item->is_completed) { + $completedItems++; + } + } + } + + if ($completedItems < $totalItems) { + throw new BadRequestHttpException("미완료 항목이 있습니다. ({$completedItems}/{$totalItems})"); + } + + $checklist->update([ + 'status' => AuditChecklist::STATUS_COMPLETED, + 'updated_by' => $this->apiUserId(), + ]); + + return $this->show($checklist->id); + } + + /** + * 항목 완료/미완료 토글 + */ + public function toggleItem(int $itemId): array + { + $item = AuditChecklistItem::findOrFail($itemId); + $userId = $this->apiUserId(); + + DB::transaction(function () use ($item, $userId) { + $item->lockForUpdate(); + + $newCompleted = ! $item->is_completed; + $item->update([ + 'is_completed' => $newCompleted, + 'completed_at' => $newCompleted ? now() : null, + 'completed_by' => $newCompleted ? $userId : null, + ]); + + // 점검표 상태 자동 업데이트: draft → in_progress + $category = $item->category; + $checklist = $category->checklist; + if ($checklist->isDraft()) { + $checklist->update([ + 'status' => AuditChecklist::STATUS_IN_PROGRESS, + 'updated_by' => $userId, + ]); + } + }); + + $item->refresh(); + + return [ + 'id' => (string) $item->id, + 'name' => $item->name, + 'is_completed' => $item->is_completed, + 'completed_at' => $item->completed_at?->toIso8601String(), + ]; + } + + /** + * 항목별 기준 문서 조회 + */ + public function itemDocuments(int $itemId): array + { + $item = AuditChecklistItem::findOrFail($itemId); + + return $item->standardDocuments()->with('document')->get() + ->map(fn ($doc) => $this->transformStandardDocument($doc)) + ->all(); + } + + /** + * 기준 문서 연결 + */ + public function attachDocument(int $itemId, array $data): array + { + $item = AuditChecklistItem::findOrFail($itemId); + $tenantId = $this->tenantId(); + + $doc = AuditStandardDocument::create([ + 'tenant_id' => $tenantId, + 'checklist_item_id' => $item->id, + 'title' => $data['title'], + 'version' => $data['version'] ?? null, + 'date' => $data['date'] ?? null, + 'document_id' => $data['document_id'] ?? null, + ]); + + $doc->load('document'); + + return $this->transformStandardDocument($doc); + } + + /** + * 기준 문서 연결 해제 + */ + public function detachDocument(int $itemId, int $docId): void + { + $doc = AuditStandardDocument::where('checklist_item_id', $itemId) + ->where('id', $docId) + ->firstOrFail(); + + $doc->delete(); + } + + // ===== Private: Sync & Transform ===== + + private function syncCategories(AuditChecklist $checklist, array $categoriesData, int $tenantId): void + { + // 기존 카테고리 ID 추적 (삭제 감지용) + $existingCategoryIds = $checklist->categories()->pluck('id')->all(); + $keptCategoryIds = []; + + foreach ($categoriesData as $catIdx => $catData) { + if (! empty($catData['id'])) { + // 기존 카테고리 업데이트 + $category = AuditChecklistCategory::findOrFail($catData['id']); + $category->update([ + 'title' => $catData['title'], + 'sort_order' => $catData['sort_order'] ?? $catIdx, + ]); + $keptCategoryIds[] = $category->id; + } else { + // 새 카테고리 생성 + $category = AuditChecklistCategory::create([ + 'tenant_id' => $tenantId, + 'checklist_id' => $checklist->id, + 'title' => $catData['title'], + 'sort_order' => $catData['sort_order'] ?? $catIdx, + ]); + $keptCategoryIds[] = $category->id; + } + + // 하위 항목 동기화 + $this->syncItems($category, $catData['items'] ?? [], $tenantId); + } + + // 삭제된 카테고리 제거 (cascade로 items도 삭제) + $deletedIds = array_diff($existingCategoryIds, $keptCategoryIds); + if (! empty($deletedIds)) { + AuditChecklistCategory::whereIn('id', $deletedIds)->delete(); + } + } + + private function syncItems(AuditChecklistCategory $category, array $itemsData, int $tenantId): void + { + $existingItemIds = $category->items()->pluck('id')->all(); + $keptItemIds = []; + + foreach ($itemsData as $itemIdx => $itemData) { + if (! empty($itemData['id'])) { + $item = AuditChecklistItem::findOrFail($itemData['id']); + $item->update([ + 'name' => $itemData['name'], + 'description' => $itemData['description'] ?? null, + 'sort_order' => $itemData['sort_order'] ?? $itemIdx, + ]); + $keptItemIds[] = $item->id; + } else { + $item = AuditChecklistItem::create([ + 'tenant_id' => $tenantId, + 'category_id' => $category->id, + 'name' => $itemData['name'], + 'description' => $itemData['description'] ?? null, + 'sort_order' => $itemData['sort_order'] ?? $itemIdx, + ]); + $keptItemIds[] = $item->id; + } + } + + $deletedIds = array_diff($existingItemIds, $keptItemIds); + if (! empty($deletedIds)) { + AuditChecklistItem::whereIn('id', $deletedIds)->delete(); + } + } + + private function transformListItem(AuditChecklist $checklist): array + { + $total = 0; + $completed = 0; + foreach ($checklist->categories as $category) { + foreach ($category->items as $item) { + $total++; + if ($item->is_completed) { + $completed++; + } + } + } + + return [ + 'id' => (string) $checklist->id, + 'year' => $checklist->year, + 'quarter' => $checklist->quarter, + 'type' => $checklist->type, + 'status' => $checklist->status, + 'progress' => [ + 'completed' => $completed, + 'total' => $total, + ], + ]; + } + + private function transformDetail(AuditChecklist $checklist): array + { + $total = 0; + $completed = 0; + + $categories = $checklist->categories->map(function ($category) use (&$total, &$completed) { + $subItems = $category->items->map(function ($item) use (&$total, &$completed) { + $total++; + if ($item->is_completed) { + $completed++; + } + + return [ + 'id' => (string) $item->id, + 'name' => $item->name, + 'description' => $item->description, + 'is_completed' => $item->is_completed, + 'completed_at' => $item->completed_at?->toIso8601String(), + 'sort_order' => $item->sort_order, + 'standard_documents' => $item->standardDocuments->map( + fn ($doc) => $this->transformStandardDocument($doc) + )->all(), + ]; + })->all(); + + return [ + 'id' => (string) $category->id, + 'title' => $category->title, + 'sort_order' => $category->sort_order, + 'sub_items' => $subItems, + ]; + })->all(); + + return [ + 'id' => (string) $checklist->id, + 'year' => $checklist->year, + 'quarter' => $checklist->quarter, + 'type' => $checklist->type, + 'status' => $checklist->status, + 'progress' => [ + 'completed' => $completed, + 'total' => $total, + ], + 'categories' => $categories, + ]; + } + + private function transformStandardDocument(AuditStandardDocument $doc): array + { + $file = $doc->document; + + return [ + 'id' => (string) $doc->id, + 'title' => $doc->title, + 'version' => $doc->version ?? '-', + 'date' => $doc->date?->toDateString() ?? '', + 'file_name' => $file?->original_name ?? null, + 'file_url' => $file ? "/api/v1/documents/{$file->id}/download" : null, + ]; + } +} diff --git a/database/migrations/2026_03_10_110000_create_audit_checklists_tables.php b/database/migrations/2026_03_10_110000_create_audit_checklists_tables.php new file mode 100644 index 0000000..1c9e332 --- /dev/null +++ b/database/migrations/2026_03_10_110000_create_audit_checklists_tables.php @@ -0,0 +1,90 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트ID'); + $table->smallInteger('year')->unsigned()->comment('연도'); + $table->tinyInteger('quarter')->unsigned()->comment('분기 1~4'); + $table->string('type', 30)->default('standard_manual')->comment('심사유형'); + $table->string('status', 20)->default('draft')->comment('draft/in_progress/completed'); + $table->json('options')->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(['tenant_id', 'year', 'quarter', 'type'], 'uq_audit_checklists_tenant_period'); + $table->index(['tenant_id', 'status'], 'idx_audit_checklists_status'); + $table->foreign('tenant_id')->references('id')->on('tenants'); + }); + + // 2) 점검표 카테고리 + Schema::create('audit_checklist_categories', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트ID'); + $table->unsignedBigInteger('checklist_id')->comment('점검표ID'); + $table->string('title', 200)->comment('카테고리명'); + $table->unsignedInteger('sort_order')->default(0)->comment('정렬순서'); + $table->json('options')->nullable(); + $table->timestamps(); + + $table->index(['checklist_id', 'sort_order'], 'idx_audit_categories_sort'); + $table->foreign('checklist_id')->references('id')->on('audit_checklists')->onDelete('cascade'); + }); + + // 3) 점검표 세부 항목 + Schema::create('audit_checklist_items', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트ID'); + $table->unsignedBigInteger('category_id')->comment('카테고리ID'); + $table->string('name', 200)->comment('항목명'); + $table->text('description')->nullable()->comment('항목 설명'); + $table->boolean('is_completed')->default(false)->comment('완료여부'); + $table->timestamp('completed_at')->nullable()->comment('완료일시'); + $table->unsignedBigInteger('completed_by')->nullable()->comment('완료처리자'); + $table->unsignedInteger('sort_order')->default(0)->comment('정렬순서'); + $table->json('options')->nullable(); + $table->timestamps(); + + $table->index(['category_id', 'sort_order'], 'idx_audit_items_sort'); + $table->index(['category_id', 'is_completed'], 'idx_audit_items_completed'); + $table->foreign('category_id')->references('id')->on('audit_checklist_categories')->onDelete('cascade'); + $table->foreign('completed_by')->references('id')->on('users')->onDelete('set null'); + }); + + // 4) 기준 문서 연결 + Schema::create('audit_standard_documents', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트ID'); + $table->unsignedBigInteger('checklist_item_id')->comment('점검항목ID'); + $table->string('title', 200)->comment('문서명'); + $table->string('version', 20)->nullable()->comment('버전'); + $table->date('date')->nullable()->comment('시행일'); + $table->unsignedBigInteger('document_id')->nullable()->comment('EAV 파일 FK'); + $table->json('options')->nullable(); + $table->timestamps(); + + $table->index('checklist_item_id', 'idx_audit_std_docs_item'); + $table->foreign('checklist_item_id')->references('id')->on('audit_checklist_items')->onDelete('cascade'); + $table->foreign('document_id')->references('id')->on('documents')->onDelete('set null'); + }); + } + + public function down(): void + { + Schema::dropIfExists('audit_standard_documents'); + Schema::dropIfExists('audit_checklist_items'); + Schema::dropIfExists('audit_checklist_categories'); + Schema::dropIfExists('audit_checklists'); + } +}; diff --git a/routes/api/v1/quality.php b/routes/api/v1/quality.php index 8100eb7..5edb7ca 100644 --- a/routes/api/v1/quality.php +++ b/routes/api/v1/quality.php @@ -7,6 +7,7 @@ * - 실적신고 */ +use App\Http\Controllers\Api\V1\AuditChecklistController; use App\Http\Controllers\Api\V1\PerformanceReportController; use App\Http\Controllers\Api\V1\QmsLotAuditController; use App\Http\Controllers\Api\V1\QualityDocumentController; @@ -48,3 +49,16 @@ Route::get('/documents/{type}/{id}', [QmsLotAuditController::class, 'documentDetail'])->whereNumber('id')->name('v1.qms.lot-audit.documents.detail'); Route::patch('/units/{id}/confirm', [QmsLotAuditController::class, 'confirm'])->whereNumber('id')->name('v1.qms.lot-audit.units.confirm'); }); + +// QMS 기준/매뉴얼 심사 (1일차) +Route::prefix('qms')->group(function () { + Route::get('/checklists', [AuditChecklistController::class, 'index'])->name('v1.qms.checklists.index'); + Route::post('/checklists', [AuditChecklistController::class, 'store'])->name('v1.qms.checklists.store'); + Route::get('/checklists/{id}', [AuditChecklistController::class, 'show'])->whereNumber('id')->name('v1.qms.checklists.show'); + Route::put('/checklists/{id}', [AuditChecklistController::class, 'update'])->whereNumber('id')->name('v1.qms.checklists.update'); + Route::patch('/checklists/{id}/complete', [AuditChecklistController::class, 'complete'])->whereNumber('id')->name('v1.qms.checklists.complete'); + Route::patch('/checklist-items/{id}/toggle', [AuditChecklistController::class, 'toggleItem'])->whereNumber('id')->name('v1.qms.checklist-items.toggle'); + Route::get('/checklist-items/{id}/documents', [AuditChecklistController::class, 'itemDocuments'])->whereNumber('id')->name('v1.qms.checklist-items.documents'); + Route::post('/checklist-items/{id}/documents', [AuditChecklistController::class, 'attachDocument'])->whereNumber('id')->name('v1.qms.checklist-items.documents.attach'); + Route::delete('/checklist-items/{id}/documents/{docId}', [AuditChecklistController::class, 'detachDocument'])->whereNumber('id')->whereNumber('docId')->name('v1.qms.checklist-items.documents.detach'); +}); From e061faadc205fb4bf21ee81a44ec4c3f5389eaac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 10 Mar 2026 17:55:00 +0900 Subject: [PATCH 133/166] =?UTF-8?q?feat:=20[QMS]=20Auditable=20trait=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95=20+=20=ED=92=88?= =?UTF-8?q?=EC=A7=88=20=EB=8D=94=EB=AF=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?Seeder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuditChecklist.php: App\Models\Traits → App\Traits 경로 수정 - QualityDummyDataSeeder: 3개 품질 페이지용 더미 데이터 생성 - 품질관리서 10건, 실적신고 6건, 점검표 2건(Q1/Q2), 항목 54건, 기준문서 114건 Co-Authored-By: Claude Opus 4.6 --- app/Models/Qualitys/AuditChecklist.php | 4 +- database/seeders/QualityDummyDataSeeder.php | 608 ++++++++++++++++++++ 2 files changed, 610 insertions(+), 2 deletions(-) create mode 100644 database/seeders/QualityDummyDataSeeder.php diff --git a/app/Models/Qualitys/AuditChecklist.php b/app/Models/Qualitys/AuditChecklist.php index af878d5..d620270 100644 --- a/app/Models/Qualitys/AuditChecklist.php +++ b/app/Models/Qualitys/AuditChecklist.php @@ -2,8 +2,8 @@ namespace App\Models\Qualitys; -use App\Models\Traits\Auditable; -use App\Models\Traits\BelongsToTenant; +use App\Traits\Auditable; +use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; diff --git a/database/seeders/QualityDummyDataSeeder.php b/database/seeders/QualityDummyDataSeeder.php new file mode 100644 index 0000000..63c007c --- /dev/null +++ b/database/seeders/QualityDummyDataSeeder.php @@ -0,0 +1,608 @@ +where('tenant_id', $tenantId) + ->where('quality_doc_number', 'like', 'KD-QD-202604-%') + ->count(); + + if ($existing > 0) { + $this->command->info(' ⚠ quality_documents: 이미 '.$existing.'개 존재 (스킵)'); + + return; + } + + DB::transaction(function () use ($tenantId, $userId, $now) { + // ============================================================ + // Page 1: 제품검사/품질관리서 (quality_documents + orders + locations) + // ============================================================ + $this->command->info('📋 Page 1: 제품검사/품질관리서 더미 데이터 생성...'); + + $qualityDocs = [ + [ + 'quality_doc_number' => 'KD-QD-202604-0001', + 'site_name' => '강남 르네상스 오피스텔 신축공사', + 'status' => 'completed', + 'client_id' => self::CLIENT_IDS[0], + 'inspector_id' => $userId, + 'received_date' => '2026-02-15', + 'options' => json_encode([ + 'manager' => ['name' => '김현수', 'phone' => '010-1111-2222'], + 'contractor' => ['name' => '이건설', 'phone' => '02-3333-4444', 'address' => '서울시 강남구 역삼동', 'company' => '대한건설(주)'], + 'inspection' => ['end_date' => '2026-03-05', 'start_date' => '2026-03-03', 'request_date' => '2026-02-28'], + 'supervisor' => ['name' => '박감리', 'phone' => '02-5555-6666', 'office' => '한국감리사무소', 'address' => '서울시 서초구'], + 'site_address' => ['detail' => '강남 르네상스 오피스텔 B1~15F', 'address' => '서울시 강남구 역삼동 123-45', 'postal_code' => '06241'], + 'construction_site' => ['name' => '강남 르네상스 오피스텔 신축공사', 'lot_number' => '123-45', 'land_location' => '서울시 강남구 역삼동'], + 'material_distributor' => ['ceo' => '최대표', 'phone' => '02-7777-8888', 'address' => '서울시 송파구', 'company' => '경동자재(주)'], + ]), + ], + [ + 'quality_doc_number' => 'KD-QD-202604-0002', + 'site_name' => '판교 테크노밸리 물류센터', + 'status' => 'completed', + 'client_id' => self::CLIENT_IDS[1], + 'inspector_id' => $userId, + 'received_date' => '2026-02-20', + 'options' => json_encode([ + 'manager' => ['name' => '정우성', 'phone' => '010-2222-3333'], + 'contractor' => ['name' => '박시공', 'phone' => '031-4444-5555', 'address' => '경기도 성남시 분당구', 'company' => '판교건설(주)'], + 'inspection' => ['end_date' => '2026-03-08', 'start_date' => '2026-03-06', 'request_date' => '2026-03-01'], + 'supervisor' => ['name' => '이감리', 'phone' => '031-6666-7777', 'office' => '성남감리사무소', 'address' => '경기도 성남시 분당구'], + 'site_address' => ['detail' => '판교 테크노밸리 3단지 물류동', 'address' => '경기도 성남시 분당구 판교동 678-9', 'postal_code' => '13487'], + 'construction_site' => ['name' => '판교 테크노밸리 물류센터 신축', 'lot_number' => '678-9', 'land_location' => '경기도 성남시 분당구 판교동'], + 'material_distributor' => ['ceo' => '김대표', 'phone' => '031-8888-9999', 'address' => '경기도 용인시', 'company' => '한국자재유통(주)'], + ]), + ], + [ + 'quality_doc_number' => 'KD-QD-202604-0003', + 'site_name' => '잠실 롯데월드타워 리모델링', + 'status' => 'completed', + 'client_id' => self::CLIENT_IDS[2], + 'inspector_id' => $userId, + 'received_date' => '2026-02-25', + 'options' => json_encode([ + 'manager' => ['name' => '송민호', 'phone' => '010-3333-4444'], + 'contractor' => ['name' => '최시공', 'phone' => '02-5555-6666', 'address' => '서울시 송파구 잠실동', 'company' => '잠실건설(주)'], + 'inspection' => ['end_date' => '2026-03-12', 'start_date' => '2026-03-10', 'request_date' => '2026-03-05'], + 'supervisor' => ['name' => '강감리', 'phone' => '02-7777-8888', 'office' => '송파감리사무소', 'address' => '서울시 송파구'], + 'site_address' => ['detail' => '잠실 롯데월드타워 15~20F', 'address' => '서울시 송파구 잠실동 29', 'postal_code' => '05551'], + 'construction_site' => ['name' => '잠실 롯데월드타워 리모델링 공사', 'lot_number' => '29', 'land_location' => '서울시 송파구 잠실동'], + 'material_distributor' => ['ceo' => '한대표', 'phone' => '02-9999-0000', 'address' => '서울시 강동구', 'company' => '동부자재(주)'], + ]), + ], + [ + 'quality_doc_number' => 'KD-QD-202604-0004', + 'site_name' => '마곡 LG사이언스파크 증축', + 'status' => 'completed', + 'client_id' => self::CLIENT_IDS[3], + 'inspector_id' => $userId, + 'received_date' => '2026-03-01', + 'options' => json_encode([ + 'manager' => ['name' => '윤서연', 'phone' => '010-4444-5555'], + 'contractor' => ['name' => '임시공', 'phone' => '02-1234-5678', 'address' => '서울시 강서구 마곡동', 'company' => '마곡종합건설(주)'], + 'inspection' => ['end_date' => '2026-03-15', 'start_date' => '2026-03-13', 'request_date' => '2026-03-08'], + 'supervisor' => ['name' => '오감리', 'phone' => '02-2345-6789', 'office' => '강서감리사무소', 'address' => '서울시 강서구'], + 'site_address' => ['detail' => 'LG사이언스파크 E동 증축', 'address' => '서울시 강서구 마곡동 757', 'postal_code' => '07796'], + 'construction_site' => ['name' => '마곡 LG사이언스파크 증축공사', 'lot_number' => '757', 'land_location' => '서울시 강서구 마곡동'], + 'material_distributor' => ['ceo' => '장대표', 'phone' => '02-3456-7890', 'address' => '서울시 영등포구', 'company' => '서부자재(주)'], + ]), + ], + [ + 'quality_doc_number' => 'KD-QD-202604-0005', + 'site_name' => '인천 송도 스마트시티 아파트', + 'status' => 'in_progress', + 'client_id' => self::CLIENT_IDS[3], + 'inspector_id' => $userId, + 'received_date' => '2026-03-05', + 'options' => json_encode([ + 'manager' => ['name' => '안재현', 'phone' => '010-5555-6666'], + 'contractor' => ['name' => '배시공', 'phone' => '032-1111-2222', 'address' => '인천시 연수구 송도동', 'company' => '송도종합건설(주)'], + 'inspection' => ['end_date' => null, 'start_date' => '2026-03-18', 'request_date' => '2026-03-10'], + 'supervisor' => ['name' => '황감리', 'phone' => '032-3333-4444', 'office' => '인천감리사무소', 'address' => '인천시 연수구'], + 'site_address' => ['detail' => '송도 스마트시티 A블록 101~105동', 'address' => '인천시 연수구 송도동 100-1', 'postal_code' => '21990'], + 'construction_site' => ['name' => '인천 송도 스마트시티 아파트 신축', 'lot_number' => '100-1', 'land_location' => '인천시 연수구 송도동'], + 'material_distributor' => ['ceo' => '서대표', 'phone' => '032-5555-6666', 'address' => '인천시 남동구', 'company' => '인천자재(주)'], + ]), + ], + [ + 'quality_doc_number' => 'KD-QD-202604-0006', + 'site_name' => '화성 동탄2 행복주택 단지', + 'status' => 'in_progress', + 'client_id' => self::CLIENT_IDS[4], + 'inspector_id' => $userId, + 'received_date' => '2026-03-06', + 'options' => json_encode([ + 'manager' => ['name' => '류준열', 'phone' => '010-6666-7777'], + 'contractor' => ['name' => '조시공', 'phone' => '031-2222-3333', 'address' => '경기도 화성시 동탄', 'company' => '동탄건설(주)'], + 'inspection' => ['end_date' => null, 'start_date' => null, 'request_date' => '2026-03-12'], + 'supervisor' => ['name' => '문감리', 'phone' => '031-4444-5555', 'office' => '화성감리사무소', 'address' => '경기도 화성시'], + 'site_address' => ['detail' => '동탄2 행복주택 A1~A5동', 'address' => '경기도 화성시 동탄면 200-3', 'postal_code' => '18450'], + 'construction_site' => ['name' => '화성 동탄2 행복주택 단지 신축공사', 'lot_number' => '200-3', 'land_location' => '경기도 화성시 동탄면'], + 'material_distributor' => ['ceo' => '남대표', 'phone' => '031-6666-7777', 'address' => '경기도 오산시', 'company' => '경기자재(주)'], + ]), + ], + [ + 'quality_doc_number' => 'KD-QD-202604-0007', + 'site_name' => '세종시 정부청사 별관', + 'status' => 'in_progress', + 'client_id' => self::CLIENT_IDS[0], + 'inspector_id' => null, + 'received_date' => '2026-03-07', + 'options' => json_encode([ + 'manager' => ['name' => '김세종', 'phone' => '010-7777-8888'], + 'contractor' => ['name' => '정시공', 'phone' => '044-1111-2222', 'address' => '세종시 어진동', 'company' => '세종건설(주)'], + 'inspection' => ['end_date' => null, 'start_date' => null, 'request_date' => null], + 'supervisor' => ['name' => '고감리', 'phone' => '044-3333-4444', 'office' => '세종감리사무소', 'address' => '세종시 나성동'], + 'site_address' => ['detail' => '정부세종청사 별관동', 'address' => '세종시 어진동 850', 'postal_code' => '30113'], + 'construction_site' => ['name' => '세종시 정부청사 별관 신축공사', 'lot_number' => '850', 'land_location' => '세종시 어진동'], + 'material_distributor' => ['ceo' => '윤대표', 'phone' => '044-5555-6666', 'address' => '대전시 유성구', 'company' => '중부자재(주)'], + ]), + ], + [ + 'quality_doc_number' => 'KD-QD-202604-0008', + 'site_name' => '부산 해운대 엘시티 주상복합', + 'status' => 'in_progress', + 'client_id' => self::CLIENT_IDS[1], + 'inspector_id' => null, + 'received_date' => '2026-03-08', + 'options' => json_encode([ + 'manager' => ['name' => '이부산', 'phone' => '010-8888-9999'], + 'contractor' => ['name' => '노시공', 'phone' => '051-1111-2222', 'address' => '부산시 해운대구', 'company' => '해운대건설(주)'], + 'inspection' => ['end_date' => null, 'start_date' => null, 'request_date' => null], + 'supervisor' => ['name' => '차감리', 'phone' => '051-3333-4444', 'office' => '부산감리사무소', 'address' => '부산시 해운대구'], + 'site_address' => ['detail' => '해운대 엘시티 B동 전층', 'address' => '부산시 해운대구 우동 1478', 'postal_code' => '48060'], + 'construction_site' => ['name' => '부산 해운대 엘시티 주상복합 리모델링', 'lot_number' => '1478', 'land_location' => '부산시 해운대구 우동'], + 'material_distributor' => ['ceo' => '백대표', 'phone' => '051-5555-6666', 'address' => '부산시 사하구', 'company' => '남부자재(주)'], + ]), + ], + [ + 'quality_doc_number' => 'KD-QD-202604-0009', + 'site_name' => '수원 광교 복합문화센터', + 'status' => 'draft', + 'client_id' => self::CLIENT_IDS[4], + 'inspector_id' => null, + 'received_date' => '2026-03-09', + 'options' => json_encode([ + 'manager' => ['name' => '한지민', 'phone' => '010-5555-6666'], + 'contractor' => ['name' => '', 'phone' => '', 'address' => '', 'company' => ''], + 'inspection' => ['end_date' => null, 'start_date' => null, 'request_date' => null], + 'supervisor' => ['name' => '', 'phone' => '', 'office' => '', 'address' => ''], + 'site_address' => ['detail' => '광교 복합문화센터 전관', 'address' => '경기도 수원시 영통구 광교동 200', 'postal_code' => '16508'], + 'construction_site' => ['name' => '수원 광교 복합문화센터 신축공사', 'lot_number' => '200', 'land_location' => '경기도 수원시 영통구 광교동'], + 'material_distributor' => ['ceo' => '', 'phone' => '', 'address' => '', 'company' => ''], + ]), + ], + [ + 'quality_doc_number' => 'KD-QD-202604-0010', + 'site_name' => '대구 수성 의료복합단지', + 'status' => 'draft', + 'client_id' => self::CLIENT_IDS[2], + 'inspector_id' => null, + 'received_date' => '2026-03-10', + 'options' => json_encode([ + 'manager' => ['name' => '박대구', 'phone' => '010-9999-0000'], + 'contractor' => ['name' => '', 'phone' => '', 'address' => '', 'company' => ''], + 'inspection' => ['end_date' => null, 'start_date' => null, 'request_date' => null], + 'supervisor' => ['name' => '', 'phone' => '', 'office' => '', 'address' => ''], + 'site_address' => ['detail' => '수성 의료복합단지 본관', 'address' => '대구시 수성구 범어동 350', 'postal_code' => '42020'], + 'construction_site' => ['name' => '대구 수성 의료복합단지 신축공사', 'lot_number' => '350', 'land_location' => '대구시 수성구 범어동'], + 'material_distributor' => ['ceo' => '', 'phone' => '', 'address' => '', 'company' => ''], + ]), + ], + ]; + + $qualityDocIds = []; + foreach ($qualityDocs as $doc) { + $qualityDocIds[] = DB::table('quality_documents')->insertGetId(array_merge($doc, [ + 'tenant_id' => $tenantId, + 'created_by' => $userId, + 'updated_by' => $userId, + 'created_at' => $now, + 'updated_at' => $now, + ])); + } + $this->command->info(' ✅ quality_documents: '.count($qualityDocIds).'개 생성'); + + // completed/in_progress 문서에 수주 연결 (draft 제외) + $docOrderMapping = [ + $qualityDocIds[0] => [self::ORDER_IDS[0], self::ORDER_IDS[1]], // completed + $qualityDocIds[1] => [self::ORDER_IDS[2], self::ORDER_IDS[3], self::ORDER_IDS[4]], // completed + $qualityDocIds[2] => [self::ORDER_IDS[5], self::ORDER_IDS[6]], // completed + $qualityDocIds[3] => [self::ORDER_IDS[7], self::ORDER_IDS[8]], // completed + $qualityDocIds[4] => [self::ORDER_IDS[9], self::ORDER_IDS[0]], // in_progress + $qualityDocIds[5] => [self::ORDER_IDS[1], self::ORDER_IDS[2]], // in_progress + $qualityDocIds[6] => [self::ORDER_IDS[3]], // in_progress + $qualityDocIds[7] => [self::ORDER_IDS[4], self::ORDER_IDS[5]], // in_progress + ]; + + $qdoMap = []; + $qdoCount = 0; + foreach ($docOrderMapping as $docId => $orderIds) { + foreach ($orderIds as $orderId) { + $qdoId = DB::table('quality_document_orders')->insertGetId([ + 'quality_document_id' => $docId, + 'order_id' => $orderId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $qdoMap[$docId][$orderId] = $qdoId; + $qdoCount++; + } + } + $this->command->info(' ✅ quality_document_orders: '.$qdoCount.'개 생성'); + + // 각 수주별 order_items 조회 후 locations 생성 + $inspectionDataSets = [ + json_encode(['motor' => 'pass', 'material' => 'pass', 'impactTest' => 'pass', 'productName' => '방화셔터', 'guideRailGap' => 'pass', 'openCloseTest' => 'pass']), + json_encode(['motor' => 'pass', 'material' => 'pass', 'impactTest' => 'fail', 'productName' => '방화셔터', 'guideRailGap' => 'pass', 'openCloseTest' => 'pass']), + json_encode(['motor' => 'pass', 'material' => 'pass', 'impactTest' => 'pass', 'productName' => '방화문', 'guideRailGap' => 'N/A', 'openCloseTest' => 'pass']), + json_encode(['motor' => 'N/A', 'material' => 'pass', 'impactTest' => 'pass', 'productName' => '스크린셔터', 'guideRailGap' => 'pass', 'openCloseTest' => 'pass']), + json_encode(['motor' => 'pass', 'material' => 'fail', 'impactTest' => 'pass', 'productName' => '방화셔터', 'guideRailGap' => 'pass', 'openCloseTest' => 'fail']), + json_encode(['motor' => 'pass', 'material' => 'pass', 'impactTest' => 'pass', 'productName' => '절곡셔터', 'guideRailGap' => 'pass', 'openCloseTest' => 'pass']), + ]; + + $completedDocIds = array_slice($qualityDocIds, 0, 4); // 처음 4개가 completed + $locationCount = 0; + foreach ($qdoMap as $docId => $orderMap) { + foreach ($orderMap as $orderId => $qdoId) { + $orderItemIds = DB::table('order_items') + ->where('order_id', $orderId) + ->pluck('id') + ->take(4) + ->toArray(); + + if (empty($orderItemIds)) { + // order_items가 없으면 order_nodes → order_items 경로 시도 + $nodeIds = DB::table('order_nodes') + ->where('order_id', $orderId) + ->pluck('id'); + $orderItemIds = DB::table('order_items') + ->whereIn('order_node_id', $nodeIds) + ->pluck('id') + ->take(4) + ->toArray(); + } + + if (empty($orderItemIds)) { + $this->command->warn(' ⚠ order_id='.$orderId.'에 order_items 없음 (스킵)'); + + continue; + } + + foreach ($orderItemIds as $idx => $orderItemId) { + $isCompleted = in_array($docId, $completedDocIds); + $inspectionStatus = $isCompleted + ? ($idx === 1 ? 'fail' : 'pass') + : 'pending'; + + $inspectionData = $isCompleted + ? $inspectionDataSets[$idx % count($inspectionDataSets)] + : null; + + $postWidth = ($isCompleted || $idx < 2) ? rand(1800, 3200) : null; + $postHeight = ($isCompleted || $idx < 2) ? rand(2100, 3800) : null; + $changeReason = ($isCompleted && $idx === 0) ? '현장 사정으로 규격 변경' : null; + + $locOptions = null; + if ($isCompleted) { + $locOptions = json_encode([ + 'lot_audit_confirmed' => $idx !== 1, + 'lot_audit_confirmed_at' => $idx !== 1 ? $now->toDateTimeString() : null, + 'lot_audit_confirmed_by' => $idx !== 1 ? $userId : null, + ]); + } + + DB::table('quality_document_locations')->insert([ + 'quality_document_id' => $docId, + 'quality_document_order_id' => $qdoId, + 'order_item_id' => $orderItemId, + 'post_width' => $postWidth, + 'post_height' => $postHeight, + 'change_reason' => $changeReason, + 'inspection_data' => $inspectionData, + 'document_id' => null, + 'inspection_status' => $inspectionStatus, + 'options' => $locOptions, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $locationCount++; + } + } + } + $this->command->info(' ✅ quality_document_locations: '.$locationCount.'개 생성'); + + // ============================================================ + // Page 2: 실적신고 (performance_reports) + // ============================================================ + $this->command->info('📊 Page 2: 실적신고 더미 데이터 생성...'); + + $existingReports = DB::table('performance_reports') + ->where('tenant_id', $tenantId) + ->whereIn('quality_document_id', $qualityDocIds) + ->count(); + + if ($existingReports > 0) { + $this->command->info(' ⚠ performance_reports: 이미 '.$existingReports.'개 존재 (스킵)'); + } else { + $performanceReports = [ + [ + 'quality_document_id' => $qualityDocIds[0], + 'year' => 2026, 'quarter' => 1, + 'confirmation_status' => 'confirmed', + 'confirmed_date' => '2026-03-06', + 'confirmed_by' => $userId, + 'memo' => '1분기 검사 완료 - 강남 르네상스 건', + ], + [ + 'quality_document_id' => $qualityDocIds[1], + 'year' => 2026, 'quarter' => 1, + 'confirmation_status' => 'confirmed', + 'confirmed_date' => '2026-03-09', + 'confirmed_by' => $userId, + 'memo' => '판교 물류센터 건 - 확인 완료', + ], + [ + 'quality_document_id' => $qualityDocIds[2], + 'year' => 2026, 'quarter' => 1, + 'confirmation_status' => 'unconfirmed', + 'confirmed_date' => null, + 'confirmed_by' => null, + 'memo' => '잠실 리모델링 건 - 확인 대기중', + ], + [ + 'quality_document_id' => $qualityDocIds[3], + 'year' => 2026, 'quarter' => 1, + 'confirmation_status' => 'confirmed', + 'confirmed_date' => '2026-03-16', + 'confirmed_by' => $userId, + 'memo' => '마곡 LG사이언스파크 건 - 확인 완료', + ], + [ + 'quality_document_id' => $qualityDocIds[4], + 'year' => 2026, 'quarter' => 1, + 'confirmation_status' => 'pending', + 'confirmed_date' => null, + 'confirmed_by' => null, + 'memo' => '송도 아파트 건 - 검사 진행중', + ], + [ + 'quality_document_id' => $qualityDocIds[5], + 'year' => 2026, 'quarter' => 1, + 'confirmation_status' => 'pending', + 'confirmed_date' => null, + 'confirmed_by' => null, + 'memo' => '동탄 행복주택 건 - 검사 진행중', + ], + ]; + + foreach ($performanceReports as $report) { + DB::table('performance_reports')->insert(array_merge($report, [ + 'tenant_id' => $tenantId, + 'created_by' => $userId, + 'updated_by' => $userId, + 'created_at' => $now, + 'updated_at' => $now, + ])); + } + $this->command->info(' ✅ performance_reports: '.count($performanceReports).'개 생성'); + } + + // ============================================================ + // Page 3: 품질인정심사 (audit_checklists + categories + items + standard_documents) + // ============================================================ + $this->command->info('🏅 Page 3: 품질인정심사 더미 데이터 생성...'); + + $existingChecklist = DB::table('audit_checklists') + ->where('tenant_id', $tenantId) + ->where('year', 2026) + ->where('type', 'standard_manual') + ->first(); + + if ($existingChecklist) { + $this->command->info(' ⚠ audit_checklists: 이미 존재 (스킵)'); + } else { + // Q1 점검표 (in_progress) + $this->seedChecklist($tenantId, $userId, $now, 2026, 1, 'in_progress', '정기심사'); + // Q2 점검표 (draft) + $this->seedChecklist($tenantId, $userId, $now, 2026, 2, 'draft', '중간심사'); + } + + $this->command->info(''); + $this->command->info('🎉 품질 더미 데이터 생성 완료!'); + }); + } + + private function seedChecklist(int $tenantId, int $userId, Carbon $now, int $year, int $quarter, string $status, string $auditType): void + { + $checklistId = DB::table('audit_checklists')->insertGetId([ + 'tenant_id' => $tenantId, + 'year' => $year, + 'quarter' => $quarter, + 'type' => 'standard_manual', + 'status' => $status, + 'options' => json_encode([ + 'audit_date' => $year.'-'.str_pad($quarter * 3, 2, '0', STR_PAD_LEFT).'-15', + 'auditor' => '한국품질인증원', + 'audit_type' => $auditType, + ]), + 'created_by' => $userId, + 'updated_by' => $userId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $this->command->info(' ✅ audit_checklists: '.$year.' Q'.$quarter.' ('.$status.') 생성'); + + $isActive = $status !== 'draft'; + + $categoriesData = [ + [ + 'title' => '품질경영 시스템', + 'sort_order' => 1, + 'items' => [ + ['name' => '품질방침 수립 및 공표', 'description' => '최고경영자의 품질방침 수립, 문서화 및 전직원 공표 여부', 'is_completed' => $isActive, + 'docs' => [['title' => '품질매뉴얼 Rev.5', 'version' => '5.0', 'date' => '2025-12-01'], ['title' => '품질방침 선언서', 'version' => '3.0', 'date' => '2025-06-15']]], + ['name' => '품질목표 설정 및 관리', 'description' => '연간 품질목표 설정, 실행계획 수립 및 주기적 모니터링', 'is_completed' => $isActive, + 'docs' => [['title' => $year.'년 품질목표 관리대장', 'version' => '1.0', 'date' => $year.'-01-05'], ['title' => '품질목표 관리절차서', 'version' => '2.1', 'date' => '2025-09-10']]], + ['name' => '내부심사 계획 및 실시', 'description' => '연간 내부심사 계획 수립 및 실시 기록', 'is_completed' => false, + 'docs' => [['title' => '내부심사 절차서', 'version' => '4.0', 'date' => '2025-03-20'], ['title' => $year.'년 내부심사 계획서', 'version' => '1.0', 'date' => $year.'-01-15'], ['title' => '내부심사 보고서 양식', 'version' => '2.0', 'date' => '2025-07-01']]], + ['name' => '경영검토 실시', 'description' => '최고경영자 주관 경영검토 실시 및 기록 유지', 'is_completed' => false, + 'docs' => [['title' => '경영검토 절차서', 'version' => '3.2', 'date' => '2025-08-01'], ['title' => '2025년 하반기 경영검토 회의록', 'version' => '1.0', 'date' => '2025-12-20']]], + ['name' => '문서 및 기록 관리 체계', 'description' => '품질문서 체계(매뉴얼, 절차서, 지침서) 수립 및 관리', 'is_completed' => $isActive, + 'docs' => [['title' => '문서관리 절차서', 'version' => '4.5', 'date' => '2025-10-01'], ['title' => '기록관리 절차서', 'version' => '4.0', 'date' => '2025-03-10']]], + ], + ], + [ + 'title' => '설계 및 개발', + 'sort_order' => 2, + 'items' => [ + ['name' => '설계 입력 관리', 'description' => '설계 입력 요구사항 식별, 문서화 및 검토', 'is_completed' => $isActive, + 'docs' => [['title' => '설계관리 절차서', 'version' => '4.1', 'date' => '2025-10-15'], ['title' => '설계입력 검토서 양식', 'version' => '2.0', 'date' => '2025-05-20']]], + ['name' => '설계 출력 관리', 'description' => '설계 출력물 문서화 및 입력 요구사항 충족 확인', 'is_completed' => $isActive, + 'docs' => [['title' => '설계출력 검증서 양식', 'version' => '2.0', 'date' => '2025-06-15'], ['title' => '도면 관리기준서', 'version' => '3.0', 'date' => '2025-08-20']]], + ['name' => '설계 검증 및 유효성 확인', 'description' => '설계 출력물에 대한 검증/유효성 확인 절차 운영', 'is_completed' => false, + 'docs' => [['title' => '설계검증 절차서', 'version' => '3.0', 'date' => '2025-04-10'], ['title' => '설계유효성확인 체크리스트', 'version' => '1.5', 'date' => '2025-11-01'], ['title' => '시제품 시험성적서 양식', 'version' => '2.0', 'date' => '2025-06-30']]], + ['name' => '설계 변경 관리', 'description' => '설계 변경 요청, 승인 및 이력 관리', 'is_completed' => false, + 'docs' => [['title' => '설계변경 관리절차서', 'version' => '2.3', 'date' => '2025-07-15'], ['title' => '설계변경 요청서(ECR) 양식', 'version' => '1.0', 'date' => '2025-01-10']]], + ['name' => 'FMEA 및 위험 분석', 'description' => '설계 고장모드 영향분석(FMEA) 실시 및 관리', 'is_completed' => false, + 'docs' => [['title' => 'FMEA 절차서', 'version' => '2.0', 'date' => '2025-09-01'], ['title' => 'DFMEA 양식', 'version' => '1.5', 'date' => '2025-11-20']]], + ], + ], + [ + 'title' => '구매 관리', + 'sort_order' => 3, + 'items' => [ + ['name' => '협력업체 평가 및 선정', 'description' => '협력업체 초기평가, 정기평가 기준 및 실시 기록', 'is_completed' => $isActive, + 'docs' => [['title' => '협력업체 관리절차서', 'version' => '5.0', 'date' => '2025-09-01'], ['title' => '2025년 협력업체 평가결과', 'version' => '1.0', 'date' => '2025-12-15']]], + ['name' => '수입검사 절차', 'description' => '구매 자재 수입검사 기준 및 합/불합격 처리 절차', 'is_completed' => $isActive, + 'docs' => [['title' => '수입검사 절차서', 'version' => '3.1', 'date' => '2025-08-20'], ['title' => '수입검사 기준서', 'version' => '4.0', 'date' => '2025-11-10'], ['title' => '자재별 검사항목 목록', 'version' => '2.0', 'date' => '2025-10-01']]], + ['name' => '부적합 자재 처리', 'description' => '수입검사 불합격 자재의 격리, 반품, 특채 처리', 'is_completed' => false, + 'docs' => [['title' => '부적합품 관리절차서', 'version' => '3.0', 'date' => '2025-05-15'], ['title' => '특채 요청서 양식', 'version' => '1.2', 'date' => '2025-09-20']]], + ['name' => '구매문서 관리', 'description' => '구매 사양서, 발주서 등 구매문서 관리 체계', 'is_completed' => false, + 'docs' => [['title' => '구매관리 절차서', 'version' => '4.2', 'date' => '2025-07-01']]], + ['name' => '입고 및 자재 보관 관리', 'description' => '입고 검수, 자재 보관 조건 및 선입선출 관리', 'is_completed' => $isActive, + 'docs' => [['title' => '자재관리 절차서', 'version' => '3.5', 'date' => '2025-08-01'], ['title' => '창고관리 기준서', 'version' => '2.0', 'date' => '2025-10-15']]], + ], + ], + [ + 'title' => '제조 공정 관리', + 'sort_order' => 4, + 'items' => [ + ['name' => '공정 관리 계획', 'description' => '제조 공정별 관리항목, 관리기준, 검사방법 수립', 'is_completed' => $isActive, + 'docs' => [['title' => '공정관리 절차서', 'version' => '4.0', 'date' => '2025-07-10'], ['title' => 'QC공정도', 'version' => '3.0', 'date' => '2025-09-15']]], + ['name' => '작업표준서 관리', 'description' => '공정별 작업표준서 작성, 배포 및 최신본 관리', 'is_completed' => $isActive, + 'docs' => [['title' => '작업표준서 관리절차', 'version' => '2.5', 'date' => '2025-06-20'], ['title' => '방화셔터 조립 작업표준서', 'version' => '5.0', 'date' => '2025-11-01']]], + ['name' => '공정검사 실시', 'description' => '제조 공정 중 품질검사 기준 및 기록 관리', 'is_completed' => false, + 'docs' => [['title' => '공정검사 절차서', 'version' => '3.5', 'date' => '2025-10-20'], ['title' => '공정검사 체크시트', 'version' => '2.0', 'date' => '2025-11-15']]], + ['name' => '부적합품 관리', 'description' => '공정 중 발생한 부적합품 식별, 격리, 처리', 'is_completed' => false, + 'docs' => [['title' => '부적합품 관리절차서', 'version' => '3.0', 'date' => '2025-05-15'], ['title' => '부적합 처리대장 양식', 'version' => '2.0', 'date' => '2025-08-01']]], + ], + ], + [ + 'title' => '검사 및 시험', + 'sort_order' => 5, + 'items' => [ + ['name' => '최종검사 및 시험', 'description' => '완제품 출하 전 최종검사 기준 및 기록', 'is_completed' => false, + 'docs' => [['title' => '최종검사 절차서', 'version' => '4.0', 'date' => '2025-06-01'], ['title' => '방화셔터 시험성적서 양식', 'version' => '3.0', 'date' => '2025-08-10'], ['title' => '제품검사 기준서', 'version' => '5.1', 'date' => '2025-12-05']]], + ['name' => '검사·측정 장비 관리', 'description' => '검사장비 교정, 유지보수 계획 및 이력 관리', 'is_completed' => $isActive, + 'docs' => [['title' => '계측기 관리절차서', 'version' => '2.8', 'date' => '2025-04-15'], ['title' => $year.'년 교정계획표', 'version' => '1.0', 'date' => $year.'-01-10']]], + ['name' => '시정 및 예방조치', 'description' => '부적합 발생 시 시정조치, 재발방지 및 예방조치 관리', 'is_completed' => false, + 'docs' => [['title' => '시정예방조치 절차서', 'version' => '3.3', 'date' => '2025-09-25'], ['title' => '시정조치 보고서 양식', 'version' => '2.0', 'date' => '2025-05-01']]], + ['name' => '품질기록 관리', 'description' => '품질기록 식별, 보관, 보호, 검색, 폐기 절차', 'is_completed' => $isActive, + 'docs' => [['title' => '기록관리 절차서', 'version' => '4.0', 'date' => '2025-03-10'], ['title' => '기록보존 기간표', 'version' => '2.5', 'date' => '2025-07-20']]], + ['name' => '출하 및 인도 관리', 'description' => '완제품 출하검사, 포장, 운송 및 인도 절차', 'is_completed' => false, + 'docs' => [['title' => '출하관리 절차서', 'version' => '3.0', 'date' => '2025-05-20'], ['title' => '포장 및 운송 기준서', 'version' => '2.5', 'date' => '2025-09-10']]], + ], + ], + [ + 'title' => '고객만족 및 지속적 개선', + 'sort_order' => 6, + 'items' => [ + ['name' => '고객 불만 처리', 'description' => '고객 불만 접수, 처리, 회신 및 재발방지 체계', 'is_completed' => $isActive, + 'docs' => [['title' => '고객불만 처리절차서', 'version' => '3.0', 'date' => '2025-04-01'], ['title' => '고객불만 처리대장 양식', 'version' => '2.0', 'date' => '2025-07-15']]], + ['name' => '고객만족도 조사', 'description' => '정기적 고객만족도 조사 실시 및 결과 분석', 'is_completed' => false, + 'docs' => [['title' => '고객만족도 조사절차서', 'version' => '2.0', 'date' => '2025-06-01'], ['title' => '2025년 고객만족도 조사결과', 'version' => '1.0', 'date' => '2025-12-30']]], + ['name' => '지속적 개선 활동', 'description' => '품질개선 과제 발굴, 실행 및 효과 확인', 'is_completed' => false, + 'docs' => [['title' => '지속적개선 절차서', 'version' => '2.5', 'date' => '2025-08-15'], ['title' => '개선활동 보고서 양식', 'version' => '1.5', 'date' => '2025-10-20']]], + ], + ], + ]; + + $categoryCount = 0; + $itemCount = 0; + $docCount = 0; + + foreach ($categoriesData as $catData) { + $categoryId = DB::table('audit_checklist_categories')->insertGetId([ + 'tenant_id' => $tenantId, + 'checklist_id' => $checklistId, + 'title' => $catData['title'], + 'sort_order' => $catData['sort_order'], + 'options' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $categoryCount++; + + foreach ($catData['items'] as $itemIdx => $itemData) { + $completedAt = $itemData['is_completed'] ? $now->copy()->subDays(rand(1, 15)) : null; + + $itemId = DB::table('audit_checklist_items')->insertGetId([ + 'tenant_id' => $tenantId, + 'category_id' => $categoryId, + 'name' => $itemData['name'], + 'description' => $itemData['description'], + 'is_completed' => $itemData['is_completed'], + 'completed_at' => $completedAt, + 'completed_by' => $itemData['is_completed'] ? $userId : null, + 'sort_order' => $itemIdx + 1, + 'options' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $itemCount++; + + foreach ($itemData['docs'] as $docData) { + DB::table('audit_standard_documents')->insert([ + 'tenant_id' => $tenantId, + 'checklist_item_id' => $itemId, + 'title' => $docData['title'], + 'version' => $docData['version'], + 'date' => $docData['date'], + 'document_id' => null, + 'options' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $docCount++; + } + } + } + + $this->command->info(' ✅ audit_checklist_categories: '.$categoryCount.'개 생성'); + $this->command->info(' ✅ audit_checklist_items: '.$itemCount.'개 생성'); + $this->command->info(' ✅ audit_standard_documents: '.$docCount.'개 생성'); + } +} From 079f4b0ffb163e1822cbaaa1bb4b87fb6c448c8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 10 Mar 2026 23:01:12 +0900 Subject: [PATCH 134/166] =?UTF-8?q?feat:=20[=EB=AC=B8=EC=84=9C]=20document?= =?UTF-8?q?=5Fdata,=20document=5Fapprovals,=20document=5Fattachments?= =?UTF-8?q?=EC=97=90=20tenant=5Fid=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 3개 테이블에 tenant_id 컬럼 + 인덱스 추가 - 기존 데이터는 부모 테이블(documents)에서 tenant_id 자동 채움 - 멀티테넌시 일관성 확보 및 데이터 동기화 지원 Co-Authored-By: Claude Opus 4.6 --- ...dd_tenant_id_to_document_detail_tables.php | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 database/migrations/2026_03_10_224806_add_tenant_id_to_document_detail_tables.php diff --git a/database/migrations/2026_03_10_224806_add_tenant_id_to_document_detail_tables.php b/database/migrations/2026_03_10_224806_add_tenant_id_to_document_detail_tables.php new file mode 100644 index 0000000..b34858e --- /dev/null +++ b/database/migrations/2026_03_10_224806_add_tenant_id_to_document_detail_tables.php @@ -0,0 +1,46 @@ +unsignedBigInteger('tenant_id')->nullable()->after('id')->comment('테넌트 ID'); + $table->index('tenant_id'); + }); + + // 부모 테이블(documents)에서 tenant_id 채우기 + DB::statement(" + UPDATE {$table} t + JOIN documents d ON t.document_id = d.id + SET t.tenant_id = d.tenant_id + "); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $tables = ['document_data', 'document_approvals', 'document_attachments']; + + foreach ($tables as $table) { + Schema::table($table, function (Blueprint $table) { + $table->dropIndex(['tenant_id']); + $table->dropColumn('tenant_id'); + }); + } + } +}; \ No newline at end of file From bbaeefb6b5dd7e8a49d9ecdb41fa62b4870d45a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 11 Mar 2026 02:04:47 +0900 Subject: [PATCH 135/166] =?UTF-8?q?sync:=20main=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=202026-03-11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From f0464d4f8cbb39f41d323e31a68bf2695155e4f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 11 Mar 2026 10:06:24 +0900 Subject: [PATCH 136/166] =?UTF-8?q?fix:=20[db]=20codebridge=20DB=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=ED=9B=84=20=EA=B9=A8=EC=A7=84=20FK=20?= =?UTF-8?q?=EC=A0=9C=EC=95=BD=EC=A1=B0=EA=B1=B4=2052=EA=B0=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sam → codebridge 테이블 이동 후 users, tenants 등 참조하는 FK 잔존 - esign_field_templates INSERT 시 FK violation 발생 수정 --- ...op_broken_foreign_keys_from_codebridge.php | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 database/migrations/2026_03_11_100000_drop_broken_foreign_keys_from_codebridge.php diff --git a/database/migrations/2026_03_11_100000_drop_broken_foreign_keys_from_codebridge.php b/database/migrations/2026_03_11_100000_drop_broken_foreign_keys_from_codebridge.php new file mode 100644 index 0000000..d98e0c1 --- /dev/null +++ b/database/migrations/2026_03_11_100000_drop_broken_foreign_keys_from_codebridge.php @@ -0,0 +1,109 @@ +dropForeignKeySafe($connection, 'admin_api_bookmarks', 'admin_api_bookmarks_user_id_foreign'); + $this->dropForeignKeySafe($connection, 'admin_api_deprecations', 'admin_api_deprecations_created_by_foreign'); + $this->dropForeignKeySafe($connection, 'admin_api_environments', 'admin_api_environments_user_id_foreign'); + $this->dropForeignKeySafe($connection, 'admin_api_histories', 'admin_api_histories_user_id_foreign'); + $this->dropForeignKeySafe($connection, 'admin_api_templates', 'admin_api_templates_user_id_foreign'); + $this->dropForeignKeySafe($connection, 'admin_meeting_logs', 'admin_meeting_logs_user_id_foreign'); + $this->dropForeignKeySafe($connection, 'admin_pm_daily_logs', 'admin_pm_daily_logs_deleted_by_foreign'); + $this->dropForeignKeySafe($connection, 'admin_pm_daily_logs', 'admin_pm_daily_logs_created_by_foreign'); + $this->dropForeignKeySafe($connection, 'admin_pm_daily_logs', 'admin_pm_daily_logs_updated_by_foreign'); + $this->dropForeignKeySafe($connection, 'admin_pm_issues', 'admin_pm_issues_assignee_id_foreign'); + $this->dropForeignKeySafe($connection, 'ai_token_usages', 'ai_token_usages_created_by_foreign'); + $this->dropForeignKeySafe($connection, 'business_income_payments', 'business_income_payments_confirmed_by_foreign'); + $this->dropForeignKeySafe($connection, 'business_income_payments', 'business_income_payments_user_id_foreign'); + $this->dropForeignKeySafe($connection, 'business_income_payments', 'business_income_payments_updated_by_foreign'); + $this->dropForeignKeySafe($connection, 'business_income_payments', 'business_income_payments_created_by_foreign'); + $this->dropForeignKeySafe($connection, 'business_income_payments', 'business_income_payments_deleted_by_foreign'); + $this->dropForeignKeySafe($connection, 'demo_links', 'demo_links_created_by_foreign'); + $this->dropForeignKeySafe($connection, 'esign_contracts', 'esign_contracts_updated_by_foreign'); + $this->dropForeignKeySafe($connection, 'esign_contracts', 'esign_contracts_deleted_by_foreign'); + $this->dropForeignKeySafe($connection, 'esign_contracts', 'esign_contracts_created_by_foreign'); + $this->dropForeignKeySafe($connection, 'esign_field_templates', 'esign_field_templates_created_by_foreign'); + $this->dropForeignKeySafe($connection, 'login_tokens', 'login_tokens_user_id_foreign'); + $this->dropForeignKeySafe($connection, 'sales_contract_products', 'sales_contract_products_created_by_foreign'); + $this->dropForeignKeySafe($connection, 'sales_manager_documents', 'sales_manager_documents_uploaded_by_foreign'); + $this->dropForeignKeySafe($connection, 'sales_manager_documents', 'sales_manager_documents_user_id_foreign'); + $this->dropForeignKeySafe($connection, 'sales_scenario_checklists', 'sales_scenario_checklists_user_id_foreign'); + $this->dropForeignKeySafe($connection, 'tenant_prospects', 'tenant_prospects_converted_by_foreign'); + $this->dropForeignKeySafe($connection, 'tenant_prospects', 'tenant_prospects_registered_by_foreign'); + + // 참조 대상: tenants (16개) + $this->dropForeignKeySafe($connection, 'account_codes', 'account_codes_tenant_id_foreign'); + $this->dropForeignKeySafe($connection, 'admin_meeting_logs', 'admin_meeting_logs_tenant_id_foreign'); + $this->dropForeignKeySafe($connection, 'admin_pm_daily_logs', 'admin_pm_daily_logs_tenant_id_foreign'); + $this->dropForeignKeySafe($connection, 'ai_token_usages', 'ai_token_usages_tenant_id_foreign'); + $this->dropForeignKeySafe($connection, 'barobill_bank_sync_status', 'barobill_bank_sync_status_tenant_id_foreign'); + $this->dropForeignKeySafe($connection, 'barobill_bank_transaction_splits', 'barobill_bank_transaction_splits_tenant_id_foreign'); + $this->dropForeignKeySafe($connection, 'barobill_bank_transactions', 'barobill_bank_transactions_tenant_id_foreign'); + $this->dropForeignKeySafe($connection, 'barobill_card_transaction_hides', 'barobill_card_transaction_hides_tenant_id_foreign'); + $this->dropForeignKeySafe($connection, 'barobill_members', 'barobill_members_tenant_id_foreign'); + $this->dropForeignKeySafe($connection, 'business_income_payments', 'business_income_payments_tenant_id_foreign'); + $this->dropForeignKeySafe($connection, 'esign_audit_logs', 'esign_audit_logs_tenant_id_foreign'); + $this->dropForeignKeySafe($connection, 'esign_contracts', 'esign_contracts_tenant_id_foreign'); + $this->dropForeignKeySafe($connection, 'esign_field_templates', 'esign_field_templates_tenant_id_foreign'); + $this->dropForeignKeySafe($connection, 'esign_sign_fields', 'esign_sign_fields_tenant_id_foreign'); + $this->dropForeignKeySafe($connection, 'esign_signers', 'esign_signers_tenant_id_foreign'); + $this->dropForeignKeySafe($connection, 'expense_accounts', 'expense_accounts_tenant_id_foreign'); + $this->dropForeignKeySafe($connection, 'hometax_invoices', 'hometax_invoices_tenant_id_foreign'); + $this->dropForeignKeySafe($connection, 'sales_contract_products', 'sales_contract_products_tenant_id_foreign'); + $this->dropForeignKeySafe($connection, 'sales_manager_documents', 'sales_manager_documents_tenant_id_foreign'); + $this->dropForeignKeySafe($connection, 'sales_scenario_checklists', 'sales_scenario_checklists_tenant_id_foreign'); + $this->dropForeignKeySafe($connection, 'tenant_prospects', 'tenant_prospects_tenant_id_foreign'); + + // 참조 대상: departments (1개) + $this->dropForeignKeySafe($connection, 'admin_pm_issues', 'admin_pm_issues_department_id_foreign'); + + // 참조 대상: processes (1개) + $this->dropForeignKeySafe($connection, 'equipment_process', 'equipment_process_process_id_foreign'); + + // 참조 대상: prospects (1개) + $this->dropForeignKeySafe($connection, 'demo_links', 'demo_links_prospect_id_foreign'); + + // 참조 대상: clients (1개) + $this->dropForeignKeySafe($connection, 'expense_accounts', 'expense_accounts_vendor_id_foreign'); + + // 참조 대상: barobill_card_transactions (1개) + $this->dropForeignKeySafe($connection, 'barobill_card_transaction_amount_logs', 'bb_amount_log_trans_fk'); + } + + public function down(): void + { + // 복원 불필요: 크로스 DB FK는 의도적으로 제거한 것 + } + + private function dropForeignKeySafe(string $connection, string $table, string $foreignKey): void + { + $exists = DB::connection($connection)->select(" + SELECT 1 FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND CONSTRAINT_NAME = ? + AND CONSTRAINT_TYPE = 'FOREIGN KEY' + ", [$table, $foreignKey]); + + if (! empty($exists)) { + DB::connection($connection)->statement("ALTER TABLE `{$table}` DROP FOREIGN KEY `{$foreignKey}`"); + } + } +}; From 6f48b8620607bd5d786cbe49695ecd40d7fa09f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 11 Mar 2026 10:15:59 +0900 Subject: [PATCH 137/166] =?UTF-8?q?fix:=20[account-codes]=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=EA=B3=BC=EB=AA=A9=20=EC=A4=91=EB=B3=B5=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A0=95=EB=A6=AC=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 - 비표준 코드(5자리 KIS 중복, 1-2자리 카테고리 헤더) 비활성화 - 홈택스 분개 코드 수정: 135→117, 251→201, 255→208 --- ...01502_fix_account_codes_duplicate_data.php | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 database/migrations/2026_03_11_101502_fix_account_codes_duplicate_data.php diff --git a/database/migrations/2026_03_11_101502_fix_account_codes_duplicate_data.php b/database/migrations/2026_03_11_101502_fix_account_codes_duplicate_data.php new file mode 100644 index 0000000..197e599 --- /dev/null +++ b/database/migrations/2026_03_11_101502_fix_account_codes_duplicate_data.php @@ -0,0 +1,65 @@ +where('is_active', true) + ->whereRaw('LENGTH(code) != 3') + ->update(['is_active' => false]); + + // 2. 홈택스 분개에서 잘못된 코드 수정 + $fixed135 = DB::table('hometax_invoice_journals') + ->where('account_code', '135') + ->update(['account_code' => '117', 'account_name' => '부가세대급금']); + + $fixed251 = DB::table('hometax_invoice_journals') + ->where('account_code', '251') + ->where('account_name', '외상매입금') + ->update(['account_code' => '201']); + + $fixed255 = DB::table('hometax_invoice_journals') + ->where('account_code', '255') + ->where('account_name', '부가세예수금') + ->update(['account_code' => '208']); + + Log::info('[Migration] 계정과목 정리 완료', [ + 'deactivated_codes' => $deactivated, + 'fixed_135_to_117' => $fixed135, + 'fixed_251_to_201' => $fixed251, + 'fixed_255_to_208' => $fixed255, + ]); + } + + public function down(): void + { + // 비활성화된 비표준 코드 복원 + DB::table('account_codes') + ->where('is_active', false) + ->whereRaw('LENGTH(code) != 3') + ->update(['is_active' => true]); + + // 분개 코드 원복 + DB::table('hometax_invoice_journals') + ->where('account_code', '117') + ->where('account_name', '부가세대급금') + ->update(['account_code' => '135']); + } +}; From 3fd412f89dd430a789c296e812fcb154aff57eaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 11 Mar 2026 16:57:54 +0900 Subject: [PATCH 138/166] =?UTF-8?q?feat:=20[approval]=20=EA=B2=B0=EC=9E=AC?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20MNG=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=EB=A1=9C=20=EC=A0=84=EB=A9=B4=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 보류/보류해제 기능 추가 (hold, releaseHold) - 전결 기능 추가 (preDecide - 이후 결재 건너뛰고 최종 승인) - 복사 재기안 기능 추가 (copyForRedraft) - 반려 후 재상신 로직 (rejection_history 저장, resubmit_count 증가) - 결재자 스냅샷 저장 (approver_name, department, position) - 완료함 목록/현황 API 추가 (completed, completedSummary) - 뱃지 카운트 API 추가 (badgeCounts) - 완료함 일괄 읽음 처리 (markCompletedAsRead) - 위임 관리 CRUD API 추가 (delegations) - Leave 연동 (승인/반려/회수/삭제 시 휴가 상태 동기화) - ApprovalDelegation 모델 신규 생성 - STATUS_ON_HOLD 상수 추가 (Approval, ApprovalStep) - isEditable/isSubmittable 반려 상태 허용으로 확장 - isCancellable 보류 상태 포함 - 회수 시 첫 번째 결재자 처리 여부 검증 추가 - i18n 에러/메시지 키 추가 --- .../Controllers/Api/V1/ApprovalController.php | 147 +++- app/Models/Tenants/Approval.php | 129 +++- app/Models/Tenants/ApprovalDelegation.php | 80 ++ app/Models/Tenants/ApprovalForm.php | 1 + app/Models/Tenants/ApprovalStep.php | 20 + app/Services/ApprovalService.php | 692 +++++++++++++++++- lang/ko/error.php | 15 +- lang/ko/message.php | 4 + routes/api/v1/hr.php | 15 + 9 files changed, 1076 insertions(+), 27 deletions(-) create mode 100644 app/Models/Tenants/ApprovalDelegation.php diff --git a/app/Http/Controllers/Api/V1/ApprovalController.php b/app/Http/Controllers/Api/V1/ApprovalController.php index 1e75426..d2b1a92 100644 --- a/app/Http/Controllers/Api/V1/ApprovalController.php +++ b/app/Http/Controllers/Api/V1/ApprovalController.php @@ -155,11 +155,104 @@ public function reject(int $id, RejectRequest $request): JsonResponse * 결재 회수 (기안자만) * POST /v1/approvals/{id}/cancel */ - public function cancel(int $id): JsonResponse + public function cancel(int $id, Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->cancel($id, $request->input('recall_reason')); + }, __('message.approval.cancelled')); + } + + /** + * 보류 (현재 결재자만) + * POST /v1/approvals/{id}/hold + */ + public function hold(int $id, Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + $comment = $request->input('comment'); + if (empty($comment)) { + throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException(__('error.approval.comment_required')); + } + + return $this->service->hold($id, $comment); + }, __('message.approval.held')); + } + + /** + * 보류 해제 (보류한 결재자만) + * POST /v1/approvals/{id}/release-hold + */ + public function releaseHold(int $id): JsonResponse { return ApiResponse::handle(function () use ($id) { - return $this->service->cancel($id); - }, __('message.approval.cancelled')); + return $this->service->releaseHold($id); + }, __('message.approval.hold_released')); + } + + /** + * 전결 (현재 결재자가 이후 모든 결재를 건너뛰고 최종 승인) + * POST /v1/approvals/{id}/pre-decide + */ + public function preDecide(int $id, Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->preDecide($id, $request->input('comment')); + }, __('message.approval.pre_decided')); + } + + /** + * 복사 재기안 + * POST /v1/approvals/{id}/copy + */ + public function copyForRedraft(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->copyForRedraft($id); + }, __('message.approval.copied')); + } + + /** + * 완료함 목록 + * GET /v1/approvals/completed + */ + public function completed(IndexRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->completed($request->validated()); + }, __('message.fetched')); + } + + /** + * 완료함 현황 카드 + * GET /v1/approvals/completed/summary + */ + public function completedSummary(): JsonResponse + { + return ApiResponse::handle(function () { + return $this->service->completedSummary(); + }, __('message.fetched')); + } + + /** + * 미처리 건수 (뱃지용) + * GET /v1/approvals/badge-counts + */ + public function badgeCounts(): JsonResponse + { + return ApiResponse::handle(function () { + return $this->service->badgeCounts(); + }, __('message.fetched')); + } + + /** + * 완료함 미읽음 일괄 읽음 처리 + * POST /v1/approvals/completed/mark-read + */ + public function markCompletedAsRead(): JsonResponse + { + return ApiResponse::handle(function () { + return $this->service->markCompletedAsRead(); + }, __('message.approval.marked_read')); } /** @@ -183,4 +276,52 @@ public function markUnread(int $id): JsonResponse return $this->service->markUnread($id); }, __('message.approval.marked_unread')); } + + // ========================================================================= + // 위임 관리 + // ========================================================================= + + /** + * 위임 목록 + * GET /v1/approvals/delegations + */ + public function delegationIndex(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->delegationIndex($request->all()); + }, __('message.fetched')); + } + + /** + * 위임 생성 + * POST /v1/approvals/delegations + */ + public function delegationStore(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->delegationStore($request->all()); + }, __('message.created')); + } + + /** + * 위임 수정 + * PATCH /v1/approvals/delegations/{id} + */ + public function delegationUpdate(int $id, Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->delegationUpdate($id, $request->all()); + }, __('message.updated')); + } + + /** + * 위임 삭제 + * DELETE /v1/approvals/delegations/{id} + */ + public function delegationDestroy(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->delegationDestroy($id); + }, __('message.deleted')); + } } diff --git a/app/Models/Tenants/Approval.php b/app/Models/Tenants/Approval.php index 373e14f..270e40c 100644 --- a/app/Models/Tenants/Approval.php +++ b/app/Models/Tenants/Approval.php @@ -39,22 +39,35 @@ class Approval extends Model protected $casts = [ 'content' => 'array', 'attachments' => 'array', + 'rejection_history' => 'array', + 'is_urgent' => 'boolean', 'drafted_at' => 'datetime', 'completed_at' => 'datetime', + 'drafter_read_at' => 'datetime', 'current_step' => 'integer', + 'resubmit_count' => 'integer', ]; protected $fillable = [ 'tenant_id', 'document_number', 'form_id', + 'line_id', 'title', 'content', + 'body', 'status', + 'is_urgent', 'drafter_id', + 'department_id', 'drafted_at', 'completed_at', + 'drafter_read_at', 'current_step', + 'resubmit_count', + 'rejection_history', + 'recall_reason', + 'parent_doc_id', 'attachments', 'linkable_type', 'linkable_id', @@ -66,6 +79,7 @@ class Approval extends Model protected $attributes = [ 'status' => 'draft', 'current_step' => 0, + 'resubmit_count' => 0, ]; // ========================================================================= @@ -82,12 +96,15 @@ class Approval extends Model public const STATUS_CANCELLED = 'cancelled'; // 회수/취소 + public const STATUS_ON_HOLD = 'on_hold'; // 보류 + public const STATUSES = [ self::STATUS_DRAFT, self::STATUS_PENDING, self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED, + self::STATUS_ON_HOLD, ]; // ========================================================================= @@ -102,6 +119,14 @@ public function form(): BelongsTo return $this->belongsTo(ApprovalForm::class, 'form_id'); } + /** + * 결재선 템플릿 + */ + public function line(): BelongsTo + { + return $this->belongsTo(ApprovalLine::class, 'line_id'); + } + /** * 기안자 */ @@ -110,6 +135,30 @@ public function drafter(): BelongsTo return $this->belongsTo(User::class, 'drafter_id'); } + /** + * 기안 부서 + */ + public function department(): BelongsTo + { + return $this->belongsTo(Department::class, 'department_id'); + } + + /** + * 원본 문서 (복사 재기안 시) + */ + public function parentDocument(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_doc_id'); + } + + /** + * 하위 문서들 (이 문서에서 복사된 문서들) + */ + public function childDocuments(): HasMany + { + return $this->hasMany(self::class, 'parent_doc_id'); + } + /** * 결재 단계들 */ @@ -207,11 +256,19 @@ public function scopeRejected($query) } /** - * 완료됨 (승인 또는 반려) + * 완료됨 (승인, 반려, 회수) */ public function scopeCompleted($query) { - return $query->whereIn('status', [self::STATUS_APPROVED, self::STATUS_REJECTED]); + return $query->whereIn('status', [self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED]); + } + + /** + * 보류 상태 + */ + public function scopeOnHold($query) + { + return $query->where('status', self::STATUS_ON_HOLD); } /** @@ -227,19 +284,19 @@ public function scopeByDrafter($query, int $userId) // ========================================================================= /** - * 수정 가능 여부 (임시저장 상태만) + * 수정 가능 여부 (임시저장 또는 반려 상태) */ public function isEditable(): bool { - return $this->status === self::STATUS_DRAFT; + return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_REJECTED]); } /** - * 상신 가능 여부 + * 상신 가능 여부 (임시저장 또는 반려 상태 = 재상신) */ public function isSubmittable(): bool { - return $this->status === self::STATUS_DRAFT; + return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_REJECTED]); } /** @@ -251,21 +308,60 @@ public function isActionable(): bool } /** - * 회수 가능 여부 (기안자만, 진행중 상태) + * 회수 가능 여부 (기안자만, 진행중 또는 보류 상태) */ public function isCancellable(): bool + { + return in_array($this->status, [self::STATUS_PENDING, self::STATUS_ON_HOLD]); + } + + /** + * 보류 가능 여부 (진행중 상태만) + */ + public function isHoldable(): bool { return $this->status === self::STATUS_PENDING; } /** - * 삭제 가능 여부 (임시저장만) + * 보류 해제 가능 여부 (보류 상태만) + */ + public function isHoldReleasable(): bool + { + return $this->status === self::STATUS_ON_HOLD; + } + + /** + * 복사 재기안 가능 여부 (완료/반려/회수 상태) + */ + public function isCopyable(): bool + { + return in_array($this->status, [self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED]); + } + + /** + * 삭제 가능 여부 + * 일반 사용자: 임시저장만 + * 관리자: 별도 isDeletableBy 사용 */ public function isDeletable(): bool { return $this->status === self::STATUS_DRAFT; } + /** + * 사용자 기준 삭제 가능 여부 + * 기안자: 임시저장/반려만 삭제 가능 + */ + public function isDeletableBy(int $userId): bool + { + if ($this->drafter_id !== $userId) { + return false; + } + + return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_REJECTED]); + } + /** * 상태 라벨 */ @@ -277,10 +373,27 @@ public function getStatusLabelAttribute(): string self::STATUS_APPROVED => '완료', self::STATUS_REJECTED => '반려', self::STATUS_CANCELLED => '회수', + self::STATUS_ON_HOLD => '보류', default => $this->status, }; } + /** + * 상태 색상 (UI 배지용) + */ + public function getStatusColorAttribute(): string + { + return match ($this->status) { + self::STATUS_DRAFT => 'gray', + self::STATUS_PENDING => 'blue', + self::STATUS_APPROVED => 'green', + self::STATUS_REJECTED => 'red', + self::STATUS_CANCELLED => 'yellow', + self::STATUS_ON_HOLD => 'orange', + default => 'gray', + }; + } + /** * 현재 결재자 확인 */ diff --git a/app/Models/Tenants/ApprovalDelegation.php b/app/Models/Tenants/ApprovalDelegation.php new file mode 100644 index 0000000..b628784 --- /dev/null +++ b/app/Models/Tenants/ApprovalDelegation.php @@ -0,0 +1,80 @@ + 'array', + 'start_date' => 'date', + 'end_date' => 'date', + 'notify_delegator' => 'boolean', + 'is_active' => 'boolean', + ]; + + protected $fillable = [ + 'tenant_id', + 'delegator_id', + 'delegate_id', + 'start_date', + 'end_date', + 'form_ids', + 'notify_delegator', + 'is_active', + 'reason', + 'created_by', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + /** + * 위임자 (원래 결재자) + */ + public function delegator(): BelongsTo + { + return $this->belongsTo(User::class, 'delegator_id'); + } + + /** + * 대리자 (대신 결재하는 사람) + */ + public function delegate(): BelongsTo + { + return $this->belongsTo(User::class, 'delegate_id'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeForDelegator($query, int $userId) + { + return $query->where('delegator_id', $userId); + } + + public function scopeCurrentlyActive($query) + { + $today = now()->toDateString(); + + return $query->active() + ->where('start_date', '<=', $today) + ->where('end_date', '>=', $today); + } +} diff --git a/app/Models/Tenants/ApprovalForm.php b/app/Models/Tenants/ApprovalForm.php index f013df7..7909298 100644 --- a/app/Models/Tenants/ApprovalForm.php +++ b/app/Models/Tenants/ApprovalForm.php @@ -41,6 +41,7 @@ class ApprovalForm extends Model 'code', 'category', 'template', + 'body_template', 'is_active', 'created_by', 'updated_by', diff --git a/app/Models/Tenants/ApprovalStep.php b/app/Models/Tenants/ApprovalStep.php index 5a7c554..017c7f3 100644 --- a/app/Models/Tenants/ApprovalStep.php +++ b/app/Models/Tenants/ApprovalStep.php @@ -24,10 +24,12 @@ class ApprovalStep extends Model { use Auditable; + protected $table = 'approval_steps'; protected $casts = [ 'step_order' => 'integer', + 'parallel_group' => 'integer', 'acted_at' => 'datetime', 'is_read' => 'boolean', 'read_at' => 'datetime', @@ -41,8 +43,14 @@ class ApprovalStep extends Model 'status', 'comment', 'acted_at', + 'acted_by', 'is_read', 'read_at', + 'parallel_group', + 'approval_type', + 'approver_name', + 'approver_department', + 'approver_position', ]; protected $attributes = [ @@ -62,11 +70,14 @@ class ApprovalStep extends Model public const STATUS_SKIPPED = 'skipped'; // 건너뜀 + public const STATUS_ON_HOLD = 'on_hold'; // 보류 + public const STATUSES = [ self::STATUS_PENDING, self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_SKIPPED, + self::STATUS_ON_HOLD, ]; // ========================================================================= @@ -89,6 +100,14 @@ public function approver(): BelongsTo return $this->belongsTo(User::class, 'approver_id'); } + /** + * 실제 처리자 (위임 결재 시 대리자) + */ + public function actedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'acted_by'); + } + // ========================================================================= // 스코프 // ========================================================================= @@ -164,6 +183,7 @@ public function getStatusLabelAttribute(): string self::STATUS_APPROVED => '승인', self::STATUS_REJECTED => '반려', self::STATUS_SKIPPED => '건너뜀', + self::STATUS_ON_HOLD => '보류', default => $this->status, }; } diff --git a/app/Services/ApprovalService.php b/app/Services/ApprovalService.php index 46ebf16..42d7391 100644 --- a/app/Services/ApprovalService.php +++ b/app/Services/ApprovalService.php @@ -3,10 +3,13 @@ namespace App\Services; use App\Models\Documents\Document; +use App\Models\Members\User; use App\Models\Tenants\Approval; +use App\Models\Tenants\ApprovalDelegation; use App\Models\Tenants\ApprovalForm; use App\Models\Tenants\ApprovalLine; use App\Models\Tenants\ApprovalStep; +use App\Models\Tenants\Leave; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; @@ -626,10 +629,14 @@ public function store(array $data): Approval 'tenant_id' => $tenantId, 'document_number' => $documentNumber, 'form_id' => $form->id, + 'line_id' => $data['line_id'] ?? null, 'title' => $data['title'], 'content' => $data['content'], + 'body' => $data['body'] ?? null, 'status' => $status, + 'is_urgent' => $data['is_urgent'] ?? false, 'drafter_id' => $userId, + 'department_id' => $data['department_id'] ?? null, 'drafted_at' => $status === Approval::STATUS_PENDING ? now() : null, 'attachments' => $data['attachments'] ?? null, 'created_by' => $userId, @@ -687,8 +694,12 @@ public function update(int $id, array $data): Approval $approval->fill([ 'form_id' => $formId, + 'line_id' => $data['line_id'] ?? $approval->line_id, 'title' => $data['title'] ?? $approval->title, 'content' => $data['content'] ?? $approval->content, + 'body' => $data['body'] ?? $approval->body, + 'is_urgent' => $data['is_urgent'] ?? $approval->is_urgent, + 'department_id' => $data['department_id'] ?? $approval->department_id, 'attachments' => $data['attachments'] ?? $approval->attachments, 'updated_by' => $userId, ]); @@ -740,7 +751,7 @@ public function destroy(int $id): bool } /** - * 결재 상신 + * 결재 상신 (draft → pending, rejected → pending 재상신 포함) */ public function submit(int $id, array $data): Approval { @@ -750,6 +761,7 @@ public function submit(int $id, array $data): Approval return DB::transaction(function () use ($id, $data, $tenantId, $userId) { $approval = Approval::query() ->where('tenant_id', $tenantId) + ->with('steps') ->findOrFail($id); if (! $approval->isSubmittable()) { @@ -767,20 +779,48 @@ public function submit(int $id, array $data): Approval } } - // 먼저 approval을 pending으로 변경 (Observer가 올바른 상태로 트리거되도록) + // 반려 후 재상신 처리 + $isResubmit = $approval->status === Approval::STATUS_REJECTED; + if ($isResubmit) { + // 반려 이력 저장 + $rejectedStep = $approval->steps + ->firstWhere('status', ApprovalStep::STATUS_REJECTED); + if ($rejectedStep) { + $history = $approval->rejection_history ?? []; + $history[] = [ + 'round' => $approval->resubmit_count + 1, + 'approver_name' => $rejectedStep->approver_name ?? '', + 'approver_position' => $rejectedStep->approver_position ?? '', + 'comment' => $rejectedStep->comment, + 'rejected_at' => $rejectedStep->acted_at?->format('Y-m-d H:i:s'), + ]; + $approval->rejection_history = $history; + } + + // 기존 steps를 모두 pending으로 초기화 + $approval->steps()->update([ + 'status' => ApprovalStep::STATUS_PENDING, + 'comment' => null, + 'acted_at' => null, + ]); + } + + // approval을 pending으로 변경 $approval->status = Approval::STATUS_PENDING; $approval->drafted_at = now(); $approval->current_step = 1; + $approval->resubmit_count = $isResubmit + ? ($approval->resubmit_count ?? 0) + 1 + : ($approval->resubmit_count ?? 0); $approval->updated_by = $userId; $approval->save(); - // steps가 있으면 새로 생성 (approval이 pending 상태일 때 생성해야 알림 발송) + // steps가 있으면 새로 생성 if (! empty($data['steps'])) { - // 기존 결재선 삭제 후 새로 생성 $approval->steps()->delete(); $this->createApprovalSteps($approval, $data['steps']); } else { - // 기존 결재선 사용 시, Observer가 트리거되지 않으므로 수동으로 알림 발송 + // 기존 결재선 사용 시 알림 발송 $firstPendingStep = $approval->steps() ->where('status', ApprovalStep::STATUS_PENDING) ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) @@ -850,6 +890,7 @@ public function approve(int $id, ?string $comment = null): Approval // 모든 결재 완료 $approval->status = Approval::STATUS_APPROVED; $approval->completed_at = now(); + $approval->drafter_read_at = null; } $approval->current_step = $myStep->step_order + 1; @@ -859,6 +900,11 @@ public function approve(int $id, ?string $comment = null): Approval // Document 브릿지 동기화 $this->syncToLinkedDocument($approval); + // Leave 연동 (승인 완료 시) + if ($approval->status === Approval::STATUS_APPROVED) { + $this->handleApprovalCompleted($approval); + } + return $approval->fresh([ 'form:id,name,code,category', 'drafter:id,name', @@ -909,12 +955,16 @@ public function reject(int $id, string $comment): Approval // 문서 반려 상태로 변경 $approval->status = Approval::STATUS_REJECTED; $approval->completed_at = now(); + $approval->drafter_read_at = null; $approval->updated_by = $userId; $approval->save(); // Document 브릿지 동기화 $this->syncToLinkedDocument($approval); + // Leave 연동 (반려 시) + $this->handleApprovalRejected($approval, $comment); + return $approval->fresh([ 'form:id,name,code,category', 'drafter:id,name', @@ -928,14 +978,14 @@ public function reject(int $id, string $comment): Approval } /** - * 결재 회수 (기안자만) + * 결재 회수 (기안자만, pending/on_hold → cancelled) */ - public function cancel(int $id): Approval + public function cancel(int $id, ?string $recallReason = null): Approval { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - return DB::transaction(function () use ($id, $tenantId, $userId) { + return DB::transaction(function () use ($id, $recallReason, $tenantId, $userId) { $approval = Approval::query() ->where('tenant_id', $tenantId) ->findOrFail($id); @@ -949,24 +999,407 @@ public function cancel(int $id): Approval throw new BadRequestHttpException(__('error.approval.only_drafter_can_cancel')); } + // 첫 번째 결재자가 이미 처리했으면 회수 불가 + $firstApproverStep = $approval->steps() + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) + ->orderBy('step_order') + ->first(); + + if ($firstApproverStep + && $firstApproverStep->status !== ApprovalStep::STATUS_PENDING + && $firstApproverStep->status !== ApprovalStep::STATUS_ON_HOLD) { + throw new BadRequestHttpException(__('error.approval.first_approver_already_acted')); + } + + // 모든 pending/on_hold steps → skipped + $approval->steps() + ->whereIn('status', [ApprovalStep::STATUS_PENDING, ApprovalStep::STATUS_ON_HOLD]) + ->update(['status' => ApprovalStep::STATUS_SKIPPED]); + $approval->status = Approval::STATUS_CANCELLED; $approval->completed_at = now(); + $approval->recall_reason = $recallReason; $approval->updated_by = $userId; $approval->save(); - // Document 브릿지 동기화 (steps 삭제 전에 실행) + // Document 브릿지 동기화 $this->syncToLinkedDocument($approval); - // 결재 단계들 삭제 - $approval->steps()->delete(); + // Leave 연동 (회수 시) + $this->handleApprovalCancelled($approval); return $approval->fresh([ 'form:id,name,code,category', 'drafter:id,name', + 'steps.approver:id,name', ]); }); } + /** + * 보류 (현재 결재자만, pending → on_hold) + */ + public function hold(int $id, string $comment): Approval + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $comment, $tenantId, $userId) { + $approval = Approval::query() + ->where('tenant_id', $tenantId) + ->with('steps') + ->findOrFail($id); + + if (! $approval->isHoldable()) { + throw new BadRequestHttpException(__('error.approval.not_holdable')); + } + + $currentStep = $approval->getCurrentApproverStep(); + if (! $currentStep || $currentStep->approver_id !== $userId) { + throw new BadRequestHttpException(__('error.approval.not_your_turn')); + } + + $currentStep->status = ApprovalStep::STATUS_ON_HOLD; + $currentStep->comment = $comment; + $currentStep->acted_at = now(); + $currentStep->save(); + + $approval->status = Approval::STATUS_ON_HOLD; + $approval->updated_by = $userId; + $approval->save(); + + return $approval->fresh([ + 'form:id,name,code,category', + 'drafter:id,name', + 'steps.approver:id,name', + 'steps.approver.tenantProfile:id,user_id,tenant_id,department_id,position_key,display_name', + 'steps.approver.tenantProfile.department:id,name', + ]); + }); + } + + /** + * 보류 해제 (보류한 결재자만, on_hold → pending) + */ + public function releaseHold(int $id): Approval + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $tenantId, $userId) { + $approval = Approval::query() + ->where('tenant_id', $tenantId) + ->with('steps') + ->findOrFail($id); + + if (! $approval->isHoldReleasable()) { + throw new BadRequestHttpException(__('error.approval.not_hold_releasable')); + } + + $holdStep = $approval->steps() + ->where('status', ApprovalStep::STATUS_ON_HOLD) + ->first(); + + if (! $holdStep || $holdStep->approver_id !== $userId) { + throw new BadRequestHttpException(__('error.approval.only_holder_can_release')); + } + + $holdStep->status = ApprovalStep::STATUS_PENDING; + $holdStep->comment = null; + $holdStep->acted_at = null; + $holdStep->save(); + + $approval->status = Approval::STATUS_PENDING; + $approval->updated_by = $userId; + $approval->save(); + + return $approval->fresh([ + 'form:id,name,code,category', + 'drafter:id,name', + 'steps.approver:id,name', + 'steps.approver.tenantProfile:id,user_id,tenant_id,department_id,position_key,display_name', + 'steps.approver.tenantProfile.department:id,name', + ]); + }); + } + + /** + * 전결 (현재 결재자가 이후 모든 결재를 건너뛰고 최종 승인) + */ + public function preDecide(int $id, ?string $comment = null): Approval + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $comment, $tenantId, $userId) { + $approval = Approval::query() + ->where('tenant_id', $tenantId) + ->with('steps') + ->findOrFail($id); + + if (! $approval->isActionable()) { + throw new BadRequestHttpException(__('error.approval.not_actionable')); + } + + $currentStep = $approval->getCurrentApproverStep(); + if (! $currentStep || $currentStep->approver_id !== $userId) { + throw new BadRequestHttpException(__('error.approval.not_your_turn')); + } + + // 현재 step → approved + pre_decided + $currentStep->status = ApprovalStep::STATUS_APPROVED; + $currentStep->approval_type = 'pre_decided'; + $currentStep->comment = $comment; + $currentStep->acted_at = now(); + $currentStep->save(); + + // 이후 모든 pending approval/agreement steps → skipped + $approval->steps() + ->where('step_order', '>', $currentStep->step_order) + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) + ->where('status', ApprovalStep::STATUS_PENDING) + ->update(['status' => ApprovalStep::STATUS_SKIPPED]); + + // 문서 최종 승인 + $approval->status = Approval::STATUS_APPROVED; + $approval->completed_at = now(); + $approval->drafter_read_at = null; + $approval->updated_by = $userId; + $approval->save(); + + // Document 브릿지 동기화 + $this->syncToLinkedDocument($approval); + + // Leave 연동 (승인 완료) + $this->handleApprovalCompleted($approval); + + return $approval->fresh([ + 'form:id,name,code,category', + 'drafter:id,name', + 'drafter.tenantProfile:id,user_id,tenant_id,department_id,position_key,display_name', + 'drafter.tenantProfile.department:id,name', + 'steps.approver:id,name', + 'steps.approver.tenantProfile:id,user_id,tenant_id,department_id,position_key,display_name', + 'steps.approver.tenantProfile.department:id,name', + ]); + }); + } + + /** + * 복사 재기안 (완료/반려/회수 문서를 복사하여 새 draft 생성) + */ + public function copyForRedraft(int $id): Approval + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $tenantId, $userId) { + $original = Approval::query() + ->where('tenant_id', $tenantId) + ->with('steps') + ->findOrFail($id); + + if (! $original->isCopyable()) { + throw new BadRequestHttpException(__('error.approval.not_copyable')); + } + + if ($original->drafter_id !== $userId) { + throw new BadRequestHttpException(__('error.approval.only_drafter_can_copy')); + } + + $documentNumber = $this->generateDocumentNumber($tenantId); + + $newApproval = Approval::create([ + 'tenant_id' => $tenantId, + 'document_number' => $documentNumber, + 'form_id' => $original->form_id, + 'line_id' => $original->line_id, + 'title' => $original->title, + 'content' => $original->content, + 'body' => $original->body, + 'status' => Approval::STATUS_DRAFT, + 'is_urgent' => $original->is_urgent, + 'drafter_id' => $userId, + 'department_id' => $original->department_id, + 'current_step' => 0, + 'parent_doc_id' => $original->id, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + // 결재선 복사 (모두 pending 상태로, 스냅샷 유지) + foreach ($original->steps as $step) { + ApprovalStep::create([ + 'approval_id' => $newApproval->id, + 'step_order' => $step->step_order, + 'step_type' => $step->step_type, + 'approver_id' => $step->approver_id, + 'approver_name' => $step->approver_name, + 'approver_department' => $step->approver_department, + 'approver_position' => $step->approver_position, + 'status' => ApprovalStep::STATUS_PENDING, + ]); + } + + return $newApproval->fresh([ + 'form:id,name,code,category', + 'drafter:id,name', + 'steps.approver:id,name', + 'steps.approver.tenantProfile:id,user_id,tenant_id,department_id,position_key,display_name', + 'steps.approver.tenantProfile.department:id,name', + ]); + }); + } + + /** + * 완료함 - 내가 기안한 완료 문서 + 내가 결재 처리한 문서 + */ + public function completed(array $params): LengthAwarePaginator + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $query = Approval::query() + ->where('tenant_id', $tenantId) + ->where(function ($q) use ($userId) { + $q->where(function ($sub) use ($userId) { + $sub->where('drafter_id', $userId) + ->whereIn('status', [ + Approval::STATUS_APPROVED, + Approval::STATUS_REJECTED, + Approval::STATUS_CANCELLED, + ]); + }) + ->orWhereHas('steps', function ($sub) use ($userId) { + $sub->where('approver_id', $userId) + ->whereIn('status', [ApprovalStep::STATUS_APPROVED, ApprovalStep::STATUS_REJECTED]); + }); + }) + ->with([ + 'form:id,name,code,category', + 'drafter:id,name', + 'drafter.tenantProfile:id,user_id,position_key,department_id', + 'drafter.tenantProfile.department:id,name', + 'steps.approver:id,name', + ]); + + if (! empty($params['search'])) { + $query->where(function ($q) use ($params) { + $q->where('title', 'like', "%{$params['search']}%") + ->orWhere('document_number', 'like', "%{$params['search']}%"); + }); + } + + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + $sortBy = $params['sort_by'] ?? 'updated_at'; + $sortDir = $params['sort_dir'] ?? 'desc'; + $query->orderBy($sortBy, $sortDir); + + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 완료함 현황 카드 + */ + public function completedSummary(): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $myCompleted = Approval::query() + ->where('tenant_id', $tenantId) + ->where('drafter_id', $userId) + ->whereIn('status', [Approval::STATUS_APPROVED, Approval::STATUS_REJECTED, Approval::STATUS_CANCELLED]) + ->selectRaw('status, COUNT(*) as count') + ->groupBy('status') + ->pluck('count', 'status') + ->toArray(); + + $unreadCount = Approval::query() + ->where('tenant_id', $tenantId) + ->where('drafter_id', $userId) + ->whereIn('status', [Approval::STATUS_APPROVED, Approval::STATUS_REJECTED]) + ->whereNull('drafter_read_at') + ->count(); + + return [ + 'approved' => $myCompleted[Approval::STATUS_APPROVED] ?? 0, + 'rejected' => $myCompleted[Approval::STATUS_REJECTED] ?? 0, + 'cancelled' => $myCompleted[Approval::STATUS_CANCELLED] ?? 0, + 'unread' => $unreadCount, + ]; + } + + /** + * 미처리 건수 (뱃지용) + */ + public function badgeCounts(): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $pendingCount = Approval::query() + ->where('tenant_id', $tenantId) + ->where('status', Approval::STATUS_PENDING) + ->whereHas('steps', function ($q) use ($userId) { + $q->where('approver_id', $userId) + ->where('status', ApprovalStep::STATUS_PENDING) + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) + ->whereRaw('step_order = (SELECT MIN(s2.step_order) FROM approval_steps s2 WHERE s2.approval_id = approval_steps.approval_id AND s2.status = ?)', [ApprovalStep::STATUS_PENDING]); + }) + ->count(); + + $draftCount = Approval::query() + ->where('tenant_id', $tenantId) + ->where('status', Approval::STATUS_PENDING) + ->where('drafter_id', $userId) + ->count(); + + $referenceUnreadCount = ApprovalStep::query() + ->where('approver_id', $userId) + ->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE) + ->where('is_read', false) + ->whereHas('approval', function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId); + }) + ->count(); + + $completedUnreadCount = Approval::query() + ->where('tenant_id', $tenantId) + ->where('drafter_id', $userId) + ->whereIn('status', [Approval::STATUS_APPROVED, Approval::STATUS_REJECTED]) + ->whereNull('drafter_read_at') + ->count(); + + return [ + 'pending' => $pendingCount, + 'draft' => $draftCount, + 'reference_unread' => $referenceUnreadCount, + 'completed_unread' => $completedUnreadCount, + ]; + } + + /** + * 완료함 미읽음 일괄 읽음 처리 + */ + public function markCompletedAsRead(): int + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return Approval::query() + ->where('tenant_id', $tenantId) + ->where('drafter_id', $userId) + ->whereIn('status', [Approval::STATUS_APPROVED, Approval::STATUS_REJECTED]) + ->whereNull('drafter_read_at') + ->update(['drafter_read_at' => now()]); + } + /** * Approval → Document 브릿지 동기화 * 결재 승인/반려/회수 시 연결된 Document의 상태와 결재란을 동기화 @@ -1118,12 +1551,13 @@ private function generateDocumentNumber(int $tenantId): string } /** - * 결재 단계 생성 + * 결재 단계 생성 + 결재자 스냅샷 저장 * 프론트엔드 호환성: step_type/approver_id 또는 type/user_id 지원 * 중복 결재자 자동 제거 */ private function createApprovalSteps(Approval $approval, array $steps): void { + $tenantId = $this->tenantId(); $order = 1; $processedApprovers = []; // 중복 체크용 @@ -1142,14 +1576,248 @@ private function createApprovalSteps(Approval $approval, array $steps): void } $processedApprovers[] = $approverId; + // 결재자 스냅샷 (이름/부서/직위) + $approverName = $step['approver_name'] ?? ''; + $approverDepartment = $step['approver_department'] ?? null; + $approverPosition = $step['approver_position'] ?? null; + + // 스냅샷이 비어있으면 DB에서 조회 + if (empty($approverName)) { + $user = User::find($approverId); + if ($user) { + $approverName = $user->name; + $profile = $user->tenantProfile ?? $user->tenantProfiles() + ->where('tenant_id', $tenantId) + ->first(); + if ($profile) { + $approverDepartment = $approverDepartment ?: ($profile->department?->name ?? null); + $approverPosition = $approverPosition ?: ($profile->position_label ?? $profile->job_title_label ?? null); + } + } + } + ApprovalStep::create([ 'approval_id' => $approval->id, 'step_order' => $stepOrder, 'step_type' => $stepType, 'approver_id' => $approverId, + 'approver_name' => $approverName, + 'approver_department' => $approverDepartment, + 'approver_position' => $approverPosition, 'status' => ApprovalStep::STATUS_PENDING, ]); } } } + + // ========================================================================= + // Leave 연동 (휴가/근태신청/사유서) + // ========================================================================= + + /** + * 휴가/근태신청/사유서 관련 결재 양식인지 확인 + */ + private function isLeaveRelatedForm(?string $code): bool + { + return in_array($code, ['leave', 'attendance_request', 'reason_report']); + } + + /** + * 결재 최종 승인 시 연동 처리 + */ + private function handleApprovalCompleted(Approval $approval): void + { + $approval->loadMissing('form'); + + if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) { + return; + } + + $leave = Leave::where('approval_id', $approval->id)->first(); + + // 기안함에서 직접 올린 경우: Leave 레코드 자동 생성 + if (! $leave && ! empty($approval->content['leave_type'])) { + $leave = $this->createLeaveFromApproval($approval); + } + + if ($leave && $leave->status === 'pending') { + $leave->update([ + 'status' => 'approved', + 'updated_by' => $approval->updated_by, + ]); + } + } + + /** + * 결재 반려 시 연동 처리 + */ + private function handleApprovalRejected(Approval $approval, string $comment): void + { + $approval->loadMissing('form'); + + if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) { + return; + } + + $leave = Leave::where('approval_id', $approval->id)->first(); + if ($leave && $leave->status === 'pending') { + $leave->update([ + 'status' => 'rejected', + 'updated_by' => $approval->updated_by, + ]); + } + } + + /** + * 결재 회수 시 연동 처리 + */ + private function handleApprovalCancelled(Approval $approval): void + { + $approval->loadMissing('form'); + + if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) { + return; + } + + $leave = Leave::where('approval_id', $approval->id)->first(); + if ($leave && in_array($leave->status, ['pending', 'approved'])) { + $leave->update([ + 'status' => 'cancelled', + 'updated_by' => $approval->updated_by, + ]); + } + } + + /** + * 결재 삭제 시 연동 처리 + */ + private function handleApprovalDeleted(Approval $approval): void + { + $approval->loadMissing('form'); + + if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) { + return; + } + + $leave = Leave::where('approval_id', $approval->id)->first(); + if ($leave && in_array($leave->status, ['pending', 'approved'])) { + $leave->update([ + 'status' => 'cancelled', + 'updated_by' => $this->apiUserId(), + ]); + } + } + + /** + * 기안함에서 직접 올린 결재 → Leave 레코드 자동 생성 + */ + private function createLeaveFromApproval(Approval $approval): Leave + { + $content = $approval->content; + + return Leave::create([ + 'tenant_id' => $approval->tenant_id, + 'user_id' => $content['user_id'] ?? $approval->drafter_id, + 'leave_type' => $content['leave_type'], + 'start_date' => $content['start_date'], + 'end_date' => $content['end_date'], + 'days' => $content['days'] ?? 0, + 'reason' => $content['reason'] ?? null, + 'status' => 'pending', + 'approval_id' => $approval->id, + 'created_by' => $approval->drafter_id, + 'updated_by' => $approval->drafter_id, + ]); + } + + // ========================================================================= + // 위임 관리 + // ========================================================================= + + /** + * 위임 목록 + */ + public function delegationIndex(array $params): LengthAwarePaginator + { + $tenantId = $this->tenantId(); + + $query = ApprovalDelegation::query() + ->where('tenant_id', $tenantId) + ->with(['delegator:id,name', 'delegate:id,name']); + + if (! empty($params['delegator_id'])) { + $query->where('delegator_id', $params['delegator_id']); + } + + if (isset($params['is_active'])) { + $query->where('is_active', $params['is_active']); + } + + $query->orderByDesc('created_at'); + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 위임 생성 + */ + public function delegationStore(array $data): ApprovalDelegation + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return ApprovalDelegation::create([ + 'tenant_id' => $tenantId, + 'delegator_id' => $data['delegator_id'], + 'delegate_id' => $data['delegate_id'], + 'start_date' => $data['start_date'], + 'end_date' => $data['end_date'], + 'form_ids' => $data['form_ids'] ?? null, + 'notify_delegator' => $data['notify_delegator'] ?? true, + 'is_active' => $data['is_active'] ?? true, + 'reason' => $data['reason'] ?? null, + 'created_by' => $userId, + ]); + } + + /** + * 위임 수정 + */ + public function delegationUpdate(int $id, array $data): ApprovalDelegation + { + $tenantId = $this->tenantId(); + + $delegation = ApprovalDelegation::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + $delegation->fill([ + 'delegate_id' => $data['delegate_id'] ?? $delegation->delegate_id, + 'start_date' => $data['start_date'] ?? $delegation->start_date, + 'end_date' => $data['end_date'] ?? $delegation->end_date, + 'form_ids' => array_key_exists('form_ids', $data) ? $data['form_ids'] : $delegation->form_ids, + 'notify_delegator' => $data['notify_delegator'] ?? $delegation->notify_delegator, + 'is_active' => $data['is_active'] ?? $delegation->is_active, + 'reason' => $data['reason'] ?? $delegation->reason, + ]); + + $delegation->save(); + + return $delegation->fresh(['delegator:id,name', 'delegate:id,name']); + } + + /** + * 위임 삭제 + */ + public function delegationDestroy(int $id): bool + { + $tenantId = $this->tenantId(); + + $delegation = ApprovalDelegation::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + return $delegation->delete(); + } } diff --git a/lang/ko/error.php b/lang/ko/error.php index 7684cc9..bc537ea 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -232,15 +232,22 @@ 'form_code_exists' => '중복된 양식 코드입니다.', 'form_in_use' => '사용 중인 양식은 삭제할 수 없습니다.', 'line_not_found' => '결재선을 찾을 수 없습니다.', - 'not_editable' => '임시저장 상태의 문서만 수정할 수 있습니다.', - 'not_deletable' => '임시저장 상태의 문서만 삭제할 수 있습니다.', - 'not_submittable' => '임시저장 상태의 문서만 상신할 수 있습니다.', + 'not_editable' => '수정할 수 없는 상태입니다.', + 'not_deletable' => '삭제할 수 없는 상태입니다.', + 'not_submittable' => '상신할 수 없는 상태입니다.', 'not_actionable' => '진행중인 문서에서만 결재 가능합니다.', - 'not_cancellable' => '진행중인 문서만 회수할 수 있습니다.', + 'not_cancellable' => '회수할 수 없는 상태입니다.', + 'not_holdable' => '보류할 수 없는 상태입니다.', + 'not_hold_releasable' => '보류 해제할 수 없는 상태입니다.', + 'not_copyable' => '복사할 수 없는 상태입니다.', 'not_your_turn' => '현재 결재 순서가 아닙니다.', 'only_drafter_can_cancel' => '기안자만 회수할 수 있습니다.', + 'only_drafter_can_copy' => '기안자만 복사할 수 있습니다.', + 'only_holder_can_release' => '보류한 결재자만 해제할 수 있습니다.', + 'first_approver_already_acted' => '첫 번째 결재자가 이미 처리하여 회수할 수 없습니다.', 'steps_required' => '결재선은 필수입니다.', 'reject_comment_required' => '반려 사유는 필수입니다.', + 'comment_required' => '사유를 입력해주세요.', 'not_referee' => '참조자가 아닙니다.', ], diff --git a/lang/ko/message.php b/lang/ko/message.php index 5f20784..60a6b53 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -244,6 +244,10 @@ 'approved' => '결재가 승인되었습니다.', 'rejected' => '결재가 반려되었습니다.', 'cancelled' => '결재가 회수되었습니다.', + 'held' => '결재가 보류되었습니다.', + 'hold_released' => '보류가 해제되었습니다.', + 'pre_decided' => '전결 처리되었습니다.', + 'copied' => '문서가 복사되었습니다.', 'marked_read' => '열람 처리되었습니다.', 'marked_unread' => '미열람 처리되었습니다.', 'form_created' => '결재 양식이 등록되었습니다.', diff --git a/routes/api/v1/hr.php b/routes/api/v1/hr.php index 0353a7e..102addb 100644 --- a/routes/api/v1/hr.php +++ b/routes/api/v1/hr.php @@ -139,6 +139,17 @@ Route::get('/inbox/summary', [ApprovalController::class, 'inboxSummary'])->name('v1.approvals.inbox.summary'); // 참조함 Route::get('/reference', [ApprovalController::class, 'reference'])->name('v1.approvals.reference'); + // 완료함 + Route::get('/completed', [ApprovalController::class, 'completed'])->name('v1.approvals.completed'); + Route::get('/completed/summary', [ApprovalController::class, 'completedSummary'])->name('v1.approvals.completed.summary'); + Route::post('/completed/mark-read', [ApprovalController::class, 'markCompletedAsRead'])->name('v1.approvals.completed.mark-read'); + // 뱃지 카운트 + Route::get('/badge-counts', [ApprovalController::class, 'badgeCounts'])->name('v1.approvals.badge-counts'); + // 위임 관리 + Route::get('/delegations', [ApprovalController::class, 'delegationIndex'])->name('v1.approvals.delegations.index'); + Route::post('/delegations', [ApprovalController::class, 'delegationStore'])->name('v1.approvals.delegations.store'); + Route::patch('/delegations/{id}', [ApprovalController::class, 'delegationUpdate'])->whereNumber('id')->name('v1.approvals.delegations.update'); + Route::delete('/delegations/{id}', [ApprovalController::class, 'delegationDestroy'])->whereNumber('id')->name('v1.approvals.delegations.destroy'); // CRUD Route::post('', [ApprovalController::class, 'store'])->name('v1.approvals.store'); Route::get('/{id}', [ApprovalController::class, 'show'])->whereNumber('id')->name('v1.approvals.show'); @@ -149,6 +160,10 @@ 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}/hold', [ApprovalController::class, 'hold'])->whereNumber('id')->name('v1.approvals.hold'); + Route::post('/{id}/release-hold', [ApprovalController::class, 'releaseHold'])->whereNumber('id')->name('v1.approvals.release-hold'); + Route::post('/{id}/pre-decide', [ApprovalController::class, 'preDecide'])->whereNumber('id')->name('v1.approvals.pre-decide'); + Route::post('/{id}/copy', [ApprovalController::class, 'copyForRedraft'])->whereNumber('id')->name('v1.approvals.copy'); // 참조 열람 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'); From 0be88f95ca49f2d12a6d20c9aa80f059296b3f81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 11 Mar 2026 17:13:08 +0900 Subject: [PATCH 139/166] =?UTF-8?q?refactor:=20[approval]=20SAM=20API=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EC=A4=80=EC=88=98=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ApprovalStep에 BelongsToTenant, SoftDeletes 추가 (마이그레이션 포함) - ApprovalForm, ApprovalDelegation에 ModelTrait 추가 (중복 scopeActive 제거) - ApprovalDelegation에 Auditable 추가 - 모든 결재 액션에 FormRequest 적용 (approve, cancel, hold, preDecide) - 위임 CRUD에 DelegationStoreRequest, DelegationUpdateRequest 적용 - ApprovalStep 생성 시 tenant_id 포함 --- .../Controllers/Api/V1/ApprovalController.php | 35 ++++++++++--------- app/Http/Requests/Approval/ApproveRequest.php | 20 +++++++++++ app/Http/Requests/Approval/CancelRequest.php | 20 +++++++++++ .../Approval/DelegationStoreRequest.php | 26 ++++++++++++++ .../Approval/DelegationUpdateRequest.php | 27 ++++++++++++++ app/Http/Requests/Approval/HoldRequest.php | 27 ++++++++++++++ .../Requests/Approval/PreDecideRequest.php | 20 +++++++++++ app/Models/Tenants/ApprovalDelegation.php | 9 ++--- app/Models/Tenants/ApprovalForm.php | 11 ++---- app/Models/Tenants/ApprovalStep.php | 6 +++- app/Services/ApprovalService.php | 2 ++ ...d_soft_deletes_to_approval_steps_table.php | 34 ++++++++++++++++++ 12 files changed, 204 insertions(+), 33 deletions(-) create mode 100644 app/Http/Requests/Approval/ApproveRequest.php create mode 100644 app/Http/Requests/Approval/CancelRequest.php create mode 100644 app/Http/Requests/Approval/DelegationStoreRequest.php create mode 100644 app/Http/Requests/Approval/DelegationUpdateRequest.php create mode 100644 app/Http/Requests/Approval/HoldRequest.php create mode 100644 app/Http/Requests/Approval/PreDecideRequest.php create mode 100644 database/migrations/2026_03_11_100001_add_tenant_id_and_soft_deletes_to_approval_steps_table.php diff --git a/app/Http/Controllers/Api/V1/ApprovalController.php b/app/Http/Controllers/Api/V1/ApprovalController.php index d2b1a92..fe29e40 100644 --- a/app/Http/Controllers/Api/V1/ApprovalController.php +++ b/app/Http/Controllers/Api/V1/ApprovalController.php @@ -4,8 +4,14 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\Approval\ApproveRequest; +use App\Http\Requests\Approval\CancelRequest; +use App\Http\Requests\Approval\DelegationStoreRequest; +use App\Http\Requests\Approval\DelegationUpdateRequest; +use App\Http\Requests\Approval\HoldRequest; use App\Http\Requests\Approval\InboxIndexRequest; use App\Http\Requests\Approval\IndexRequest; +use App\Http\Requests\Approval\PreDecideRequest; use App\Http\Requests\Approval\ReferenceIndexRequest; use App\Http\Requests\Approval\RejectRequest; use App\Http\Requests\Approval\StoreRequest; @@ -133,10 +139,10 @@ public function submit(int $id, SubmitRequest $request): JsonResponse * 결재 승인 * POST /v1/approvals/{id}/approve */ - public function approve(int $id, Request $request): JsonResponse + public function approve(int $id, ApproveRequest $request): JsonResponse { return ApiResponse::handle(function () use ($id, $request) { - return $this->service->approve($id, $request->input('comment')); + return $this->service->approve($id, $request->validated()['comment'] ?? null); }, __('message.approval.approved')); } @@ -155,10 +161,10 @@ public function reject(int $id, RejectRequest $request): JsonResponse * 결재 회수 (기안자만) * POST /v1/approvals/{id}/cancel */ - public function cancel(int $id, Request $request): JsonResponse + public function cancel(int $id, CancelRequest $request): JsonResponse { return ApiResponse::handle(function () use ($id, $request) { - return $this->service->cancel($id, $request->input('recall_reason')); + return $this->service->cancel($id, $request->validated()['recall_reason'] ?? null); }, __('message.approval.cancelled')); } @@ -166,15 +172,10 @@ public function cancel(int $id, Request $request): JsonResponse * 보류 (현재 결재자만) * POST /v1/approvals/{id}/hold */ - public function hold(int $id, Request $request): JsonResponse + public function hold(int $id, HoldRequest $request): JsonResponse { return ApiResponse::handle(function () use ($id, $request) { - $comment = $request->input('comment'); - if (empty($comment)) { - throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException(__('error.approval.comment_required')); - } - - return $this->service->hold($id, $comment); + return $this->service->hold($id, $request->validated()['comment']); }, __('message.approval.held')); } @@ -193,10 +194,10 @@ public function releaseHold(int $id): JsonResponse * 전결 (현재 결재자가 이후 모든 결재를 건너뛰고 최종 승인) * POST /v1/approvals/{id}/pre-decide */ - public function preDecide(int $id, Request $request): JsonResponse + public function preDecide(int $id, PreDecideRequest $request): JsonResponse { return ApiResponse::handle(function () use ($id, $request) { - return $this->service->preDecide($id, $request->input('comment')); + return $this->service->preDecide($id, $request->validated()['comment'] ?? null); }, __('message.approval.pre_decided')); } @@ -296,10 +297,10 @@ public function delegationIndex(Request $request): JsonResponse * 위임 생성 * POST /v1/approvals/delegations */ - public function delegationStore(Request $request): JsonResponse + public function delegationStore(DelegationStoreRequest $request): JsonResponse { return ApiResponse::handle(function () use ($request) { - return $this->service->delegationStore($request->all()); + return $this->service->delegationStore($request->validated()); }, __('message.created')); } @@ -307,10 +308,10 @@ public function delegationStore(Request $request): JsonResponse * 위임 수정 * PATCH /v1/approvals/delegations/{id} */ - public function delegationUpdate(int $id, Request $request): JsonResponse + public function delegationUpdate(int $id, DelegationUpdateRequest $request): JsonResponse { return ApiResponse::handle(function () use ($id, $request) { - return $this->service->delegationUpdate($id, $request->all()); + return $this->service->delegationUpdate($id, $request->validated()); }, __('message.updated')); } diff --git a/app/Http/Requests/Approval/ApproveRequest.php b/app/Http/Requests/Approval/ApproveRequest.php new file mode 100644 index 0000000..592c039 --- /dev/null +++ b/app/Http/Requests/Approval/ApproveRequest.php @@ -0,0 +1,20 @@ + 'nullable|string|max:1000', + ]; + } +} diff --git a/app/Http/Requests/Approval/CancelRequest.php b/app/Http/Requests/Approval/CancelRequest.php new file mode 100644 index 0000000..ae8ace0 --- /dev/null +++ b/app/Http/Requests/Approval/CancelRequest.php @@ -0,0 +1,20 @@ + 'nullable|string|max:1000', + ]; + } +} diff --git a/app/Http/Requests/Approval/DelegationStoreRequest.php b/app/Http/Requests/Approval/DelegationStoreRequest.php new file mode 100644 index 0000000..e73488f --- /dev/null +++ b/app/Http/Requests/Approval/DelegationStoreRequest.php @@ -0,0 +1,26 @@ + 'required|integer|exists:users,id', + 'start_date' => 'required|date|after_or_equal:today', + 'end_date' => 'required|date|after_or_equal:start_date', + 'form_ids' => 'nullable|array', + 'form_ids.*' => 'integer|exists:approval_forms,id', + 'notify_delegator' => 'nullable|boolean', + 'reason' => 'nullable|string|max:500', + ]; + } +} diff --git a/app/Http/Requests/Approval/DelegationUpdateRequest.php b/app/Http/Requests/Approval/DelegationUpdateRequest.php new file mode 100644 index 0000000..b147f44 --- /dev/null +++ b/app/Http/Requests/Approval/DelegationUpdateRequest.php @@ -0,0 +1,27 @@ + 'nullable|integer|exists:users,id', + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'form_ids' => 'nullable|array', + 'form_ids.*' => 'integer|exists:approval_forms,id', + 'notify_delegator' => 'nullable|boolean', + 'is_active' => 'nullable|boolean', + 'reason' => 'nullable|string|max:500', + ]; + } +} diff --git a/app/Http/Requests/Approval/HoldRequest.php b/app/Http/Requests/Approval/HoldRequest.php new file mode 100644 index 0000000..aea97a5 --- /dev/null +++ b/app/Http/Requests/Approval/HoldRequest.php @@ -0,0 +1,27 @@ + 'required|string|max:1000', + ]; + } + + public function messages(): array + { + return [ + 'comment.required' => __('error.approval.comment_required'), + ]; + } +} diff --git a/app/Http/Requests/Approval/PreDecideRequest.php b/app/Http/Requests/Approval/PreDecideRequest.php new file mode 100644 index 0000000..e765469 --- /dev/null +++ b/app/Http/Requests/Approval/PreDecideRequest.php @@ -0,0 +1,20 @@ + 'nullable|string|max:1000', + ]; + } +} diff --git a/app/Models/Tenants/ApprovalDelegation.php b/app/Models/Tenants/ApprovalDelegation.php index b628784..a374b6e 100644 --- a/app/Models/Tenants/ApprovalDelegation.php +++ b/app/Models/Tenants/ApprovalDelegation.php @@ -3,14 +3,16 @@ 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; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; class ApprovalDelegation extends Model { - use BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $table = 'approval_delegations'; @@ -59,11 +61,6 @@ public function delegate(): BelongsTo // 스코프 // ========================================================================= - public function scopeActive($query) - { - return $query->where('is_active', true); - } - public function scopeForDelegator($query, int $userId) { return $query->where('delegator_id', $userId); diff --git a/app/Models/Tenants/ApprovalForm.php b/app/Models/Tenants/ApprovalForm.php index 7909298..9323b44 100644 --- a/app/Models/Tenants/ApprovalForm.php +++ b/app/Models/Tenants/ApprovalForm.php @@ -5,6 +5,7 @@ use App\Models\Members\User; use App\Traits\Auditable; use App\Traits\BelongsToTenant; +use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -26,7 +27,7 @@ */ class ApprovalForm extends Model { - use Auditable, BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, ModelTrait, SoftDeletes; protected $table = 'approval_forms'; @@ -100,14 +101,6 @@ public function updater(): BelongsTo // 스코프 // ========================================================================= - /** - * 활성 양식만 - */ - public function scopeActive($query) - { - return $query->where('is_active', true); - } - /** * 특정 카테고리 */ diff --git a/app/Models/Tenants/ApprovalStep.php b/app/Models/Tenants/ApprovalStep.php index 017c7f3..eff5904 100644 --- a/app/Models/Tenants/ApprovalStep.php +++ b/app/Models/Tenants/ApprovalStep.php @@ -4,13 +4,16 @@ use App\Models\Members\User; use App\Traits\Auditable; +use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\SoftDeletes; /** * 결재 단계 모델 * * @property int $id + * @property int $tenant_id * @property int $approval_id * @property int $step_order * @property string $step_type @@ -23,7 +26,7 @@ */ class ApprovalStep extends Model { - use Auditable; + use Auditable, BelongsToTenant, SoftDeletes; protected $table = 'approval_steps'; @@ -36,6 +39,7 @@ class ApprovalStep extends Model ]; protected $fillable = [ + 'tenant_id', 'approval_id', 'step_order', 'step_type', diff --git a/app/Services/ApprovalService.php b/app/Services/ApprovalService.php index 42d7391..419debb 100644 --- a/app/Services/ApprovalService.php +++ b/app/Services/ApprovalService.php @@ -1230,6 +1230,7 @@ public function copyForRedraft(int $id): Approval // 결재선 복사 (모두 pending 상태로, 스냅샷 유지) foreach ($original->steps as $step) { ApprovalStep::create([ + 'tenant_id' => $tenantId, 'approval_id' => $newApproval->id, 'step_order' => $step->step_order, 'step_type' => $step->step_type, @@ -1597,6 +1598,7 @@ private function createApprovalSteps(Approval $approval, array $steps): void } ApprovalStep::create([ + 'tenant_id' => $approval->tenant_id, 'approval_id' => $approval->id, 'step_order' => $stepOrder, 'step_type' => $stepType, diff --git a/database/migrations/2026_03_11_100001_add_tenant_id_and_soft_deletes_to_approval_steps_table.php b/database/migrations/2026_03_11_100001_add_tenant_id_and_soft_deletes_to_approval_steps_table.php new file mode 100644 index 0000000..a63d9f6 --- /dev/null +++ b/database/migrations/2026_03_11_100001_add_tenant_id_and_soft_deletes_to_approval_steps_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('tenant_id')->nullable()->after('id')->comment('테넌트 ID'); + $table->softDeletes()->comment('삭제일시'); + + $table->index('tenant_id', 'idx_approval_steps_tenant'); + }); + + // 기존 데이터: 부모 approvals 테이블에서 tenant_id 복사 + DB::statement(' + UPDATE approval_steps AS s + INNER JOIN approvals AS a ON s.approval_id = a.id + SET s.tenant_id = a.tenant_id + '); + } + + public function down(): void + { + Schema::table('approval_steps', function (Blueprint $table) { + $table->dropIndex('idx_approval_steps_tenant'); + $table->dropColumn(['tenant_id', 'deleted_at']); + }); + } +}; From 0ab3d5ab8859f12b9b41f28852ced2ec84d09b3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 11 Mar 2026 17:49:16 +0900 Subject: [PATCH 140/166] =?UTF-8?q?R2=20=ED=8C=8C=EC=9D=BC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Console/Commands/CleanupTempFiles.php | 4 +- app/Console/Commands/CleanupTrash.php | 4 +- app/Console/Commands/UploadLocalFilesToR2.php | 264 ++++ .../Api/V1/FileStorageController.php | 15 +- .../Api/V1/ItemsFileController.php | 2 +- app/Models/Commons/File.php | 42 +- app/Services/FileStorageService.php | 12 +- composer.json | 1 + composer.lock | 346 ++++- config/filesystems.php | 12 + routes/api/v1/files.php | 1 + storage/api-docs/api-docs-v1.json | 1287 ++++++----------- 12 files changed, 1145 insertions(+), 845 deletions(-) create mode 100644 app/Console/Commands/UploadLocalFilesToR2.php diff --git a/app/Console/Commands/CleanupTempFiles.php b/app/Console/Commands/CleanupTempFiles.php index ae40410..ad73bed 100644 --- a/app/Console/Commands/CleanupTempFiles.php +++ b/app/Console/Commands/CleanupTempFiles.php @@ -40,8 +40,8 @@ public function handle(): int foreach ($files as $file) { try { // Delete physical file - if (Storage::disk('tenant')->exists($file->file_path)) { - Storage::disk('tenant')->delete($file->file_path); + if (Storage::disk('r2')->exists($file->file_path)) { + Storage::disk('r2')->delete($file->file_path); } // Force delete from DB diff --git a/app/Console/Commands/CleanupTrash.php b/app/Console/Commands/CleanupTrash.php index 8ed9e58..66c40d7 100644 --- a/app/Console/Commands/CleanupTrash.php +++ b/app/Console/Commands/CleanupTrash.php @@ -60,8 +60,8 @@ private function permanentDelete(File $file): void { DB::transaction(function () use ($file) { // Delete physical file - if (Storage::disk('tenant')->exists($file->file_path)) { - Storage::disk('tenant')->delete($file->file_path); + if (Storage::disk('r2')->exists($file->file_path)) { + Storage::disk('r2')->delete($file->file_path); } // Update tenant storage usage diff --git a/app/Console/Commands/UploadLocalFilesToR2.php b/app/Console/Commands/UploadLocalFilesToR2.php new file mode 100644 index 0000000..d99c8a2 --- /dev/null +++ b/app/Console/Commands/UploadLocalFilesToR2.php @@ -0,0 +1,264 @@ +option('count'); + $source = $this->option('source'); + $dryRun = $this->option('dry-run'); + + $this->info("=== R2 Upload Tool ==="); + $this->info("Source: {$source} | Count: {$count}"); + + return $source === 'db' + ? $this->uploadFromDb($count, $dryRun) + : $this->uploadFromDisk($count, $dryRun); + } + + /** + * Upload files based on DB records (latest by ID desc) + */ + private function uploadFromDb(int $count, bool $dryRun): int + { + $files = File::orderByDesc('id')->limit($count)->get(); + + if ($files->isEmpty()) { + $this->warn('No files in DB.'); + return 0; + } + + $this->newLine(); + $headers = ['ID', 'Display Name', 'R2 Path', 'R2 Exists', 'Local Exists', 'Size']; + $rows = []; + + foreach ($files as $f) { + $localPath = storage_path("app/tenants/{$f->file_path}"); + $r2Exists = Storage::disk('r2')->exists($f->file_path); + $localExists = file_exists($localPath); + + $rows[] = [ + $f->id, + mb_strimwidth($f->display_name ?? '', 0, 25, '...'), + $f->file_path, + $r2Exists ? '✓ YES' : '✗ NO', + $localExists ? '✓ YES' : '✗ NO', + $f->file_size ? $this->formatSize($f->file_size) : '-', + ]; + } + + $this->table($headers, $rows); + + // Filter: local exists but R2 doesn't + $toUpload = $files->filter(function ($f) { + $localPath = storage_path("app/tenants/{$f->file_path}"); + return file_exists($localPath) && !Storage::disk('r2')->exists($f->file_path); + }); + + $alreadyInR2 = $files->filter(function ($f) { + return Storage::disk('r2')->exists($f->file_path); + }); + + if ($alreadyInR2->isNotEmpty()) { + $this->info("Already in R2: {$alreadyInR2->count()} files (skipped)"); + } + + if ($toUpload->isEmpty()) { + $missingBoth = $files->filter(function ($f) { + $localPath = storage_path("app/tenants/{$f->file_path}"); + return !file_exists($localPath) && !Storage::disk('r2')->exists($f->file_path); + }); + + if ($missingBoth->isNotEmpty()) { + $this->warn("Missing both locally and in R2: {$missingBoth->count()} files"); + $this->warn("These files may exist on the dev server only."); + } + + $this->info('Nothing to upload.'); + return 0; + } + + if ($dryRun) { + $this->warn("[DRY RUN] Would upload {$toUpload->count()} files."); + return 0; + } + + // Test R2 connection + $this->info('Testing R2 connection...'); + try { + Storage::disk('r2')->directories('/'); + $this->info('✓ R2 connection OK'); + } catch (\Exception $e) { + $this->error('✗ R2 connection failed: ' . $e->getMessage()); + return 1; + } + + // Upload + $bar = $this->output->createProgressBar($toUpload->count()); + $bar->start(); + $success = 0; + $failed = 0; + + foreach ($toUpload as $f) { + $localPath = storage_path("app/tenants/{$f->file_path}"); + try { + $content = FileFacade::get($localPath); + $mimeType = $f->mime_type ?: FileFacade::mimeType($localPath); + + Storage::disk('r2')->put($f->file_path, $content, [ + 'ContentType' => $mimeType, + ]); + $success++; + } catch (\Exception $e) { + $failed++; + $this->newLine(); + $this->error(" ✗ ID {$f->id}: {$e->getMessage()}"); + } + $bar->advance(); + } + + $bar->finish(); + $this->newLine(2); + $this->info("=== Upload Complete ==="); + $this->info("✓ Success: {$success}"); + if ($failed > 0) { + $this->error("✗ Failed: {$failed}"); + return 1; + } + return 0; + } + + /** + * Upload files based on local disk (newest files by mtime) + */ + private function uploadFromDisk(int $count, bool $dryRun): int + { + $storagePath = storage_path('app/tenants'); + + if (!is_dir($storagePath)) { + $this->error("Path not found: {$storagePath}"); + return 1; + } + + $allFiles = $this->collectFiles($storagePath); + if (empty($allFiles)) { + $this->warn('No files found.'); + return 0; + } + + usort($allFiles, fn($a, $b) => filemtime($b) - filemtime($a)); + $filesToUpload = array_slice($allFiles, 0, $count); + + $this->info("Found " . count($allFiles) . " total files, uploading {$count} most recent:"); + $this->newLine(); + + $headers = ['#', 'File', 'Size', 'Modified', 'R2 Path']; + $rows = []; + + foreach ($filesToUpload as $i => $filePath) { + $r2Path = $this->toR2Path($filePath); + $rows[] = [$i + 1, basename($filePath), $this->formatSize(filesize($filePath)), date('Y-m-d H:i:s', filemtime($filePath)), $r2Path]; + } + + $this->table($headers, $rows); + + if ($dryRun) { + $this->warn('[DRY RUN] No files uploaded.'); + return 0; + } + + $this->info('Testing R2 connection...'); + try { + Storage::disk('r2')->directories('/'); + $this->info('✓ R2 connection OK'); + } catch (\Exception $e) { + $this->error('✗ R2 connection failed: ' . $e->getMessage()); + return 1; + } + + $bar = $this->output->createProgressBar(count($filesToUpload)); + $bar->start(); + $success = 0; + $failed = 0; + $fix = $this->option('fix'); + + foreach ($filesToUpload as $filePath) { + $r2Path = $this->toR2Path($filePath); + try { + if ($fix) { + $wrongPath = $this->toRelativePath($filePath); + if ($wrongPath !== $r2Path && Storage::disk('r2')->exists($wrongPath)) { + Storage::disk('r2')->delete($wrongPath); + } + } + + $content = FileFacade::get($filePath); + $mimeType = FileFacade::mimeType($filePath); + + Storage::disk('r2')->put($r2Path, $content, ['ContentType' => $mimeType]); + $success++; + } catch (\Exception $e) { + $failed++; + $this->newLine(); + $this->error(" ✗ Failed: {$r2Path} - {$e->getMessage()}"); + } + $bar->advance(); + } + + $bar->finish(); + $this->newLine(2); + $this->info("=== Upload Complete ==="); + $this->info("✓ Success: {$success}"); + if ($failed > 0) { + $this->error("✗ Failed: {$failed}"); + return 1; + } + return 0; + } + + private function toR2Path(string $filePath): string + { + $relative = $this->toRelativePath($filePath); + return str_starts_with($relative, 'tenants/') ? substr($relative, strlen('tenants/')) : $relative; + } + + private function toRelativePath(string $filePath): string + { + return str_replace(str_replace('\\', '/', storage_path('app/')), '', str_replace('\\', '/', $filePath)); + } + + private function collectFiles(string $dir): array + { + $files = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS) + ); + foreach ($iterator as $file) { + if ($file->isFile() && $file->getFilename() !== '.gitignore') { + $files[] = $file->getPathname(); + } + } + return $files; + } + + private function formatSize(int $bytes): string + { + if ($bytes >= 1048576) return round($bytes / 1048576, 1) . ' MB'; + return round($bytes / 1024, 1) . ' KB'; + } +} diff --git a/app/Http/Controllers/Api/V1/FileStorageController.php b/app/Http/Controllers/Api/V1/FileStorageController.php index 438dabd..1e26fac 100644 --- a/app/Http/Controllers/Api/V1/FileStorageController.php +++ b/app/Http/Controllers/Api/V1/FileStorageController.php @@ -83,14 +83,25 @@ public function trash() } /** - * Download file + * Download file (attachment) */ public function download(int $id) { $service = new FileStorageService; $file = $service->getFile($id); - return $file->download(); + return $file->download(inline: false); + } + + /** + * View file inline (이미지/PDF 브라우저에서 바로 표시) + */ + public function view(int $id) + { + $service = new FileStorageService; + $file = $service->getFile($id); + + return $file->download(inline: true); } /** diff --git a/app/Http/Controllers/Api/V1/ItemsFileController.php b/app/Http/Controllers/Api/V1/ItemsFileController.php index 0527814..7fd8f72 100644 --- a/app/Http/Controllers/Api/V1/ItemsFileController.php +++ b/app/Http/Controllers/Api/V1/ItemsFileController.php @@ -109,7 +109,7 @@ public function upload(int $id, ItemFileUploadRequest $request) $filePath = $directory.'/'.$storedName; // 파일 저장 (tenant 디스크) - Storage::disk('tenant')->putFileAs($directory, $uploadedFile, $storedName); + Storage::disk('r2')->putFileAs($directory, $uploadedFile, $storedName); // file_type 자동 분류 (MIME 타입 기반) $mimeType = $uploadedFile->getMimeType(); diff --git a/app/Models/Commons/File.php b/app/Models/Commons/File.php index 697b493..9169bc6 100644 --- a/app/Models/Commons/File.php +++ b/app/Models/Commons/File.php @@ -103,7 +103,7 @@ public function fileable() */ public function getStoragePath(): string { - return Storage::disk('tenant')->path($this->file_path); + return $this->file_path; } /** @@ -111,22 +111,38 @@ public function getStoragePath(): string */ public function exists(): bool { - return Storage::disk('tenant')->exists($this->file_path); + return Storage::disk('r2')->exists($this->file_path); } /** - * Get download response + * Get download response (streaming from R2) + * + * @param bool $inline true = 브라우저에서 바로 표시 (이미지/PDF), false = 다운로드 */ - public function download() + public function download(bool $inline = false) { if (! $this->exists()) { abort(404, 'File not found in storage'); } - return response()->download( - $this->getStoragePath(), - $this->display_name ?? $this->original_name - ); + $fileName = $this->display_name ?? $this->original_name; + $mimeType = $this->mime_type ?? 'application/octet-stream'; + $disposition = $inline ? 'inline' : 'attachment'; + + // Stream from R2 (메모리에 전체 로드하지 않음) + $stream = Storage::disk('r2')->readStream($this->file_path); + + return response()->stream(function () use ($stream) { + fpassthru($stream); + if (is_resource($stream)) { + fclose($stream); + } + }, 200, [ + 'Content-Type' => $mimeType, + 'Content-Disposition' => $disposition . '; filename="' . $fileName . '"', + 'Content-Length' => $this->file_size, + 'Cache-Control' => 'private, max-age=3600', + ]); } /** @@ -149,9 +165,9 @@ public function moveToFolder(Folder $folder): bool $this->stored_name ?? $this->file_name ); - // Move physical file - if (Storage::disk('tenant')->exists($this->file_path)) { - Storage::disk('tenant')->move($this->file_path, $newPath); + // Move physical file in R2 + if (Storage::disk('r2')->exists($this->file_path)) { + Storage::disk('r2')->move($this->file_path, $newPath); } // Update DB @@ -182,9 +198,9 @@ public function softDeleteFile(int $userId): void */ public function permanentDelete(): void { - // Delete physical file + // Delete physical file from R2 if ($this->exists()) { - Storage::disk('tenant')->delete($this->file_path); + Storage::disk('r2')->delete($this->file_path); } // Decrement tenant storage diff --git a/app/Services/FileStorageService.php b/app/Services/FileStorageService.php index 73df813..3601e5a 100644 --- a/app/Services/FileStorageService.php +++ b/app/Services/FileStorageService.php @@ -54,18 +54,16 @@ public function upload(UploadedFile $file, ?string $description = null): File $storedName ); - // Store file - Storage::disk('tenant')->putFileAs( - dirname($tempPath), - $file, - basename($tempPath) - ); + // Store file to R2 (Cloudflare R2, S3 compatible) + Storage::disk('r2')->put($tempPath, file_get_contents($file->getRealPath()), [ + 'ContentType' => $file->getMimeType(), + ]); // Determine file type $mimeType = $file->getMimeType(); $fileType = $this->determineFileType($mimeType); - // Create DB record + // Create DB record (file_path = R2 key path) $fileRecord = File::create([ 'tenant_id' => $tenantId, 'display_name' => $file->getClientOriginalName(), diff --git a/composer.json b/composer.json index e45077d..01007a9 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "laravel/mcp": "^0.1.1", "laravel/sanctum": "^4.0", "laravel/tinker": "^2.10.1", + "league/flysystem-aws-s3-v3": "^3.32", "livewire/livewire": "^3.0", "maatwebsite/excel": "^3.1", "spatie/laravel-permission": "^6.21" diff --git a/composer.lock b/composer.lock index 19a010a..019af7b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,159 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "340d586f1c4e3f7bd0728229300967da", + "content-hash": "f39a7807cc0a6aa991e31a6acffc9508", "packages": [ + { + "name": "aws/aws-crt-php", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/awslabs/aws-crt-php.git", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7" + }, + "time": "2024-10-18T22:15:13+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.372.3", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "d207d2ca972c9b10674e535dacd4a5d956a80bad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d207d2ca972c9b10674e535dacd4a5d956a80bad", + "reference": "d207d2ca972c9b10674e535dacd4a5d956a80bad", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.2.3", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/promises": "^2.0", + "guzzlehttp/psr7": "^2.4.5", + "mtdowling/jmespath.php": "^2.8.0", + "php": ">=8.1", + "psr/http-message": "^1.0 || ^2.0", + "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^2.7.8", + "dms/phpunit-arraysubset-asserts": "^0.4.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-sockets": "*", + "phpunit/phpunit": "^9.6", + "psr/cache": "^2.0 || ^3.0", + "psr/simple-cache": "^2.0 || ^3.0", + "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-pcntl": "To use client-side monitoring", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + }, + "exclude-from-classmap": [ + "src/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "https://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "https://aws.amazon.com/sdk-for-php", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "support": { + "forum": "https://github.com/aws/aws-sdk-php/discussions", + "issues": "https://github.com/aws/aws-sdk-php/issues", + "source": "https://github.com/aws/aws-sdk-php/tree/3.372.3" + }, + "time": "2026-03-10T18:07:21+00:00" + }, { "name": "brick/math", "version": "0.13.1", @@ -2512,6 +2663,61 @@ }, "time": "2025-06-25T13:29:59+00:00" }, + { + "name": "league/flysystem-aws-s3-v3", + "version": "3.32.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", + "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/a1979df7c9784d334ea6df356aed3d18ac6673d0", + "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0", + "shasum": "" + }, + "require": { + "aws/aws-sdk-php": "^3.295.10", + "league/flysystem": "^3.10.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\AwsS3V3\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "AWS S3 filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "aws", + "file", + "files", + "filesystem", + "s3", + "storage" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.32.0" + }, + "time": "2026-02-25T16:46:44+00:00" + }, { "name": "league/flysystem-local", "version": "3.30.0", @@ -3236,6 +3442,72 @@ ], "time": "2025-03-24T10:02:05+00:00" }, + { + "name": "mtdowling/jmespath.php", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.33" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.8-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.8.0" + }, + "time": "2024-09-04T18:46:31+00:00" + }, { "name": "nesbot/carbon", "version": "3.10.1", @@ -5230,6 +5502,76 @@ ], "time": "2024-09-25T14:21:43+00:00" }, + { + "name": "symfony/filesystem", + "version": "v8.0.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7bf9162d7a0dff98d079b72948508fa48018a770", + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.0.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-25T16:59:43+00:00" + }, { "name": "symfony/finder", "version": "v7.3.0", @@ -10773,5 +11115,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/filesystems.php b/config/filesystems.php index 199fd26..9fd9a30 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -76,6 +76,18 @@ 'report' => false, ], + 'r2' => [ + 'driver' => 's3', + 'key' => env('R2_ACCESS_KEY_ID'), + 'secret' => env('R2_SECRET_ACCESS_KEY'), + 'region' => env('R2_REGION', 'auto'), + 'bucket' => env('R2_BUCKET'), + 'endpoint' => env('R2_ENDPOINT'), + 'use_path_style_endpoint' => true, + 'throw' => false, + 'report' => false, + ], + ], /* diff --git a/routes/api/v1/files.php b/routes/api/v1/files.php index 1f0ca39..4f48bc9 100644 --- a/routes/api/v1/files.php +++ b/routes/api/v1/files.php @@ -21,6 +21,7 @@ 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::get('/{id}/view', [FileStorageController::class, 'view'])->name('v1.files.view'); // 파일 인라인 보기 (이미지/PDF) 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'); // 파일 영구 삭제 diff --git a/storage/api-docs/api-docs-v1.json b/storage/api-docs/api-docs-v1.json index a12284d..92d16ed 100755 --- a/storage/api-docs/api-docs-v1.json +++ b/storage/api-docs/api-docs-v1.json @@ -11,7 +11,7 @@ "servers": [ { "url": "https://api.sam.kr/", - "description": "SAM관리시스템 API 서버" + "description": "SAM API 서버" } ], "paths": { @@ -42517,6 +42517,231 @@ ] } }, + "/api/v1/production-orders": { + "get": { + "tags": [ + "ProductionOrders" + ], + "summary": "생산지시 목록 조회", + "operationId": "8564d6d5af05027a941d784917a6e7b6", + "parameters": [ + { + "name": "search", + "in": "query", + "description": "검색어 (수주번호, 거래처명, 현장명)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "production_status", + "in": "query", + "description": "생산 상태 필터", + "required": false, + "schema": { + "type": "string", + "enum": [ + "waiting", + "in_production", + "completed" + ] + } + }, + { + "name": "sort_by", + "in": "query", + "description": "정렬 기준", + "required": false, + "schema": { + "type": "string", + "enum": [ + "created_at", + "delivery_date", + "order_no" + ] + } + }, + { + "name": "sort_dir", + "in": "query", + "description": "정렬 방향", + "required": false, + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "성공", + "content": { + "application/json": { + "schema": { + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string" + }, + "data": { + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProductionOrderListItem" + } + }, + "current_page": { + "type": "integer" + }, + "last_page": { + "type": "integer" + }, + "per_page": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, + "/api/v1/production-orders/stats": { + "get": { + "tags": [ + "ProductionOrders" + ], + "summary": "생산지시 상태별 통계", + "operationId": "7ab51cb2b0394b4cf098d1d684ed7cc3", + "responses": { + "200": { + "description": "성공", + "content": { + "application/json": { + "schema": { + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/ProductionOrderStats" + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, + "/api/v1/production-orders/{orderId}": { + "get": { + "tags": [ + "ProductionOrders" + ], + "summary": "생산지시 상세 조회", + "operationId": "33057f259db03f1b7d5f06afc15d019e", + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "수주 ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "성공", + "content": { + "application/json": { + "schema": { + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/ProductionOrderDetail" + } + }, + "type": "object" + } + } + } + }, + "404": { + "description": "생산지시를 찾을 수 없음" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, "/api/v1/purchases": { "get": { "tags": [ @@ -46553,7 +46778,7 @@ "Role" ], "summary": "역할 목록 조회", - "description": "테넌트 범위 내 역할 목록을 페이징으로 반환합니다. (q로 이름/설명 검색, is_hidden으로 필터)", + "description": "테넌트 범위 내 역할 목록을 페이징으로 반환합니다. (q로 이름/설명 검색)", "operationId": "2fe3440eb56182754caf817600b13375", "parameters": [ { @@ -46582,16 +46807,6 @@ "type": "string", "example": "read" } - }, - { - "name": "is_hidden", - "in": "query", - "description": "숨김 상태 필터", - "required": false, - "schema": { - "type": "boolean", - "example": false - } } ], "responses": { @@ -47048,148 +47263,6 @@ ] } }, - "/api/v1/roles/stats": { - "get": { - "tags": [ - "Role" - ], - "summary": "역할 통계 조회", - "description": "테넌트 범위 내 역할 통계(전체/공개/숨김/사용자 보유)를 반환합니다.", - "operationId": "419d5a08537494bf256b10661e221944", - "responses": { - "200": { - "description": "통계 조회 성공", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ApiResponse" - }, - { - "properties": { - "data": { - "$ref": "#/components/schemas/RoleStats" - } - }, - "type": "object" - } - ] - } - } - } - }, - "401": { - "description": "인증 실패", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "서버 에러", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "security": [ - { - "ApiKeyAuth": [] - }, - { - "BearerAuth": [] - } - ] - } - }, - "/api/v1/roles/active": { - "get": { - "tags": [ - "Role" - ], - "summary": "활성 역할 목록 (드롭다운용)", - "description": "숨겨지지 않은 활성 역할 목록을 이름순으로 반환합니다. (id, name, description만 포함)", - "operationId": "8663eac59de3903354a3d5dd4502a5bf", - "responses": { - "200": { - "description": "목록 조회 성공", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ApiResponse" - }, - { - "properties": { - "data": { - "type": "array", - "items": { - "properties": { - "id": { - "type": "integer", - "example": 1 - }, - "name": { - "type": "string", - "example": "admin" - }, - "description": { - "type": "string", - "example": "관리자", - "nullable": true - } - }, - "type": "object" - } - } - }, - "type": "object" - } - ] - } - } - } - }, - "401": { - "description": "인증 실패", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "서버 에러", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "security": [ - { - "ApiKeyAuth": [] - }, - { - "BearerAuth": [] - } - ] - } - }, "/api/v1/roles/{id}/permissions": { "get": { "tags": [ @@ -47582,467 +47655,6 @@ ] } }, - "/api/v1/role-permissions/menus": { - "get": { - "tags": [ - "RolePermission" - ], - "summary": "권한 매트릭스용 메뉴 트리 조회", - "description": "활성 메뉴를 플랫 배열(depth 포함)로 반환하고, 사용 가능한 권한 유형 목록을 함께 반환합니다.", - "operationId": "1eea6074af7fe23108049fc436ae4b8f", - "responses": { - "200": { - "description": "조회 성공", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ApiResponse" - }, - { - "properties": { - "data": { - "$ref": "#/components/schemas/PermissionMenuTree" - } - }, - "type": "object" - } - ] - } - } - } - }, - "401": { - "description": "인증 실패", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "서버 에러", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "security": [ - { - "ApiKeyAuth": [] - }, - { - "BearerAuth": [] - } - ] - } - }, - "/api/v1/roles/{id}/permissions/matrix": { - "get": { - "tags": [ - "RolePermission" - ], - "summary": "역할의 권한 매트릭스 조회", - "description": "해당 역할에 부여된 메뉴별 권한 매트릭스를 반환합니다.", - "operationId": "18e9a32f62613b9cd3d41e79f500d122", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "example": 1 - } - ], - "responses": { - "200": { - "description": "조회 성공", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ApiResponse" - }, - { - "properties": { - "data": { - "$ref": "#/components/schemas/RolePermissionMatrix" - } - }, - "type": "object" - } - ] - } - } - } - }, - "404": { - "description": "역할 없음", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "인증 실패", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "서버 에러", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "security": [ - { - "ApiKeyAuth": [] - }, - { - "BearerAuth": [] - } - ] - } - }, - "/api/v1/roles/{id}/permissions/toggle": { - "post": { - "tags": [ - "RolePermission" - ], - "summary": "특정 메뉴의 특정 권한 토글", - "description": "지정한 메뉴+권한 유형의 부여 상태를 반전합니다. 하위 메뉴에 재귀적으로 전파합니다.", - "operationId": "cd6302edade7b8f79c39a85f8c369638", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "example": 1 - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RolePermissionToggleRequest" - } - } - } - }, - "responses": { - "200": { - "description": "토글 성공", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ApiResponse" - }, - { - "properties": { - "data": { - "$ref": "#/components/schemas/RolePermissionToggleResponse" - } - }, - "type": "object" - } - ] - } - } - } - }, - "404": { - "description": "역할 없음", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "422": { - "description": "검증 실패", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "인증 실패", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "서버 에러", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "security": [ - { - "ApiKeyAuth": [] - }, - { - "BearerAuth": [] - } - ] - } - }, - "/api/v1/roles/{id}/permissions/allow-all": { - "post": { - "tags": [ - "RolePermission" - ], - "summary": "모든 권한 허용", - "description": "해당 역할에 모든 활성 메뉴의 모든 권한 유형을 일괄 부여합니다.", - "operationId": "ab526a580d6926ef0971582b9aeb1d58", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "example": 1 - } - ], - "responses": { - "200": { - "description": "성공", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponse" - } - } - } - }, - "404": { - "description": "역할 없음", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "인증 실패", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "서버 에러", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "security": [ - { - "ApiKeyAuth": [] - }, - { - "BearerAuth": [] - } - ] - } - }, - "/api/v1/roles/{id}/permissions/deny-all": { - "post": { - "tags": [ - "RolePermission" - ], - "summary": "모든 권한 거부", - "description": "해당 역할의 모든 메뉴 권한을 일괄 제거합니다.", - "operationId": "f0120556f6104f5778f13349a5eec469", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "example": 1 - } - ], - "responses": { - "200": { - "description": "성공", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponse" - } - } - } - }, - "404": { - "description": "역할 없음", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "인증 실패", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "서버 에러", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "security": [ - { - "ApiKeyAuth": [] - }, - { - "BearerAuth": [] - } - ] - } - }, - "/api/v1/roles/{id}/permissions/reset": { - "post": { - "tags": [ - "RolePermission" - ], - "summary": "기본 권한으로 초기화 (view만 허용)", - "description": "해당 역할의 모든 권한을 제거한 후, 모든 활성 메뉴에 view 권한만 부여합니다.", - "operationId": "7d0ce4d8a4116908a9639c70dc7dba61", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "example": 1 - } - ], - "responses": { - "200": { - "description": "성공", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponse" - } - } - } - }, - "404": { - "description": "역할 없음", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "인증 실패", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "서버 에러", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "security": [ - { - "ApiKeyAuth": [] - }, - { - "BearerAuth": [] - } - ] - } - }, "/api/v1/sales": { "get": { "tags": [ @@ -83244,6 +82856,246 @@ "type": "object" } }, + "ProductionOrderListItem": { + "description": "생산지시 목록 아이템", + "properties": { + "id": { + "description": "수주 ID", + "type": "integer", + "example": 1 + }, + "order_no": { + "description": "수주번호 (= 생산지시번호)", + "type": "string", + "example": "ORD-20260301-0001" + }, + "site_name": { + "description": "현장명", + "type": "string", + "example": "서울현장", + "nullable": true + }, + "client_name": { + "description": "거래처명", + "type": "string", + "example": "(주)고객사", + "nullable": true + }, + "quantity": { + "description": "부품수량 합계", + "type": "number", + "example": 232 + }, + "node_count": { + "description": "개소수 (order_nodes 수)", + "type": "integer", + "example": 4 + }, + "delivery_date": { + "description": "납기일", + "type": "string", + "format": "date", + "example": "2026-03-15", + "nullable": true + }, + "production_ordered_at": { + "description": "생산지시일 (첫 WorkOrder 생성일, Y-m-d)", + "type": "string", + "format": "date", + "example": "2026-02-21", + "nullable": true + }, + "production_status": { + "description": "생산 상태", + "type": "string", + "enum": [ + "waiting", + "in_production", + "completed" + ], + "example": "waiting" + }, + "work_orders_count": { + "description": "작업지시 수 (공정별 1건)", + "type": "integer", + "example": 2 + }, + "work_order_progress": { + "properties": { + "total": { + "type": "integer", + "example": 3 + }, + "completed": { + "type": "integer", + "example": 1 + }, + "in_progress": { + "type": "integer", + "example": 1 + } + }, + "type": "object" + }, + "client": { + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "name": { + "type": "string", + "example": "(주)고객사" + } + }, + "type": "object", + "nullable": true + } + }, + "type": "object" + }, + "ProductionOrderStats": { + "description": "생산지시 통계", + "properties": { + "total": { + "description": "전체", + "type": "integer", + "example": 25 + }, + "waiting": { + "description": "생산대기", + "type": "integer", + "example": 10 + }, + "in_production": { + "description": "생산중", + "type": "integer", + "example": 8 + }, + "completed": { + "description": "생산완료", + "type": "integer", + "example": 7 + } + }, + "type": "object" + }, + "ProductionOrderDetail": { + "description": "생산지시 상세", + "properties": { + "order": { + "$ref": "#/components/schemas/ProductionOrderListItem" + }, + "production_ordered_at": { + "type": "string", + "format": "date", + "example": "2026-02-21", + "nullable": true + }, + "production_status": { + "type": "string", + "enum": [ + "waiting", + "in_production", + "completed" + ] + }, + "node_count": { + "description": "개소수", + "type": "integer", + "example": 4 + }, + "work_order_progress": { + "properties": { + "total": { + "type": "integer" + }, + "completed": { + "type": "integer" + }, + "in_progress": { + "type": "integer" + } + }, + "type": "object" + }, + "work_orders": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "work_order_no": { + "type": "string" + }, + "process_name": { + "type": "string" + }, + "quantity": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "assignees": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "type": "object" + } + }, + "bom_process_groups": { + "type": "array", + "items": { + "properties": { + "process_name": { + "type": "string" + }, + "size_spec": { + "type": "string", + "nullable": true + }, + "items": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer", + "nullable": true + }, + "item_code": { + "type": "string" + }, + "item_name": { + "type": "string" + }, + "spec": { + "type": "string" + }, + "lot_no": { + "type": "string" + }, + "required_qty": { + "type": "number" + }, + "qty": { + "type": "number" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + }, + "type": "object" + }, "Purchase": { "description": "매입 정보", "properties": { @@ -86181,18 +86033,6 @@ "type": "string", "example": "api" }, - "is_hidden": { - "type": "boolean", - "example": false - }, - "permissions_count": { - "type": "integer", - "example": 12 - }, - "users_count": { - "type": "integer", - "example": 3 - }, "created_at": { "type": "string", "format": "date-time", @@ -86253,11 +86093,6 @@ "type": "string", "example": "메뉴 관리 역할", "nullable": true - }, - "is_hidden": { - "description": "숨김 여부", - "type": "boolean", - "example": false } }, "type": "object" @@ -86272,32 +86107,6 @@ "type": "string", "example": "설명 변경", "nullable": true - }, - "is_hidden": { - "type": "boolean", - "example": false - } - }, - "type": "object" - }, - "RoleStats": { - "description": "역할 통계", - "properties": { - "total": { - "type": "integer", - "example": 5 - }, - "visible": { - "type": "integer", - "example": 3 - }, - "hidden": { - "type": "integer", - "example": 2 - }, - "with_users": { - "type": "integer", - "example": 4 } }, "type": "object" @@ -86510,164 +86319,6 @@ } ] }, - "PermissionMenuTree": { - "description": "권한 매트릭스용 메뉴 트리", - "properties": { - "menus": { - "type": "array", - "items": { - "properties": { - "id": { - "type": "integer", - "example": 1 - }, - "parent_id": { - "type": "integer", - "example": null, - "nullable": true - }, - "name": { - "type": "string", - "example": "대시보드" - }, - "url": { - "type": "string", - "example": "/dashboard", - "nullable": true - }, - "icon": { - "type": "string", - "example": "dashboard", - "nullable": true - }, - "sort_order": { - "type": "integer", - "example": 1 - }, - "is_active": { - "type": "boolean", - "example": true - }, - "depth": { - "type": "integer", - "example": 0 - }, - "has_children": { - "type": "boolean", - "example": true - } - }, - "type": "object" - } - }, - "permission_types": { - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "view", - "create", - "update", - "delete", - "approve", - "export", - "manage" - ] - } - }, - "type": "object" - }, - "RolePermissionMatrix": { - "description": "역할의 권한 매트릭스", - "properties": { - "role": { - "properties": { - "id": { - "type": "integer", - "example": 1 - }, - "name": { - "type": "string", - "example": "admin" - }, - "description": { - "type": "string", - "example": "관리자", - "nullable": true - } - }, - "type": "object" - }, - "permission_types": { - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "view", - "create", - "update", - "delete", - "approve", - "export", - "manage" - ] - }, - "permissions": { - "description": "메뉴ID를 키로 한 권한 맵", - "type": "object", - "example": { - "101": { - "view": true, - "create": true - }, - "102": { - "view": true - } - }, - "additionalProperties": true - } - }, - "type": "object" - }, - "RolePermissionToggleRequest": { - "required": [ - "menu_id", - "permission_type" - ], - "properties": { - "menu_id": { - "description": "메뉴 ID", - "type": "integer", - "example": 101 - }, - "permission_type": { - "description": "권한 유형 (view, create, update, delete, approve, export, manage)", - "type": "string", - "example": "view" - } - }, - "type": "object" - }, - "RolePermissionToggleResponse": { - "properties": { - "menu_id": { - "type": "integer", - "example": 101 - }, - "permission_type": { - "type": "string", - "example": "view" - }, - "granted": { - "description": "토글 후 권한 부여 상태", - "type": "boolean", - "example": true - } - }, - "type": "object" - }, "Sale": { "description": "매출 정보", "properties": { @@ -94322,6 +93973,10 @@ "name": "Products-BOM", "description": "제품 BOM (제품/자재 혼합) 관리" }, + { + "name": "ProductionOrders", + "description": "생산지시 관리" + }, { "name": "Purchases", "description": "매입 관리" From 18a6f3e7aa37078b62fd4910c907e8232df10329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 11 Mar 2026 18:16:59 +0900 Subject: [PATCH 141/166] =?UTF-8?q?refactor:=20[barobill]=20=EB=B0=94?= =?UTF-8?q?=EB=A1=9C=EB=B9=8C=20=EC=97=B0=EB=8F=99=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=84=EB=A9=B4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config/services.php에 barobill 설정 등록 (운영/테스트 모드 분기 정상화) - BarobillSetting 모델에 BelongsToTenant 적용 및 use_* 필드 casts 추가 - BarobillService API URL을 baroservice.com(SOAP)으로 수정 - BarobillService callApi 메서드 경로 하드코딩 제거 (서비스별 분기) - BarobillService 예외 이중 래핑 문제 수정 - BarobillController URL 메서드 중복 코드 제거 - 누락 모델 16개 생성 (MNG 패턴 준수, BelongsToTenant 적용) - 바로빌 전 테이블 options JSON 컬럼 추가 마이그레이션 --- .../Controllers/Api/V1/BarobillController.php | 36 ++-- .../Barobill/BarobillBankSyncStatus.php | 34 ++++ .../Barobill/BarobillBankTransaction.php | 97 +++++++++++ .../BarobillBankTransactionOverride.php | 49 ++++++ .../Barobill/BarobillBankTransactionSplit.php | 69 ++++++++ app/Models/Barobill/BarobillBillingRecord.php | 82 +++++++++ .../Barobill/BarobillCardTransaction.php | 108 ++++++++++++ .../BarobillCardTransactionAmountLog.php | 41 +++++ .../Barobill/BarobillCardTransactionHide.php | 61 +++++++ .../Barobill/BarobillCardTransactionSplit.php | 74 ++++++++ app/Models/Barobill/BarobillConfig.php | 61 +++++++ app/Models/Barobill/BarobillMember.php | 82 +++++++++ .../Barobill/BarobillMonthlySummary.php | 53 ++++++ app/Models/Barobill/BarobillPricingPolicy.php | 82 +++++++++ app/Models/Barobill/BarobillSubscription.php | 76 +++++++++ app/Models/Barobill/HometaxInvoice.php | 158 ++++++++++++++++++ app/Models/Barobill/HometaxInvoiceJournal.php | 78 +++++++++ app/Models/Tenants/BarobillSetting.php | 33 ++++ app/Services/BarobillService.php | 123 +++++++------- config/services.php | 13 ++ ..._100000_add_options_to_barobill_tables.php | 59 +++++++ 21 files changed, 1390 insertions(+), 79 deletions(-) create mode 100644 app/Models/Barobill/BarobillBankSyncStatus.php create mode 100644 app/Models/Barobill/BarobillBankTransaction.php create mode 100644 app/Models/Barobill/BarobillBankTransactionOverride.php create mode 100644 app/Models/Barobill/BarobillBankTransactionSplit.php create mode 100644 app/Models/Barobill/BarobillBillingRecord.php create mode 100644 app/Models/Barobill/BarobillCardTransaction.php create mode 100644 app/Models/Barobill/BarobillCardTransactionAmountLog.php create mode 100644 app/Models/Barobill/BarobillCardTransactionHide.php create mode 100644 app/Models/Barobill/BarobillCardTransactionSplit.php create mode 100644 app/Models/Barobill/BarobillConfig.php create mode 100644 app/Models/Barobill/BarobillMember.php create mode 100644 app/Models/Barobill/BarobillMonthlySummary.php create mode 100644 app/Models/Barobill/BarobillPricingPolicy.php create mode 100644 app/Models/Barobill/BarobillSubscription.php create mode 100644 app/Models/Barobill/HometaxInvoice.php create mode 100644 app/Models/Barobill/HometaxInvoiceJournal.php create mode 100644 database/migrations/2026_03_11_100000_add_options_to_barobill_tables.php diff --git a/app/Http/Controllers/Api/V1/BarobillController.php b/app/Http/Controllers/Api/V1/BarobillController.php index 9e90b91..a93307c 100644 --- a/app/Http/Controllers/Api/V1/BarobillController.php +++ b/app/Http/Controllers/Api/V1/BarobillController.php @@ -28,7 +28,7 @@ public function status() 'barobill_id' => $setting->barobill_id, 'biz_no' => $setting->corp_num, 'status' => $setting->isVerified() ? 'active' : 'inactive', - 'server_mode' => config('services.barobill.test_mode', true) ? 'test' : 'production', + 'server_mode' => $this->barobillService->isTestMode() ? 'test' : 'production', ] : null, ]; }, __('message.fetched')); @@ -86,17 +86,21 @@ public function signup(Request $request) }, __('message.saved')); } + /** + * 바로빌 서비스 URL 조회 (공통) + */ + private function getServiceUrl(string $path): array + { + return ['url' => $this->barobillService->getBaseUrl().$path]; + } + /** * 은행 빠른조회 서비스 URL 조회 */ - public function bankServiceUrl(Request $request) + public function bankServiceUrl() { return ApiResponse::handle(function () { - $baseUrl = config('services.barobill.test_mode', true) - ? 'https://testws.barobill.co.kr' - : 'https://ws.barobill.co.kr'; - - return ['url' => $baseUrl.'/Bank/BankAccountService']; + return $this->getServiceUrl('/BANKACCOUNT.asmx'); }, __('message.fetched')); } @@ -106,11 +110,7 @@ public function bankServiceUrl(Request $request) public function accountLinkUrl() { return ApiResponse::handle(function () { - $baseUrl = config('services.barobill.test_mode', true) - ? 'https://testws.barobill.co.kr' - : 'https://ws.barobill.co.kr'; - - return ['url' => $baseUrl.'/Bank/AccountLink']; + return $this->getServiceUrl('/BANKACCOUNT.asmx'); }, __('message.fetched')); } @@ -120,11 +120,7 @@ public function accountLinkUrl() public function cardLinkUrl() { return ApiResponse::handle(function () { - $baseUrl = config('services.barobill.test_mode', true) - ? 'https://testws.barobill.co.kr' - : 'https://ws.barobill.co.kr'; - - return ['url' => $baseUrl.'/Card/CardLink']; + return $this->getServiceUrl('/CARD.asmx'); }, __('message.fetched')); } @@ -134,11 +130,7 @@ public function cardLinkUrl() public function certificateUrl() { return ApiResponse::handle(function () { - $baseUrl = config('services.barobill.test_mode', true) - ? 'https://testws.barobill.co.kr' - : 'https://ws.barobill.co.kr'; - - return ['url' => $baseUrl.'/Certificate/Register']; + return $this->getServiceUrl('/CORPSTATE.asmx'); }, __('message.fetched')); } } diff --git a/app/Models/Barobill/BarobillBankSyncStatus.php b/app/Models/Barobill/BarobillBankSyncStatus.php new file mode 100644 index 0000000..e4431bb --- /dev/null +++ b/app/Models/Barobill/BarobillBankSyncStatus.php @@ -0,0 +1,34 @@ + 'datetime', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } +} diff --git a/app/Models/Barobill/BarobillBankTransaction.php b/app/Models/Barobill/BarobillBankTransaction.php new file mode 100644 index 0000000..3bc7c0e --- /dev/null +++ b/app/Models/Barobill/BarobillBankTransaction.php @@ -0,0 +1,97 @@ + 'decimal:2', + 'withdraw' => 'decimal:2', + 'balance' => 'decimal:2', + 'is_manual' => 'boolean', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } + + // ========================================================================= + // 접근자 + // ========================================================================= + + /** + * 거래 고유 키 (계좌번호|거래일시|입금|출금|잔액) + */ + public function getUniqueKeyAttribute(): string + { + return static::generateUniqueKey([ + 'bank_account_num' => $this->bank_account_num, + 'trans_dt' => $this->trans_dt, + 'deposit' => $this->deposit, + 'withdraw' => $this->withdraw, + 'balance' => $this->balance, + ]); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function generateUniqueKey(array $data): string + { + return implode('|', [ + $data['bank_account_num'] ?? '', + $data['trans_dt'] ?? '', + $data['deposit'] ?? '0', + $data['withdraw'] ?? '0', + $data['balance'] ?? '0', + ]); + } + + public static function getByDateRange(int $tenantId, string $startDate, string $endDate, ?string $accountNum = null) + { + $query = static::where('tenant_id', $tenantId) + ->whereBetween('trans_date', [$startDate, $endDate]); + + if ($accountNum) { + $query->where('bank_account_num', $accountNum); + } + + return $query->orderBy('trans_date')->orderBy('trans_dt')->get(); + } +} diff --git a/app/Models/Barobill/BarobillBankTransactionOverride.php b/app/Models/Barobill/BarobillBankTransactionOverride.php new file mode 100644 index 0000000..1d8d78a --- /dev/null +++ b/app/Models/Barobill/BarobillBankTransactionOverride.php @@ -0,0 +1,49 @@ +where('unique_key', $uniqueKey); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function getByUniqueKeys(int $tenantId, array $uniqueKeys) + { + return static::where('tenant_id', $tenantId) + ->whereIn('unique_key', $uniqueKeys) + ->get() + ->keyBy('unique_key'); + } + + public static function saveOverride(int $tenantId, string $uniqueKey, ?string $modifiedSummary, ?string $modifiedCast): self + { + return static::updateOrCreate( + ['tenant_id' => $tenantId, 'unique_key' => $uniqueKey], + ['modified_summary' => $modifiedSummary, 'modified_cast' => $modifiedCast] + ); + } +} diff --git a/app/Models/Barobill/BarobillBankTransactionSplit.php b/app/Models/Barobill/BarobillBankTransactionSplit.php new file mode 100644 index 0000000..35e5184 --- /dev/null +++ b/app/Models/Barobill/BarobillBankTransactionSplit.php @@ -0,0 +1,69 @@ + 'decimal:2', + 'original_deposit' => 'decimal:2', + 'original_withdraw' => 'decimal:2', + 'sort_order' => 'integer', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function getByDateRange(int $tenantId, string $startDate, string $endDate) + { + return static::where('tenant_id', $tenantId) + ->whereBetween('trans_date', [$startDate, $endDate]) + ->orderBy('original_unique_key') + ->orderBy('sort_order') + ->get() + ->groupBy('original_unique_key'); + } + + public static function getByUniqueKey(int $tenantId, string $uniqueKey) + { + return static::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->orderBy('sort_order') + ->get(); + } +} diff --git a/app/Models/Barobill/BarobillBillingRecord.php b/app/Models/Barobill/BarobillBillingRecord.php new file mode 100644 index 0000000..5a87228 --- /dev/null +++ b/app/Models/Barobill/BarobillBillingRecord.php @@ -0,0 +1,82 @@ + 'integer', + 'unit_price' => 'integer', + 'total_amount' => 'integer', + 'billed_at' => 'date', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function member(): BelongsTo + { + return $this->belongsTo(BarobillMember::class, 'member_id'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeOfMonth($query, string $billingMonth) + { + return $query->where('billing_month', $billingMonth); + } + + public function scopeSubscription($query) + { + return $query->where('billing_type', 'subscription'); + } + + public function scopeUsage($query) + { + return $query->where('billing_type', 'usage'); + } + + public function scopeOfService($query, string $serviceType) + { + return $query->where('service_type', $serviceType); + } + + // ========================================================================= + // 접근자 + // ========================================================================= + + public function getServiceTypeLabelAttribute(): string + { + return match ($this->service_type) { + 'tax_invoice' => '전자세금계산서', + 'bank_account' => '계좌조회', + 'card' => '카드조회', + 'hometax' => '홈택스', + default => $this->service_type, + }; + } +} diff --git a/app/Models/Barobill/BarobillCardTransaction.php b/app/Models/Barobill/BarobillCardTransaction.php new file mode 100644 index 0000000..49611f9 --- /dev/null +++ b/app/Models/Barobill/BarobillCardTransaction.php @@ -0,0 +1,108 @@ + 'decimal:2', + 'tax' => 'decimal:2', + 'service_charge' => 'decimal:2', + 'modified_supply_amount' => 'decimal:2', + 'modified_tax' => 'decimal:2', + 'is_manual' => 'boolean', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } + + // ========================================================================= + // 접근자 + // ========================================================================= + + /** + * 거래 고유 키 (cardNum|useDt|approvalNum|approvalAmount) + */ + public function getUniqueKeyAttribute(): string + { + return static::generateUniqueKey([ + 'card_num' => $this->card_num, + 'use_dt' => $this->use_dt, + 'approval_num' => $this->approval_num, + 'approval_amount' => $this->approval_amount, + ]); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function generateUniqueKey(array $data): string + { + return implode('|', [ + $data['card_num'] ?? '', + $data['use_dt'] ?? '', + $data['approval_num'] ?? '', + $data['approval_amount'] ?? '0', + ]); + } + + public static function getByDateRange(int $tenantId, string $startDate, string $endDate, ?string $cardNum = null) + { + $query = static::where('tenant_id', $tenantId) + ->whereBetween('use_date', [$startDate, $endDate]); + + if ($cardNum) { + $query->where('card_num', $cardNum); + } + + return $query->orderBy('use_date')->orderBy('use_dt')->get(); + } +} diff --git a/app/Models/Barobill/BarobillCardTransactionAmountLog.php b/app/Models/Barobill/BarobillCardTransactionAmountLog.php new file mode 100644 index 0000000..0109c11 --- /dev/null +++ b/app/Models/Barobill/BarobillCardTransactionAmountLog.php @@ -0,0 +1,41 @@ + 'decimal:2', + 'before_tax' => 'decimal:2', + 'after_supply_amount' => 'decimal:2', + 'after_tax' => 'decimal:2', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function cardTransaction(): BelongsTo + { + return $this->belongsTo(BarobillCardTransaction::class, 'card_transaction_id'); + } +} diff --git a/app/Models/Barobill/BarobillCardTransactionHide.php b/app/Models/Barobill/BarobillCardTransactionHide.php new file mode 100644 index 0000000..3112665 --- /dev/null +++ b/app/Models/Barobill/BarobillCardTransactionHide.php @@ -0,0 +1,61 @@ + 'decimal:2', + ]; + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function getHiddenKeys(int $tenantId, string $startDate, string $endDate) + { + return static::where('tenant_id', $tenantId) + ->whereBetween('use_date', [$startDate, $endDate]) + ->pluck('original_unique_key') + ->toArray(); + } + + public static function hideTransaction(int $tenantId, string $uniqueKey, array $originalData, int $userId): self + { + return static::create([ + 'tenant_id' => $tenantId, + 'original_unique_key' => $uniqueKey, + 'card_num' => $originalData['card_num'] ?? '', + 'use_date' => $originalData['use_date'] ?? '', + 'approval_num' => $originalData['approval_num'] ?? '', + 'original_amount' => $originalData['approval_amount'] ?? 0, + 'merchant_name' => $originalData['merchant_name'] ?? '', + 'hidden_by' => $userId, + ]); + } + + public static function restoreTransaction(int $tenantId, string $uniqueKey): bool + { + return static::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->delete() > 0; + } +} diff --git a/app/Models/Barobill/BarobillCardTransactionSplit.php b/app/Models/Barobill/BarobillCardTransactionSplit.php new file mode 100644 index 0000000..8daf73c --- /dev/null +++ b/app/Models/Barobill/BarobillCardTransactionSplit.php @@ -0,0 +1,74 @@ + 'decimal:2', + 'split_supply_amount' => 'decimal:2', + 'split_tax' => 'decimal:2', + 'original_amount' => 'decimal:2', + 'sort_order' => 'integer', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function getByDateRange(int $tenantId, string $startDate, string $endDate) + { + return static::where('tenant_id', $tenantId) + ->whereBetween('use_date', [$startDate, $endDate]) + ->orderBy('original_unique_key') + ->orderBy('sort_order') + ->get() + ->groupBy('original_unique_key'); + } + + public static function getByUniqueKey(int $tenantId, string $uniqueKey) + { + return static::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->orderBy('sort_order') + ->get(); + } +} diff --git a/app/Models/Barobill/BarobillConfig.php b/app/Models/Barobill/BarobillConfig.php new file mode 100644 index 0000000..5962e02 --- /dev/null +++ b/app/Models/Barobill/BarobillConfig.php @@ -0,0 +1,61 @@ + 'boolean', + ]; + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function getActiveTest(): ?self + { + return static::where('environment', 'test')->where('is_active', true)->first(); + } + + public static function getActiveProduction(): ?self + { + return static::where('environment', 'production')->where('is_active', true)->first(); + } + + public static function getActive(bool $isTestMode): ?self + { + return $isTestMode ? static::getActiveTest() : static::getActiveProduction(); + } + + public function getEnvironmentLabelAttribute(): string + { + return $this->environment === 'test' ? '테스트' : '운영'; + } + + public function getMaskedCertKeyAttribute(): string + { + $key = $this->cert_key; + if (strlen($key) <= 8) { + return str_repeat('*', strlen($key)); + } + + return substr($key, 0, 4).'****'.substr($key, -4); + } +} diff --git a/app/Models/Barobill/BarobillMember.php b/app/Models/Barobill/BarobillMember.php new file mode 100644 index 0000000..341bae4 --- /dev/null +++ b/app/Models/Barobill/BarobillMember.php @@ -0,0 +1,82 @@ + 'encrypted', + 'last_sales_fetch_at' => 'datetime', + 'last_purchases_fetch_at' => 'datetime', + ]; + + protected $hidden = [ + 'barobill_pwd', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } + + // ========================================================================= + // 접근자 + // ========================================================================= + + public function getFormattedBizNoAttribute(): string + { + $num = $this->biz_no; + if (strlen($num) === 10) { + return substr($num, 0, 3).'-'.substr($num, 3, 2).'-'.substr($num, 5); + } + + return $num ?? ''; + } + + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + 'active' => '활성', + 'inactive' => '비활성', + 'pending' => '대기', + default => $this->status, + }; + } + + public function isTestMode(): bool + { + return $this->server_mode === 'test'; + } +} diff --git a/app/Models/Barobill/BarobillMonthlySummary.php b/app/Models/Barobill/BarobillMonthlySummary.php new file mode 100644 index 0000000..0ce3325 --- /dev/null +++ b/app/Models/Barobill/BarobillMonthlySummary.php @@ -0,0 +1,53 @@ + 'integer', + 'card_fee' => 'integer', + 'hometax_fee' => 'integer', + 'subscription_total' => 'integer', + 'tax_invoice_count' => 'integer', + 'tax_invoice_amount' => 'integer', + 'usage_total' => 'integer', + 'grand_total' => 'integer', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function member(): BelongsTo + { + return $this->belongsTo(BarobillMember::class, 'member_id'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeOfMonth($query, string $billingMonth) + { + return $query->where('billing_month', $billingMonth); + } +} diff --git a/app/Models/Barobill/BarobillPricingPolicy.php b/app/Models/Barobill/BarobillPricingPolicy.php new file mode 100644 index 0000000..f816a35 --- /dev/null +++ b/app/Models/Barobill/BarobillPricingPolicy.php @@ -0,0 +1,82 @@ + 'integer', + 'additional_unit' => 'integer', + 'additional_price' => 'integer', + 'is_active' => 'boolean', + 'sort_order' => 'integer', + ]; + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function getByServiceType(string $serviceType): ?self + { + return static::active()->where('service_type', $serviceType)->first(); + } + + public static function getAllActive() + { + return static::active()->orderBy('sort_order')->get(); + } + + public function getServiceTypeLabelAttribute(): string + { + return match ($this->service_type) { + self::TYPE_CARD => '카드조회', + self::TYPE_TAX_INVOICE => '전자세금계산서', + self::TYPE_BANK_ACCOUNT => '계좌조회', + default => $this->service_type, + }; + } + + public function calculateBilling(int $usageCount): int + { + if ($usageCount <= $this->free_quota) { + return 0; + } + + $excess = $usageCount - $this->free_quota; + $units = (int) ceil($excess / max($this->additional_unit, 1)); + + return $units * $this->additional_price; + } +} diff --git a/app/Models/Barobill/BarobillSubscription.php b/app/Models/Barobill/BarobillSubscription.php new file mode 100644 index 0000000..bd6acbe --- /dev/null +++ b/app/Models/Barobill/BarobillSubscription.php @@ -0,0 +1,76 @@ + 10000, + 'card' => 10000, + 'hometax' => 0, + ]; + + protected $fillable = [ + 'member_id', + 'service_type', + 'monthly_fee', + 'started_at', + 'ended_at', + 'is_active', + 'memo', + ]; + + protected $casts = [ + 'monthly_fee' => 'integer', + 'started_at' => 'date', + 'ended_at' => 'date', + 'is_active' => 'boolean', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function member(): BelongsTo + { + return $this->belongsTo(BarobillMember::class, 'member_id'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeOfService($query, string $serviceType) + { + return $query->where('service_type', $serviceType); + } + + // ========================================================================= + // 접근자 + // ========================================================================= + + public function getServiceTypeLabelAttribute(): string + { + return match ($this->service_type) { + 'bank_account' => '계좌조회', + 'card' => '카드조회', + 'hometax' => '홈택스', + default => $this->service_type, + }; + } +} diff --git a/app/Models/Barobill/HometaxInvoice.php b/app/Models/Barobill/HometaxInvoice.php new file mode 100644 index 0000000..f1ffab0 --- /dev/null +++ b/app/Models/Barobill/HometaxInvoice.php @@ -0,0 +1,158 @@ + 'integer', + 'tax_amount' => 'integer', + 'total_amount' => 'integer', + 'item_count' => 'integer', + 'item_unit_price' => 'integer', + 'item_supply_amount' => 'integer', + 'item_tax_amount' => 'integer', + 'is_modified' => 'boolean', + 'write_date' => 'date', + 'issue_date' => 'date', + 'send_date' => 'date', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } + + public function journals(): HasMany + { + return $this->hasMany(HometaxInvoiceJournal::class, 'hometax_invoice_id'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeSales($query) + { + return $query->where('invoice_type', 'sales'); + } + + public function scopePurchase($query) + { + return $query->where('invoice_type', 'purchase'); + } + + public function scopePeriod($query, string $startDate, string $endDate) + { + return $query->whereBetween('write_date', [$startDate, $endDate]); + } + + // ========================================================================= + // 접근자 + // ========================================================================= + + public function getTaxTypeNameAttribute(): string + { + return match ($this->tax_type) { + self::TAX_TYPE_TAXABLE => '과세', + self::TAX_TYPE_ZERO => '영세', + self::TAX_TYPE_EXEMPT => '면세', + default => $this->tax_type ?? '', + }; + } + + public function getPurposeTypeNameAttribute(): string + { + return match ($this->purpose_type) { + self::PURPOSE_TYPE_RECEIPT => '영수', + self::PURPOSE_TYPE_CLAIM => '청구', + default => $this->purpose_type ?? '', + }; + } + + public function getIssueTypeNameAttribute(): string + { + return match ($this->issue_type) { + self::ISSUE_TYPE_NORMAL => '정발행', + self::ISSUE_TYPE_REVERSE => '역발행', + default => $this->issue_type ?? '', + }; + } +} diff --git a/app/Models/Barobill/HometaxInvoiceJournal.php b/app/Models/Barobill/HometaxInvoiceJournal.php new file mode 100644 index 0000000..c328c89 --- /dev/null +++ b/app/Models/Barobill/HometaxInvoiceJournal.php @@ -0,0 +1,78 @@ + 'integer', + 'credit_amount' => 'integer', + 'sort_order' => 'integer', + 'supply_amount' => 'integer', + 'tax_amount' => 'integer', + 'total_amount' => 'integer', + 'write_date' => 'date', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } + + public function invoice(): BelongsTo + { + return $this->belongsTo(HometaxInvoice::class, 'hometax_invoice_id'); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function getByInvoiceId(int $tenantId, int $invoiceId) + { + return static::where('tenant_id', $tenantId) + ->where('hometax_invoice_id', $invoiceId) + ->orderBy('sort_order') + ->get(); + } + + public static function getJournaledInvoiceIds(int $tenantId, array $invoiceIds): array + { + return static::where('tenant_id', $tenantId) + ->whereIn('hometax_invoice_id', $invoiceIds) + ->distinct() + ->pluck('hometax_invoice_id') + ->toArray(); + } +} diff --git a/app/Models/Tenants/BarobillSetting.php b/app/Models/Tenants/BarobillSetting.php index 45ad09e..32e3a6e 100644 --- a/app/Models/Tenants/BarobillSetting.php +++ b/app/Models/Tenants/BarobillSetting.php @@ -2,12 +2,15 @@ namespace App\Models\Tenants; +use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Facades\Crypt; class BarobillSetting extends Model { + use BelongsToTenant; + protected $fillable = [ 'tenant_id', 'corp_num', @@ -23,6 +26,10 @@ class BarobillSetting extends Model 'contact_tel', 'is_active', 'auto_issue', + 'use_tax_invoice', + 'use_bank_account', + 'use_card_usage', + 'use_hometax', 'verified_at', 'created_by', 'updated_by', @@ -31,6 +38,10 @@ class BarobillSetting extends Model protected $casts = [ 'is_active' => 'boolean', 'auto_issue' => 'boolean', + 'use_tax_invoice' => 'boolean', + 'use_bank_account' => 'boolean', + 'use_card_usage' => 'boolean', + 'use_hometax' => 'boolean', 'verified_at' => 'datetime', ]; @@ -129,4 +140,26 @@ public function getFormattedCorpNumAttribute(): string return $num; } + + /** + * 활성화된 서비스 목록 + */ + public function getActiveServicesAttribute(): array + { + $services = []; + if ($this->use_tax_invoice) { + $services[] = 'tax_invoice'; + } + if ($this->use_bank_account) { + $services[] = 'bank_account'; + } + if ($this->use_card_usage) { + $services[] = 'card_usage'; + } + if ($this->use_hometax) { + $services[] = 'hometax'; + } + + return $services; + } } diff --git a/app/Services/BarobillService.php b/app/Services/BarobillService.php index 1d8451f..e63420f 100644 --- a/app/Services/BarobillService.php +++ b/app/Services/BarobillService.php @@ -12,18 +12,41 @@ * 바로빌 API 연동 서비스 * * 바로빌 개발자센터: https://dev.barobill.co.kr/ + * SOAP 서비스 URL: https://ws.baroservice.com/ (운영) / https://testws.baroservice.com/ (테스트) */ class BarobillService extends Service { /** - * 바로빌 API 기본 URL + * 바로빌 SOAP 서비스 기본 URL (운영) */ - private const API_BASE_URL = 'https://ws.barobill.co.kr'; + private const API_BASE_URL = 'https://ws.baroservice.com'; /** - * 바로빌 API 테스트 URL + * 바로빌 SOAP 서비스 테스트 URL */ - private const API_TEST_URL = 'https://testws.barobill.co.kr'; + private const API_TEST_URL = 'https://testws.baroservice.com'; + + /** + * API 서비스 경로 매핑 + */ + private const SERVICE_PATHS = [ + 'TI' => '/TI.asmx', // 세금계산서 + 'CORPSTATE' => '/CORPSTATE.asmx', // 회원/사업자 관리 + 'BANKACCOUNT' => '/BANKACCOUNT.asmx', // 계좌 조회 + 'CARD' => '/CARD.asmx', // 카드 조회 + ]; + + /** + * 메서드별 서비스 매핑 + */ + private const METHOD_SERVICE_MAP = [ + 'GetAccessToken' => 'CORPSTATE', + 'CheckCorpNum' => 'CORPSTATE', + 'RegistCorp' => 'CORPSTATE', + 'RegistAndIssueTaxInvoice' => 'TI', + 'CancelTaxInvoice' => 'TI', + 'GetTaxInvoiceState' => 'TI', + ]; /** * 테스트 모드 여부 @@ -32,7 +55,7 @@ class BarobillService extends Service public function __construct() { - $this->testMode = config('services.barobill.test_mode', true); + $this->testMode = (bool) config('services.barobill.test_mode', true); } // ========================================================================= @@ -44,11 +67,7 @@ public function __construct() */ public function getSetting(): ?BarobillSetting { - $tenantId = $this->tenantId(); - - return BarobillSetting::query() - ->where('tenant_id', $tenantId) - ->first(); + return BarobillSetting::query()->first(); } /** @@ -59,9 +78,7 @@ public function saveSetting(array $data): BarobillSetting $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - $setting = BarobillSetting::query() - ->where('tenant_id', $tenantId) - ->first(); + $setting = BarobillSetting::query()->first(); if ($setting) { $setting->fill(array_merge($data, ['updated_by' => $userId])); @@ -89,7 +106,6 @@ public function testConnection(): array } try { - // 바로빌 API 토큰 조회로 연동 테스트 $response = $this->callApi('GetAccessToken', [ 'CERTKEY' => $setting->cert_key, 'CorpNum' => $setting->corp_num, @@ -97,7 +113,6 @@ public function testConnection(): array ]); if (! empty($response['AccessToken'])) { - // 검증 성공 시 verified_at 업데이트 $setting->verified_at = now(); $setting->save(); @@ -108,7 +123,10 @@ public function testConnection(): array ]; } - throw new BadRequestHttpException($response['Message'] ?? __('error.barobill.connection_failed')); + return [ + 'success' => false, + 'message' => $response['Message'] ?? __('error.barobill.connection_failed'), + ]; } catch (\Exception $e) { Log::error('바로빌 연동 테스트 실패', [ 'tenant_id' => $this->tenantId(), @@ -125,19 +143,11 @@ public function testConnection(): array /** * 사업자등록번호 유효성 검사 (휴폐업 조회) - * - * 바로빌 API를 통해 사업자등록번호의 유효성을 검증합니다. - * 바로빌 설정이 없는 경우 기본 형식 검증만 수행합니다. - * - * @param string $businessNumber 사업자등록번호 (10자리, 하이픈 제거) - * @return array{valid: bool, status: string, status_label: string, corp_name: ?string, ceo_name: ?string, message: string} */ public function checkBusinessNumber(string $businessNumber): array { - // 하이픈 제거 및 숫자만 추출 $businessNumber = preg_replace('/[^0-9]/', '', $businessNumber); - // 기본 형식 검증 (10자리) if (strlen($businessNumber) !== 10) { return [ 'valid' => false, @@ -149,7 +159,6 @@ public function checkBusinessNumber(string $businessNumber): array ]; } - // 체크섬 검증 (사업자등록번호 자체 유효성) if (! $this->validateBusinessNumberChecksum($businessNumber)) { return [ 'valid' => false, @@ -161,16 +170,14 @@ public function checkBusinessNumber(string $businessNumber): array ]; } - // 바로빌 API 조회 시도 try { $response = $this->callApi('CheckCorpNum', [ 'CorpNum' => $businessNumber, ]); - // 바로빌 응답 해석 if (isset($response['CorpState'])) { $state = $response['CorpState']; - $isValid = in_array($state, ['01', '02']); // 01: 사업중, 02: 휴업 + $isValid = in_array($state, ['01', '02']); $statusLabel = match ($state) { '01' => '사업중', '02' => '휴업', @@ -190,7 +197,6 @@ public function checkBusinessNumber(string $businessNumber): array ]; } - // 응답 형식이 다른 경우 (결과 코드 방식) if (isset($response['Result'])) { $isValid = $response['Result'] >= 0; @@ -206,7 +212,6 @@ public function checkBusinessNumber(string $businessNumber): array ]; } - // 기본 응답 (체크섬만 통과한 경우) return [ 'valid' => true, 'status' => 'format_valid', @@ -216,7 +221,6 @@ public function checkBusinessNumber(string $businessNumber): array 'message' => __('message.company.business_number_format_valid'), ]; } catch (\Exception $e) { - // API 호출 실패 시 형식 검증 결과만 반환 Log::warning('바로빌 사업자번호 조회 실패', [ 'business_number' => $businessNumber, 'error' => $e->getMessage(), @@ -235,8 +239,6 @@ public function checkBusinessNumber(string $businessNumber): array /** * 사업자등록번호 체크섬 검증 - * - * @param string $businessNumber 10자리 사업자등록번호 */ private function validateBusinessNumberChecksum(string $businessNumber): bool { @@ -252,7 +254,6 @@ private function validateBusinessNumberChecksum(string $businessNumber): bool $sum += intval($digits[$i]) * $multipliers[$i]; } - // 8번째 자리 (인덱스 8)에 대한 추가 처리 $sum += intval(floor(intval($digits[8]) * 5 / 10)); $remainder = $sum % 10; @@ -277,14 +278,11 @@ public function issueTaxInvoice(TaxInvoice $taxInvoice): TaxInvoice } try { - // 바로빌 API 호출을 위한 데이터 구성 $apiData = $this->buildTaxInvoiceData($taxInvoice, $setting); - // 세금계산서 발행 API 호출 $response = $this->callApi('RegistAndIssueTaxInvoice', $apiData); if (! empty($response['InvoiceID'])) { - // 발행 성공 $taxInvoice->barobill_invoice_id = $response['InvoiceID']; $taxInvoice->nts_confirm_num = $response['NTSConfirmNum'] ?? null; $taxInvoice->status = TaxInvoice::STATUS_ISSUED; @@ -301,9 +299,10 @@ public function issueTaxInvoice(TaxInvoice $taxInvoice): TaxInvoice return $taxInvoice->fresh(); } - throw new \Exception($response['Message'] ?? '발행 실패'); + throw new \RuntimeException($response['Message'] ?? '발행 실패'); + } catch (BadRequestHttpException $e) { + throw $e; } catch (\Exception $e) { - // 발행 실패 $taxInvoice->status = TaxInvoice::STATUS_FAILED; $taxInvoice->error_message = $e->getMessage(); $taxInvoice->save(); @@ -334,7 +333,6 @@ public function cancelTaxInvoice(TaxInvoice $taxInvoice, string $reason): TaxInv } try { - // 세금계산서 취소 API 호출 $response = $this->callApi('CancelTaxInvoice', [ 'CERTKEY' => $setting->cert_key, 'CorpNum' => $setting->corp_num, @@ -358,7 +356,9 @@ public function cancelTaxInvoice(TaxInvoice $taxInvoice, string $reason): TaxInv return $taxInvoice->fresh(); } - throw new \Exception($response['Message'] ?? '취소 실패'); + throw new \RuntimeException($response['Message'] ?? '취소 실패'); + } catch (BadRequestHttpException $e) { + throw $e; } catch (\Exception $e) { Log::error('세금계산서 취소 실패', [ 'tenant_id' => $this->tenantId(), @@ -396,7 +396,6 @@ public function checkNtsSendStatus(TaxInvoice $taxInvoice): TaxInvoice if (! empty($response['State'])) { $taxInvoice->nts_send_status = $response['State']; - // 국세청 전송 완료 시 상태 업데이트 if ($response['State'] === '전송완료' && ! $taxInvoice->sent_at) { $taxInvoice->status = TaxInvoice::STATUS_SENT; $taxInvoice->sent_at = now(); @@ -418,6 +417,26 @@ public function checkNtsSendStatus(TaxInvoice $taxInvoice): TaxInvoice } } + // ========================================================================= + // URL 헬퍼 + // ========================================================================= + + /** + * 바로빌 API base URL 반환 + */ + public function getBaseUrl(): string + { + return $this->testMode ? self::API_TEST_URL : self::API_BASE_URL; + } + + /** + * 테스트 모드 여부 + */ + public function isTestMode(): bool + { + return $this->testMode; + } + // ========================================================================= // Private 메서드 // ========================================================================= @@ -427,8 +446,10 @@ public function checkNtsSendStatus(TaxInvoice $taxInvoice): TaxInvoice */ private function callApi(string $method, array $data): array { - $baseUrl = $this->testMode ? self::API_TEST_URL : self::API_BASE_URL; - $url = $baseUrl.'/TI/'.$method; + $baseUrl = $this->getBaseUrl(); + $servicePath = self::METHOD_SERVICE_MAP[$method] ?? 'TI'; + $path = self::SERVICE_PATHS[$servicePath] ?? '/TI.asmx'; + $url = $baseUrl.$path.'/'.$method; $response = Http::timeout(30) ->withHeaders([ @@ -437,7 +458,7 @@ private function callApi(string $method, array $data): array ->post($url, $data); if ($response->failed()) { - throw new \Exception('API 호출 실패: '.$response->status()); + throw new \RuntimeException('API 호출 실패: '.$response->status()); } return $response->json() ?? []; @@ -448,7 +469,6 @@ private function callApi(string $method, array $data): array */ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $setting): array { - // 품목 데이터 구성 $items = []; foreach ($taxInvoice->items ?? [] as $index => $item) { $items[] = [ @@ -463,7 +483,6 @@ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $se ]; } - // 품목이 없는 경우 기본 품목 추가 if (empty($items)) { $items[] = [ 'PurchaseDT' => $taxInvoice->issue_date->format('Ymd'), @@ -487,8 +506,6 @@ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $se 'TaxType' => '과세', 'PurposeType' => '영수', 'WriteDate' => $taxInvoice->issue_date->format('Ymd'), - - // 공급자 정보 'InvoicerCorpNum' => $taxInvoice->supplier_corp_num, 'InvoicerCorpName' => $taxInvoice->supplier_corp_name, 'InvoicerCEOName' => $taxInvoice->supplier_ceo_name, @@ -496,8 +513,6 @@ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $se 'InvoicerBizType' => $taxInvoice->supplier_biz_type, 'InvoicerBizClass' => $taxInvoice->supplier_biz_class, 'InvoicerContactID' => $taxInvoice->supplier_contact_id, - - // 공급받는자 정보 'InvoiceeCorpNum' => $taxInvoice->buyer_corp_num, 'InvoiceeCorpName' => $taxInvoice->buyer_corp_name, 'InvoiceeCEOName' => $taxInvoice->buyer_ceo_name, @@ -505,16 +520,10 @@ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $se 'InvoiceeBizType' => $taxInvoice->buyer_biz_type, 'InvoiceeBizClass' => $taxInvoice->buyer_biz_class, 'InvoiceeContactID' => $taxInvoice->buyer_contact_id, - - // 금액 정보 'SupplyCostTotal' => (int) $taxInvoice->supply_amount, 'TaxTotal' => (int) $taxInvoice->tax_amount, 'TotalAmount' => (int) $taxInvoice->total_amount, - - // 품목 정보 'TaxInvoiceTradeLineItems' => $items, - - // 비고 'Remark1' => $taxInvoice->description ?? '', ], ]; diff --git a/config/services.php b/config/services.php index 6a99ace..1f29e1e 100644 --- a/config/services.php +++ b/config/services.php @@ -58,4 +58,17 @@ 'exchange_secret' => env('INTERNAL_EXCHANGE_SECRET'), ], + /* + |-------------------------------------------------------------------------- + | BaroBill (바로빌 전자세금계산서/회계 연동) + |-------------------------------------------------------------------------- + | MNG와 동일한 설정 구조를 사용한다. + */ + 'barobill' => [ + 'cert_key_test' => env('BAROBILL_CERT_KEY_TEST', ''), + 'cert_key_prod' => env('BAROBILL_CERT_KEY_PROD', ''), + 'corp_num' => env('BAROBILL_CORP_NUM', ''), + 'test_mode' => env('BAROBILL_TEST_MODE', true), + ], + ]; diff --git a/database/migrations/2026_03_11_100000_add_options_to_barobill_tables.php b/database/migrations/2026_03_11_100000_add_options_to_barobill_tables.php new file mode 100644 index 0000000..f4d49c8 --- /dev/null +++ b/database/migrations/2026_03_11_100000_add_options_to_barobill_tables.php @@ -0,0 +1,59 @@ +tables as $table) { + if (Schema::hasTable($table) && ! Schema::hasColumn($table, 'options')) { + Schema::table($table, function (Blueprint $table) { + $table->json('options')->nullable()->after('id'); + }); + } + } + } + + public function down(): void + { + foreach ($this->tables as $table) { + if (Schema::hasTable($table) && Schema::hasColumn($table, 'options')) { + Schema::table($table, function (Blueprint $table) { + $table->dropColumn('options'); + }); + } + } + } +}; From 82621a60454ba9a425cb820bb8bd377672074a7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 11 Mar 2026 19:18:27 +0900 Subject: [PATCH 142/166] =?UTF-8?q?feat:=20[payroll]=20MNG=20=EA=B8=89?= =?UTF-8?q?=EC=97=AC=EA=B4=80=EB=A6=AC=20=EA=B3=84=EC=82=B0=20=EC=97=94?= =?UTF-8?q?=EC=A7=84=20=EB=B0=8F=20=EC=9D=BC=EA=B4=84=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IncomeTaxBracket 모델 추가 (2024 간이세액표 DB 조회) - PayrollService 전면 개편: 4대보험 + 소득세 자동 계산 엔진 - 10,000천원 초과 고소득 구간 공식 계산 지원 - 과세표준 = 총지급액 - 식대(비과세), 10원 단위 절삭 - 일괄 생성(bulkGenerate), 전월 복사(copyFromPrevious) 기능 - 확정취소(unconfirm), 지급취소(unpay) 상태 관리 - 계산 미리보기(calculatePreview) 엔드포인트 추가 - 공제항목 수동 오버라이드(deduction_overrides) 지원 - Payroll 모델에 long_term_care, options 필드 추가 --- .../Controllers/Api/V1/PayrollController.php | 76 +- .../V1/Payroll/BulkGeneratePayrollRequest.php | 29 + .../CopyFromPreviousPayrollRequest.php | 29 + .../V1/Payroll/StorePayrollRequest.php | 8 + .../V1/Payroll/UpdatePayrollRequest.php | 8 + app/Models/Tenants/IncomeTaxBracket.php | 64 ++ app/Models/Tenants/Payroll.php | 52 +- app/Services/PayrollService.php | 821 +++++++++++++----- lang/ko/error.php | 3 + lang/ko/message.php | 4 + routes/api/v1/finance.php | 5 + 11 files changed, 850 insertions(+), 249 deletions(-) create mode 100644 app/Http/Requests/V1/Payroll/BulkGeneratePayrollRequest.php create mode 100644 app/Http/Requests/V1/Payroll/CopyFromPreviousPayrollRequest.php create mode 100644 app/Models/Tenants/IncomeTaxBracket.php diff --git a/app/Http/Controllers/Api/V1/PayrollController.php b/app/Http/Controllers/Api/V1/PayrollController.php index 4c8af6f..e1197df 100644 --- a/app/Http/Controllers/Api/V1/PayrollController.php +++ b/app/Http/Controllers/Api/V1/PayrollController.php @@ -4,7 +4,9 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\V1\Payroll\BulkGeneratePayrollRequest; use App\Http\Requests\V1\Payroll\CalculatePayrollRequest; +use App\Http\Requests\V1\Payroll\CopyFromPreviousPayrollRequest; use App\Http\Requests\V1\Payroll\PayPayrollRequest; use App\Http\Requests\V1\Payroll\StorePayrollRequest; use App\Http\Requests\V1\Payroll\UpdatePayrollRequest; @@ -28,6 +30,7 @@ public function index(Request $request) 'month', 'user_id', 'status', + 'department_id', 'search', 'sort_by', 'sort_dir', @@ -103,6 +106,16 @@ public function confirm(int $id) return ApiResponse::success($payroll, __('message.payroll.confirmed')); } + /** + * 급여 확정 취소 + */ + public function unconfirm(int $id) + { + $payroll = $this->service->unconfirm($id); + + return ApiResponse::success($payroll, __('message.payroll.unconfirmed')); + } + /** * 급여 지급 처리 */ @@ -113,6 +126,16 @@ public function pay(int $id, PayPayrollRequest $request) return ApiResponse::success($payroll, __('message.payroll.paid')); } + /** + * 급여 지급 취소 (슈퍼관리자) + */ + public function unpay(int $id) + { + $payroll = $this->service->unpay($id); + + return ApiResponse::success($payroll, __('message.payroll.unpaid')); + } + /** * 일괄 확정 */ @@ -127,13 +150,29 @@ public function bulkConfirm(Request $request) } /** - * 급여명세서 조회 + * 재직사원 일괄 생성 */ - public function payslip(int $id) + public function bulkGenerate(BulkGeneratePayrollRequest $request) { - $payslip = $this->service->payslip($id); + $year = (int) $request->input('year'); + $month = (int) $request->input('month'); - return ApiResponse::success($payslip, __('message.fetched')); + $result = $this->service->bulkGenerate($year, $month); + + return ApiResponse::success($result, __('message.payroll.bulk_generated')); + } + + /** + * 전월 급여 복사 + */ + public function copyFromPrevious(CopyFromPreviousPayrollRequest $request) + { + $year = (int) $request->input('year'); + $month = (int) $request->input('month'); + + $result = $this->service->copyFromPreviousMonth($year, $month); + + return ApiResponse::success($result, __('message.payroll.copied')); } /** @@ -150,6 +189,35 @@ public function calculate(CalculatePayrollRequest $request) return ApiResponse::success($payrolls, __('message.payroll.calculated')); } + /** + * 급여 계산 미리보기 + */ + public function calculatePreview(Request $request) + { + $data = $request->only([ + 'user_id', + 'base_salary', + 'overtime_pay', + 'bonus', + 'allowances', + 'deductions', + ]); + + $result = $this->service->calculatePreview($data); + + return ApiResponse::success($result, __('message.calculated')); + } + + /** + * 급여명세서 조회 + */ + public function payslip(int $id) + { + $payslip = $this->service->payslip($id); + + return ApiResponse::success($payslip, __('message.fetched')); + } + /** * 급여 설정 조회 */ diff --git a/app/Http/Requests/V1/Payroll/BulkGeneratePayrollRequest.php b/app/Http/Requests/V1/Payroll/BulkGeneratePayrollRequest.php new file mode 100644 index 0000000..103ca07 --- /dev/null +++ b/app/Http/Requests/V1/Payroll/BulkGeneratePayrollRequest.php @@ -0,0 +1,29 @@ + ['required', 'integer', 'min:2000', 'max:2100'], + 'month' => ['required', 'integer', 'min:1', 'max:12'], + ]; + } + + public function attributes(): array + { + return [ + 'year' => __('validation.attributes.pay_year'), + 'month' => __('validation.attributes.pay_month'), + ]; + } +} diff --git a/app/Http/Requests/V1/Payroll/CopyFromPreviousPayrollRequest.php b/app/Http/Requests/V1/Payroll/CopyFromPreviousPayrollRequest.php new file mode 100644 index 0000000..c88a89e --- /dev/null +++ b/app/Http/Requests/V1/Payroll/CopyFromPreviousPayrollRequest.php @@ -0,0 +1,29 @@ + ['required', 'integer', 'min:2000', 'max:2100'], + 'month' => ['required', 'integer', 'min:1', 'max:12'], + ]; + } + + public function attributes(): array + { + return [ + 'year' => __('validation.attributes.pay_year'), + 'month' => __('validation.attributes.pay_month'), + ]; + } +} diff --git a/app/Http/Requests/V1/Payroll/StorePayrollRequest.php b/app/Http/Requests/V1/Payroll/StorePayrollRequest.php index b416dca..35f6e93 100644 --- a/app/Http/Requests/V1/Payroll/StorePayrollRequest.php +++ b/app/Http/Requests/V1/Payroll/StorePayrollRequest.php @@ -31,6 +31,14 @@ public function rules(): array 'deductions' => ['nullable', 'array'], 'deductions.*.name' => ['required_with:deductions', 'string', 'max:50'], 'deductions.*.amount' => ['required_with:deductions', 'numeric', 'min:0'], + 'deduction_overrides' => ['nullable', 'array'], + 'deduction_overrides.income_tax' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.resident_tax' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.health_insurance' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.long_term_care' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.pension' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.employment_insurance' => ['nullable', 'numeric', 'min:0'], + 'family_count' => ['nullable', 'integer', 'min:1', 'max:11'], 'note' => ['nullable', 'string', 'max:1000'], ]; } diff --git a/app/Http/Requests/V1/Payroll/UpdatePayrollRequest.php b/app/Http/Requests/V1/Payroll/UpdatePayrollRequest.php index 6eefc3d..413dfaf 100644 --- a/app/Http/Requests/V1/Payroll/UpdatePayrollRequest.php +++ b/app/Http/Requests/V1/Payroll/UpdatePayrollRequest.php @@ -31,6 +31,14 @@ public function rules(): array 'deductions' => ['nullable', 'array'], 'deductions.*.name' => ['required_with:deductions', 'string', 'max:50'], 'deductions.*.amount' => ['required_with:deductions', 'numeric', 'min:0'], + 'deduction_overrides' => ['nullable', 'array'], + 'deduction_overrides.income_tax' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.resident_tax' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.health_insurance' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.long_term_care' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.pension' => ['nullable', 'numeric', 'min:0'], + 'deduction_overrides.employment_insurance' => ['nullable', 'numeric', 'min:0'], + '_is_super_admin' => ['nullable', 'boolean'], 'note' => ['nullable', 'string', 'max:1000'], ]; } diff --git a/app/Models/Tenants/IncomeTaxBracket.php b/app/Models/Tenants/IncomeTaxBracket.php new file mode 100644 index 0000000..973cd17 --- /dev/null +++ b/app/Models/Tenants/IncomeTaxBracket.php @@ -0,0 +1,64 @@ + 'integer', + 'salary_from' => 'integer', + 'salary_to' => 'integer', + 'family_count' => 'integer', + 'tax_amount' => 'integer', + ]; + + public function scopeForYear(Builder $query, int $year): Builder + { + return $query->where('tax_year', $year); + } + + public function scopeForSalaryRange(Builder $query, int $salaryThousand): Builder + { + return $query->where('salary_from', '<=', $salaryThousand) + ->where(function ($q) use ($salaryThousand) { + $q->where('salary_to', '>', $salaryThousand) + ->orWhere(function ($q2) use ($salaryThousand) { + $q2->whereColumn('salary_from', 'salary_to') + ->where('salary_from', $salaryThousand); + }); + }); + } + + public function scopeForFamilyCount(Builder $query, int $count): Builder + { + return $query->where('family_count', $count); + } + + /** + * 간이세액표에서 세액 조회 + */ + public static function lookupTax(int $year, int $salaryThousand, int $familyCount): int + { + $familyCount = max(1, min(11, $familyCount)); + + $bracket = static::forYear($year) + ->forSalaryRange($salaryThousand) + ->forFamilyCount($familyCount) + ->first(); + + return $bracket ? $bracket->tax_amount : 0; + } +} diff --git a/app/Models/Tenants/Payroll.php b/app/Models/Tenants/Payroll.php index da12f95..a3e3682 100644 --- a/app/Models/Tenants/Payroll.php +++ b/app/Models/Tenants/Payroll.php @@ -49,17 +49,19 @@ class Payroll extends Model protected $casts = [ 'allowances' => 'array', 'deductions' => 'array', - 'base_salary' => 'decimal:2', - 'overtime_pay' => 'decimal:2', - 'bonus' => 'decimal:2', - 'gross_salary' => 'decimal:2', - 'income_tax' => 'decimal:2', - 'resident_tax' => 'decimal:2', - 'health_insurance' => 'decimal:2', - 'pension' => 'decimal:2', - 'employment_insurance' => 'decimal:2', - 'total_deductions' => 'decimal:2', - 'net_salary' => 'decimal:2', + 'options' => 'array', + 'base_salary' => 'decimal:0', + 'overtime_pay' => 'decimal:0', + 'bonus' => 'decimal:0', + 'gross_salary' => 'decimal:0', + 'income_tax' => 'decimal:0', + 'resident_tax' => 'decimal:0', + 'health_insurance' => 'decimal:0', + 'long_term_care' => 'decimal:0', + 'pension' => 'decimal:0', + 'employment_insurance' => 'decimal:0', + 'total_deductions' => 'decimal:0', + 'net_salary' => 'decimal:0', 'confirmed_at' => 'datetime', 'paid_at' => 'datetime', 'pay_year' => 'integer', @@ -79,9 +81,11 @@ class Payroll extends Model 'income_tax', 'resident_tax', 'health_insurance', + 'long_term_care', 'pension', 'employment_insurance', 'deductions', + 'options', 'total_deductions', 'net_salary', 'status', @@ -104,6 +108,7 @@ class Payroll extends Model 'income_tax' => 0, 'resident_tax' => 0, 'health_insurance' => 0, + 'long_term_care' => 0, 'pension' => 0, 'employment_insurance' => 0, 'total_deductions' => 0, @@ -227,13 +232,33 @@ public function scopeForUser($query, int $userId) // ========================================================================= /** - * 수정 가능 여부 (작성중 상태만) + * 수정 가능 여부 (작성중, 슈퍼관리자는 모든 상태) */ - public function isEditable(): bool + public function isEditable(bool $isSuperAdmin = false): bool { + if ($isSuperAdmin) { + return true; + } + return $this->status === self::STATUS_DRAFT; } + /** + * 확정 취소 가능 여부 + */ + public function isUnconfirmable(): bool + { + return $this->status === self::STATUS_CONFIRMED; + } + + /** + * 지급 취소 가능 여부 (슈퍼관리자 전용) + */ + public function isUnpayable(): bool + { + return $this->status === self::STATUS_PAID; + } + /** * 확정 가능 여부 */ @@ -322,6 +347,7 @@ public function calculateTotalDeductions(): float return $this->income_tax + $this->resident_tax + $this->health_insurance + + $this->long_term_care + $this->pension + $this->employment_insurance + $this->deductions_total; diff --git a/app/Services/PayrollService.php b/app/Services/PayrollService.php index ca49c9d..a4fdb0b 100644 --- a/app/Services/PayrollService.php +++ b/app/Services/PayrollService.php @@ -2,8 +2,10 @@ namespace App\Services; +use App\Models\Tenants\IncomeTaxBracket; use App\Models\Tenants\Payroll; use App\Models\Tenants\PayrollSetting; +use App\Models\Tenants\TenantUserProfile; use App\Models\Tenants\Withdrawal; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; @@ -12,13 +14,12 @@ class PayrollService extends Service { + private const TAX_TABLE_YEAR = 2024; + // ========================================================================= // 급여 목록/상세 // ========================================================================= - /** - * 급여 목록 - */ public function index(array $params): LengthAwarePaginator { $tenantId = $this->tenantId(); @@ -27,34 +28,30 @@ public function index(array $params): LengthAwarePaginator ->where('tenant_id', $tenantId) ->with(['user:id,name,email', 'creator:id,name']); - // 연도 필터 if (! empty($params['year'])) { $query->where('pay_year', $params['year']); } - - // 월 필터 if (! empty($params['month'])) { $query->where('pay_month', $params['month']); } - - // 사용자 필터 if (! empty($params['user_id'])) { $query->where('user_id', $params['user_id']); } - - // 상태 필터 if (! empty($params['status'])) { $query->where('status', $params['status']); } - - // 검색 (사용자명) if (! empty($params['search'])) { $query->whereHas('user', function ($q) use ($params) { $q->where('name', 'like', "%{$params['search']}%"); }); } + if (! empty($params['department_id'])) { + $deptId = $params['department_id']; + $query->whereHas('user.tenantProfiles', function ($q) use ($tenantId, $deptId) { + $q->where('tenant_id', $tenantId)->where('department_id', $deptId); + }); + } - // 정렬 $sortBy = $params['sort_by'] ?? 'pay_year'; $sortDir = $params['sort_dir'] ?? 'desc'; @@ -64,14 +61,9 @@ public function index(array $params): LengthAwarePaginator $query->orderBy($sortBy, $sortDir); } - $perPage = $params['per_page'] ?? 20; - - return $query->paginate($perPage); + return $query->paginate($params['per_page'] ?? 20); } - /** - * 특정 연월 급여 요약 - */ public function summary(int $year, int $month): array { $tenantId = $this->tenantId(); @@ -98,27 +90,17 @@ public function summary(int $year, int $month): array 'draft_count' => (int) $stats->draft_count, 'confirmed_count' => (int) $stats->confirmed_count, 'paid_count' => (int) $stats->paid_count, - 'total_gross' => (float) $stats->total_gross, - 'total_deductions' => (float) $stats->total_deductions, - 'total_net' => (float) $stats->total_net, + 'total_gross' => (int) $stats->total_gross, + 'total_deductions' => (int) $stats->total_deductions, + 'total_net' => (int) $stats->total_net, ]; } - /** - * 급여 상세 - */ public function show(int $id): Payroll { - $tenantId = $this->tenantId(); - return Payroll::query() - ->where('tenant_id', $tenantId) - ->with([ - 'user:id,name,email', - 'confirmer:id,name', - 'withdrawal', - 'creator:id,name', - ]) + ->where('tenant_id', $this->tenantId()) + ->with(['user:id,name,email', 'confirmer:id,name', 'withdrawal', 'creator:id,name']) ->findOrFail($id); } @@ -126,59 +108,60 @@ public function show(int $id): Payroll // 급여 생성/수정/삭제 // ========================================================================= - /** - * 급여 생성 - */ public function store(array $data): Payroll { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - // 중복 확인 - $exists = Payroll::query() - ->where('tenant_id', $tenantId) - ->where('user_id', $data['user_id']) - ->where('pay_year', $data['pay_year']) - ->where('pay_month', $data['pay_month']) - ->exists(); + return DB::transaction(function () use ($data, $tenantId, $userId) { + // 중복 확인 (soft-deleted 포함) + $existing = Payroll::withTrashed() + ->where('tenant_id', $tenantId) + ->where('user_id', $data['user_id']) + ->where('pay_year', $data['pay_year']) + ->where('pay_month', $data['pay_month']) + ->first(); - if ($exists) { - throw new BadRequestHttpException(__('error.payroll.already_exists')); - } + if ($existing && ! $existing->trashed()) { + throw new BadRequestHttpException(__('error.payroll.already_exists')); + } + if ($existing && $existing->trashed()) { + $existing->forceDelete(); + } - // 금액 계산 - $grossSalary = $this->calculateGross($data); - $totalDeductions = $this->calculateDeductions($data); - $netSalary = $grossSalary - $totalDeductions; + // 자동 계산 + $settings = PayrollSetting::getOrCreate($tenantId); + $familyCount = $data['family_count'] ?? $this->resolveFamilyCount($data['user_id']); + $calculated = $this->calculateAmounts($data, $settings, $familyCount); + $this->applyDeductionOverrides($calculated, $data['deduction_overrides'] ?? null); - return Payroll::create([ - 'tenant_id' => $tenantId, - 'user_id' => $data['user_id'], - 'pay_year' => $data['pay_year'], - 'pay_month' => $data['pay_month'], - 'base_salary' => $data['base_salary'] ?? 0, - 'overtime_pay' => $data['overtime_pay'] ?? 0, - 'bonus' => $data['bonus'] ?? 0, - 'allowances' => $data['allowances'] ?? null, - 'gross_salary' => $grossSalary, - 'income_tax' => $data['income_tax'] ?? 0, - 'resident_tax' => $data['resident_tax'] ?? 0, - 'health_insurance' => $data['health_insurance'] ?? 0, - 'pension' => $data['pension'] ?? 0, - 'employment_insurance' => $data['employment_insurance'] ?? 0, - 'deductions' => $data['deductions'] ?? null, - 'total_deductions' => $totalDeductions, - 'net_salary' => $netSalary, - 'status' => Payroll::STATUS_DRAFT, - 'note' => $data['note'] ?? null, - 'created_by' => $userId, - 'updated_by' => $userId, - ]); + return Payroll::create([ + 'tenant_id' => $tenantId, + 'user_id' => $data['user_id'], + 'pay_year' => $data['pay_year'], + 'pay_month' => $data['pay_month'], + 'base_salary' => $data['base_salary'] ?? 0, + 'overtime_pay' => $data['overtime_pay'] ?? 0, + 'bonus' => $data['bonus'] ?? 0, + 'allowances' => $data['allowances'] ?? null, + 'gross_salary' => $calculated['gross_salary'], + 'income_tax' => $calculated['income_tax'], + 'resident_tax' => $calculated['resident_tax'], + 'health_insurance' => $calculated['health_insurance'], + 'long_term_care' => $calculated['long_term_care'], + 'pension' => $calculated['pension'], + 'employment_insurance' => $calculated['employment_insurance'], + 'deductions' => $data['deductions'] ?? null, + 'total_deductions' => $calculated['total_deductions'], + 'net_salary' => $calculated['net_salary'], + 'status' => Payroll::STATUS_DRAFT, + 'note' => $data['note'] ?? null, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + }); } - /** - * 급여 수정 - */ public function update(int $id, array $data): Payroll { $tenantId = $this->tenantId(); @@ -188,11 +171,12 @@ public function update(int $id, array $data): Payroll ->where('tenant_id', $tenantId) ->findOrFail($id); - if (! $payroll->isEditable()) { + $isSuperAdmin = $data['_is_super_admin'] ?? false; + if (! $payroll->isEditable($isSuperAdmin)) { throw new BadRequestHttpException(__('error.payroll.not_editable')); } - // 연월 변경 시 중복 확인 + // 연월/사원 변경 시 중복 확인 $newYear = $data['pay_year'] ?? $payroll->pay_year; $newMonth = $data['pay_month'] ?? $payroll->pay_month; $newUserId = $data['user_id'] ?? $payroll->user_id; @@ -211,41 +195,63 @@ public function update(int $id, array $data): Payroll } } - // 금액 업데이트 - $updateData = array_merge($payroll->toArray(), $data); - $grossSalary = $this->calculateGross($updateData); - $totalDeductions = $this->calculateDeductions($updateData); - $netSalary = $grossSalary - $totalDeductions; + // 지급 항목 (신규 입력값 또는 기존값) + $baseSalary = (float) ($data['base_salary'] ?? $payroll->base_salary); + $overtimePay = (float) ($data['overtime_pay'] ?? $payroll->overtime_pay); + $bonus = (float) ($data['bonus'] ?? $payroll->bonus); + $allowances = array_key_exists('allowances', $data) ? $data['allowances'] : $payroll->allowances; + $deductions = array_key_exists('deductions', $data) ? $data['deductions'] : $payroll->deductions; - $payroll->fill([ - 'user_id' => $data['user_id'] ?? $payroll->user_id, - 'pay_year' => $data['pay_year'] ?? $payroll->pay_year, - 'pay_month' => $data['pay_month'] ?? $payroll->pay_month, - 'base_salary' => $data['base_salary'] ?? $payroll->base_salary, - 'overtime_pay' => $data['overtime_pay'] ?? $payroll->overtime_pay, - 'bonus' => $data['bonus'] ?? $payroll->bonus, - 'allowances' => $data['allowances'] ?? $payroll->allowances, + $allowancesTotal = 0; + $allowancesArr = is_string($allowances) ? json_decode($allowances, true) : $allowances; + foreach ($allowancesArr ?? [] as $allowance) { + $allowancesTotal += (float) ($allowance['amount'] ?? 0); + } + $grossSalary = (int) ($baseSalary + $overtimePay + $bonus + $allowancesTotal); + + // 공제 항목 (수동 수정값 우선, 없으면 기존값 유지) + $overrides = $data['deduction_overrides'] ?? []; + $incomeTax = isset($overrides['income_tax']) ? (int) $overrides['income_tax'] : (int) $payroll->income_tax; + $residentTax = isset($overrides['resident_tax']) ? (int) $overrides['resident_tax'] : (int) $payroll->resident_tax; + $healthInsurance = isset($overrides['health_insurance']) ? (int) $overrides['health_insurance'] : (int) $payroll->health_insurance; + $longTermCare = isset($overrides['long_term_care']) ? (int) $overrides['long_term_care'] : (int) $payroll->long_term_care; + $pension = isset($overrides['pension']) ? (int) $overrides['pension'] : (int) $payroll->pension; + $employmentInsurance = isset($overrides['employment_insurance']) ? (int) $overrides['employment_insurance'] : (int) $payroll->employment_insurance; + + $extraDeductions = 0; + $deductionsArr = is_string($deductions) ? json_decode($deductions, true) : $deductions; + foreach ($deductionsArr ?? [] as $deduction) { + $extraDeductions += (float) ($deduction['amount'] ?? 0); + } + + $totalDeductions = (int) ($incomeTax + $residentTax + $healthInsurance + $longTermCare + $pension + $employmentInsurance + $extraDeductions); + $netSalary = (int) max(0, $grossSalary - $totalDeductions); + + $payroll->update([ + 'user_id' => $newUserId, + 'pay_year' => $newYear, + 'pay_month' => $newMonth, + 'base_salary' => $baseSalary, + 'overtime_pay' => $overtimePay, + 'bonus' => $bonus, + 'allowances' => $allowances, 'gross_salary' => $grossSalary, - 'income_tax' => $data['income_tax'] ?? $payroll->income_tax, - 'resident_tax' => $data['resident_tax'] ?? $payroll->resident_tax, - 'health_insurance' => $data['health_insurance'] ?? $payroll->health_insurance, - 'pension' => $data['pension'] ?? $payroll->pension, - 'employment_insurance' => $data['employment_insurance'] ?? $payroll->employment_insurance, - 'deductions' => $data['deductions'] ?? $payroll->deductions, + 'income_tax' => $incomeTax, + 'resident_tax' => $residentTax, + 'health_insurance' => $healthInsurance, + 'long_term_care' => $longTermCare, + 'pension' => $pension, + 'employment_insurance' => $employmentInsurance, + 'deductions' => $deductions, 'total_deductions' => $totalDeductions, 'net_salary' => $netSalary, 'note' => $data['note'] ?? $payroll->note, 'updated_by' => $userId, ]); - $payroll->save(); - return $payroll->fresh(['user:id,name,email', 'creator:id,name']); } - /** - * 급여 삭제 - */ public function destroy(int $id): bool { $tenantId = $this->tenantId(); @@ -267,12 +273,9 @@ public function destroy(int $id): bool } // ========================================================================= - // 급여 확정/지급 + // 상태 관리 (확정/지급/취소) // ========================================================================= - /** - * 급여 확정 - */ public function confirm(int $id): Payroll { $tenantId = $this->tenantId(); @@ -286,18 +289,39 @@ public function confirm(int $id): Payroll throw new BadRequestHttpException(__('error.payroll.not_confirmable')); } - $payroll->status = Payroll::STATUS_CONFIRMED; - $payroll->confirmed_at = now(); - $payroll->confirmed_by = $userId; - $payroll->updated_by = $userId; - $payroll->save(); + $payroll->update([ + 'status' => Payroll::STATUS_CONFIRMED, + 'confirmed_at' => now(), + 'confirmed_by' => $userId, + 'updated_by' => $userId, + ]); return $payroll->fresh(['user:id,name,email', 'confirmer:id,name']); } - /** - * 급여 지급 처리 - */ + public function unconfirm(int $id): Payroll + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $payroll = Payroll::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $payroll->isUnconfirmable()) { + throw new BadRequestHttpException(__('error.payroll.not_unconfirmable')); + } + + $payroll->update([ + 'status' => Payroll::STATUS_DRAFT, + 'confirmed_at' => null, + 'confirmed_by' => null, + 'updated_by' => $userId, + ]); + + return $payroll->fresh(['user:id,name,email']); + } + public function pay(int $id, ?int $withdrawalId = null): Payroll { $tenantId = $this->tenantId(); @@ -312,7 +336,6 @@ public function pay(int $id, ?int $withdrawalId = null): Payroll throw new BadRequestHttpException(__('error.payroll.not_payable')); } - // 출금 내역 연결 검증 if ($withdrawalId) { $withdrawal = Withdrawal::query() ->where('tenant_id', $tenantId) @@ -324,19 +347,42 @@ public function pay(int $id, ?int $withdrawalId = null): Payroll } } - $payroll->status = Payroll::STATUS_PAID; - $payroll->paid_at = now(); - $payroll->withdrawal_id = $withdrawalId; - $payroll->updated_by = $userId; - $payroll->save(); + $payroll->update([ + 'status' => Payroll::STATUS_PAID, + 'paid_at' => now(), + 'withdrawal_id' => $withdrawalId, + 'updated_by' => $userId, + ]); return $payroll->fresh(['user:id,name,email', 'withdrawal']); }); } - /** - * 일괄 확정 - */ + public function unpay(int $id): Payroll + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $payroll = Payroll::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $payroll->isUnpayable()) { + throw new BadRequestHttpException(__('error.payroll.not_unpayable')); + } + + $payroll->update([ + 'status' => Payroll::STATUS_DRAFT, + 'confirmed_at' => null, + 'confirmed_by' => null, + 'paid_at' => null, + 'withdrawal_id' => null, + 'updated_by' => $userId, + ]); + + return $payroll->fresh(['user:id,name,email']); + } + public function bulkConfirm(int $year, int $month): int { $tenantId = $this->tenantId(); @@ -356,84 +402,174 @@ public function bulkConfirm(int $year, int $month): int } // ========================================================================= - // 급여명세서 + // 일괄 처리 (생성/복사/계산) // ========================================================================= /** - * 급여명세서 데이터 + * 재직사원 일괄 생성 */ - public function payslip(int $id): array + public function bulkGenerate(int $year, int $month): array { - $payroll = $this->show($id); + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + $settings = PayrollSetting::getOrCreate($tenantId); + $created = 0; + $skipped = 0; - // 수당 목록 - $allowances = collect($payroll->allowances ?? [])->map(function ($item) { - return [ - 'name' => $item['name'] ?? '', - 'amount' => (float) ($item['amount'] ?? 0), - ]; - })->toArray(); + $employees = TenantUserProfile::query() + ->with('user:id,name') + ->where('tenant_id', $tenantId) + ->where('employee_status', 'active') + ->get(); - // 공제 목록 - $deductions = collect($payroll->deductions ?? [])->map(function ($item) { - return [ - 'name' => $item['name'] ?? '', - 'amount' => (float) ($item['amount'] ?? 0), - ]; - })->toArray(); + DB::transaction(function () use ($employees, $tenantId, $year, $month, $settings, $userId, &$created, &$skipped) { + foreach ($employees as $employee) { + $existing = Payroll::withTrashed() + ->where('tenant_id', $tenantId) + ->where('user_id', $employee->user_id) + ->forPeriod($year, $month) + ->first(); - return [ - 'payroll' => $payroll, - 'period' => $payroll->period_label, - 'employee' => [ - 'id' => $payroll->user->id, - 'name' => $payroll->user->name, - 'email' => $payroll->user->email, - ], - 'earnings' => [ - 'base_salary' => (float) $payroll->base_salary, - 'overtime_pay' => (float) $payroll->overtime_pay, - 'bonus' => (float) $payroll->bonus, - 'allowances' => $allowances, - 'allowances_total' => (float) $payroll->allowances_total, - 'gross_total' => (float) $payroll->gross_salary, - ], - 'deductions' => [ - 'income_tax' => (float) $payroll->income_tax, - 'resident_tax' => (float) $payroll->resident_tax, - 'health_insurance' => (float) $payroll->health_insurance, - 'pension' => (float) $payroll->pension, - 'employment_insurance' => (float) $payroll->employment_insurance, - 'other_deductions' => $deductions, - 'other_total' => (float) $payroll->deductions_total, - 'total' => (float) $payroll->total_deductions, - ], - 'net_salary' => (float) $payroll->net_salary, - 'status' => $payroll->status, - 'status_label' => $payroll->status_label, - 'paid_at' => $payroll->paid_at?->toIso8601String(), - ]; + if ($existing && ! $existing->trashed()) { + $skipped++; + + continue; + } + if ($existing && $existing->trashed()) { + $existing->forceDelete(); + } + + // 연봉에서 월급 산출 + $salaryInfo = $employee->json_extra['salary_info'] ?? $employee->json_extra ?? []; + $annualSalary = $salaryInfo['annual_salary'] ?? ($employee->json_extra['salary'] ?? 0); + $baseSalary = $annualSalary > 0 ? (int) round($annualSalary / 12) : 0; + + $data = [ + 'base_salary' => $baseSalary, + 'overtime_pay' => 0, + 'bonus' => 0, + 'allowances' => null, + 'deductions' => null, + ]; + + // 피부양자 기반 가족수 산출 + $dependents = $employee->json_extra['dependents'] ?? []; + $familyCount = 1 + collect($dependents) + ->where('is_dependent', true)->count(); + + $calculated = $this->calculateAmounts($data, $settings, $familyCount); + + Payroll::create([ + 'tenant_id' => $tenantId, + 'user_id' => $employee->user_id, + 'pay_year' => $year, + 'pay_month' => $month, + 'base_salary' => $baseSalary, + 'overtime_pay' => 0, + 'bonus' => 0, + 'allowances' => null, + 'gross_salary' => $calculated['gross_salary'], + 'income_tax' => $calculated['income_tax'], + 'resident_tax' => $calculated['resident_tax'], + 'health_insurance' => $calculated['health_insurance'], + 'long_term_care' => $calculated['long_term_care'], + 'pension' => $calculated['pension'], + 'employment_insurance' => $calculated['employment_insurance'], + 'deductions' => null, + 'total_deductions' => $calculated['total_deductions'], + 'net_salary' => $calculated['net_salary'], + 'status' => Payroll::STATUS_DRAFT, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + $created++; + } + }); + + return ['created' => $created, 'skipped' => $skipped]; } - // ========================================================================= - // 급여 일괄 계산 - // ========================================================================= + /** + * 전월 급여 복사 + */ + public function copyFromPreviousMonth(int $year, int $month): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $prevYear = $month === 1 ? $year - 1 : $year; + $prevMonth = $month === 1 ? 12 : $month - 1; + + $previousPayrolls = Payroll::query() + ->where('tenant_id', $tenantId) + ->forPeriod($prevYear, $prevMonth) + ->get(); + + if ($previousPayrolls->isEmpty()) { + throw new BadRequestHttpException(__('error.payroll.no_previous_month')); + } + + $created = 0; + $skipped = 0; + + DB::transaction(function () use ($previousPayrolls, $tenantId, $year, $month, $userId, &$created, &$skipped) { + foreach ($previousPayrolls as $prev) { + $existing = Payroll::withTrashed() + ->where('tenant_id', $tenantId) + ->where('user_id', $prev->user_id) + ->forPeriod($year, $month) + ->first(); + + if ($existing && ! $existing->trashed()) { + $skipped++; + + continue; + } + if ($existing && $existing->trashed()) { + $existing->forceDelete(); + } + + Payroll::create([ + 'tenant_id' => $tenantId, + 'user_id' => $prev->user_id, + 'pay_year' => $year, + 'pay_month' => $month, + 'base_salary' => $prev->base_salary, + 'overtime_pay' => $prev->overtime_pay, + 'bonus' => $prev->bonus, + 'allowances' => $prev->allowances, + 'gross_salary' => $prev->gross_salary, + 'income_tax' => $prev->income_tax, + 'resident_tax' => $prev->resident_tax, + 'health_insurance' => $prev->health_insurance, + 'long_term_care' => $prev->long_term_care, + 'pension' => $prev->pension, + 'employment_insurance' => $prev->employment_insurance, + 'deductions' => $prev->deductions, + 'total_deductions' => $prev->total_deductions, + 'net_salary' => $prev->net_salary, + 'status' => Payroll::STATUS_DRAFT, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + $created++; + } + }); + + return ['created' => $created, 'skipped' => $skipped]; + } /** - * 급여 일괄 계산 (생성 또는 업데이트) + * 급여 일괄 계산 (기존 draft 급여 재계산) */ public function calculate(int $year, int $month, ?array $userIds = null): Collection { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - - // 급여 설정 가져오기 $settings = PayrollSetting::getOrCreate($tenantId); - // 대상 사용자 조회 - // TODO: 실제로는 직원 목록에서 급여 대상자를 조회해야 함 - // 여기서는 기존 급여 데이터만 업데이트 - return DB::transaction(function () use ($year, $month, $userIds, $tenantId, $userId, $settings) { $query = Payroll::query() ->where('tenant_id', $tenantId) @@ -448,57 +584,118 @@ public function calculate(int $year, int $month, ?array $userIds = null): Collec $payrolls = $query->get(); foreach ($payrolls as $payroll) { - // 4대보험 재계산 - $baseSalary = (float) $payroll->base_salary; + $familyCount = $this->resolveFamilyCount($payroll->user_id); - $healthInsurance = $settings->calculateHealthInsurance($baseSalary); - $longTermCare = $settings->calculateLongTermCare($healthInsurance); - $pension = $settings->calculatePension($baseSalary); - $employmentInsurance = $settings->calculateEmploymentInsurance($baseSalary); + $data = [ + 'base_salary' => (float) $payroll->base_salary, + 'overtime_pay' => (float) $payroll->overtime_pay, + 'bonus' => (float) $payroll->bonus, + 'allowances' => $payroll->allowances, + 'deductions' => $payroll->deductions, + ]; - // 건강보험에 장기요양보험 포함 - $totalHealthInsurance = $healthInsurance + $longTermCare; + $calculated = $this->calculateAmounts($data, $settings, $familyCount); - $payroll->health_insurance = $totalHealthInsurance; - $payroll->pension = $pension; - $payroll->employment_insurance = $employmentInsurance; - - // 주민세 재계산 - $payroll->resident_tax = $settings->calculateResidentTax($payroll->income_tax); - - // 총액 재계산 - $payroll->total_deductions = $payroll->calculateTotalDeductions(); - $payroll->net_salary = $payroll->calculateNetSalary(); - $payroll->updated_by = $userId; - $payroll->save(); + $payroll->update([ + 'gross_salary' => $calculated['gross_salary'], + 'income_tax' => $calculated['income_tax'], + 'resident_tax' => $calculated['resident_tax'], + 'health_insurance' => $calculated['health_insurance'], + 'long_term_care' => $calculated['long_term_care'], + 'pension' => $calculated['pension'], + 'employment_insurance' => $calculated['employment_insurance'], + 'total_deductions' => $calculated['total_deductions'], + 'net_salary' => $calculated['net_salary'], + 'updated_by' => $userId, + ]); } return $payrolls->fresh(['user:id,name,email']); }); } + /** + * 계산 미리보기 (저장하지 않음) + */ + public function calculatePreview(array $data): array + { + $tenantId = $this->tenantId(); + $settings = PayrollSetting::getOrCreate($tenantId); + $familyCount = 1; + + if (! empty($data['user_id'])) { + $familyCount = $this->resolveFamilyCount((int) $data['user_id']); + } + + $calculated = $this->calculateAmounts($data, $settings, $familyCount); + + return array_merge($calculated, ['family_count' => $familyCount]); + } + + // ========================================================================= + // 급여명세서 + // ========================================================================= + + public function payslip(int $id): array + { + $payroll = $this->show($id); + + $allowances = collect($payroll->allowances ?? [])->map(fn ($item) => [ + 'name' => $item['name'] ?? '', + 'amount' => (int) ($item['amount'] ?? 0), + ])->toArray(); + + $deductions = collect($payroll->deductions ?? [])->map(fn ($item) => [ + 'name' => $item['name'] ?? '', + 'amount' => (int) ($item['amount'] ?? 0), + ])->toArray(); + + return [ + 'payroll' => $payroll, + 'period' => $payroll->period_label, + 'employee' => [ + 'id' => $payroll->user->id, + 'name' => $payroll->user->name, + 'email' => $payroll->user->email, + ], + 'earnings' => [ + 'base_salary' => (int) $payroll->base_salary, + 'overtime_pay' => (int) $payroll->overtime_pay, + 'bonus' => (int) $payroll->bonus, + 'allowances' => $allowances, + 'allowances_total' => (int) $payroll->allowances_total, + 'gross_total' => (int) $payroll->gross_salary, + ], + 'deductions' => [ + 'income_tax' => (int) $payroll->income_tax, + 'resident_tax' => (int) $payroll->resident_tax, + 'health_insurance' => (int) $payroll->health_insurance, + 'long_term_care' => (int) $payroll->long_term_care, + 'pension' => (int) $payroll->pension, + 'employment_insurance' => (int) $payroll->employment_insurance, + 'other_deductions' => $deductions, + 'other_total' => (int) $payroll->deductions_total, + 'total' => (int) $payroll->total_deductions, + ], + 'net_salary' => (int) $payroll->net_salary, + 'status' => $payroll->status, + 'status_label' => $payroll->status_label, + 'paid_at' => $payroll->paid_at?->toIso8601String(), + ]; + } + // ========================================================================= // 급여 설정 // ========================================================================= - /** - * 급여 설정 조회 - */ public function getSettings(): PayrollSetting { - $tenantId = $this->tenantId(); - - return PayrollSetting::getOrCreate($tenantId); + return PayrollSetting::getOrCreate($this->tenantId()); } - /** - * 급여 설정 수정 - */ public function updateSettings(array $data): PayrollSetting { - $tenantId = $this->tenantId(); - - $settings = PayrollSetting::getOrCreate($tenantId); + $settings = PayrollSetting::getOrCreate($this->tenantId()); $settings->fill([ 'income_tax_rate' => $data['income_tax_rate'] ?? $settings->income_tax_rate, @@ -521,42 +718,202 @@ public function updateSettings(array $data): PayrollSetting } // ========================================================================= - // 헬퍼 메서드 + // 계산 엔진 // ========================================================================= /** - * 총지급액 계산 + * 급여 금액 자동 계산 + * + * 식대(bonus)는 비과세 항목으로, 총 지급액에는 포함되지만 + * 4대보험 및 세금 산출 기준(과세표준)에서는 제외된다. */ - private function calculateGross(array $data): float + public function calculateAmounts(array $data, ?PayrollSetting $settings = null, int $familyCount = 1): array { + $settings = $settings ?? PayrollSetting::getOrCreate($this->tenantId()); + $baseSalary = (float) ($data['base_salary'] ?? 0); $overtimePay = (float) ($data['overtime_pay'] ?? 0); $bonus = (float) ($data['bonus'] ?? 0); $allowancesTotal = 0; if (! empty($data['allowances'])) { - $allowancesTotal = collect($data['allowances'])->sum('amount'); + $allowances = is_string($data['allowances']) ? json_decode($data['allowances'], true) : $data['allowances']; + foreach ($allowances ?? [] as $allowance) { + $allowancesTotal += (float) ($allowance['amount'] ?? 0); + } } - return $baseSalary + $overtimePay + $bonus + $allowancesTotal; + // 총 지급액 (비과세 포함) + $grossSalary = $baseSalary + $overtimePay + $bonus + $allowancesTotal; + + // 과세표준 = 총 지급액 - 식대(비과세) + $taxableBase = $grossSalary - $bonus; + + // 4대보험 (과세표준 기준) + $healthInsurance = $this->calcHealthInsurance($taxableBase, $settings); + $longTermCare = $this->calcLongTermCare($taxableBase, $settings); + $pension = $this->calcPension($taxableBase, $settings); + $employmentInsurance = $this->calcEmploymentInsurance($taxableBase, $settings); + + // 근로소득세 (간이세액표, 가족수 반영) + $incomeTax = $this->calculateIncomeTax($taxableBase, $familyCount); + // 지방소득세 (근로소득세의 10%, 10원 단위 절삭) + $residentTax = (int) (floor($incomeTax * ($settings->resident_tax_rate / 100) / 10) * 10); + + // 추가 공제 합계 + $extraDeductions = 0; + if (! empty($data['deductions'])) { + $deductions = is_string($data['deductions']) ? json_decode($data['deductions'], true) : $data['deductions']; + foreach ($deductions ?? [] as $deduction) { + $extraDeductions += (float) ($deduction['amount'] ?? 0); + } + } + + $totalDeductions = $incomeTax + $residentTax + $healthInsurance + $longTermCare + $pension + $employmentInsurance + $extraDeductions; + $netSalary = $grossSalary - $totalDeductions; + + return [ + 'gross_salary' => (int) $grossSalary, + 'taxable_base' => (int) $taxableBase, + 'income_tax' => $incomeTax, + 'resident_tax' => $residentTax, + 'health_insurance' => $healthInsurance, + 'long_term_care' => $longTermCare, + 'pension' => $pension, + 'employment_insurance' => $employmentInsurance, + 'total_deductions' => (int) $totalDeductions, + 'net_salary' => (int) max(0, $netSalary), + ]; } /** - * 총공제액 계산 + * 수동 수정된 공제 항목 반영 */ - private function calculateDeductions(array $data): float + private function applyDeductionOverrides(array &$calculated, ?array $overrides): void { - $incomeTax = (float) ($data['income_tax'] ?? 0); - $residentTax = (float) ($data['resident_tax'] ?? 0); - $healthInsurance = (float) ($data['health_insurance'] ?? 0); - $pension = (float) ($data['pension'] ?? 0); - $employmentInsurance = (float) ($data['employment_insurance'] ?? 0); - - $deductionsTotal = 0; - if (! empty($data['deductions'])) { - $deductionsTotal = collect($data['deductions'])->sum('amount'); + if (empty($overrides)) { + return; } - return $incomeTax + $residentTax + $healthInsurance + $pension + $employmentInsurance + $deductionsTotal; + $oldStatutory = $calculated['pension'] + $calculated['health_insurance'] + $calculated['long_term_care'] + + $calculated['employment_insurance'] + $calculated['income_tax'] + $calculated['resident_tax']; + $extraDeductions = max(0, $calculated['total_deductions'] - $oldStatutory); + + $fields = ['pension', 'health_insurance', 'long_term_care', 'employment_insurance', 'income_tax', 'resident_tax']; + foreach ($fields as $field) { + if (isset($overrides[$field])) { + $calculated[$field] = (int) $overrides[$field]; + } + } + + $newStatutory = $calculated['pension'] + $calculated['health_insurance'] + $calculated['long_term_care'] + + $calculated['employment_insurance'] + $calculated['income_tax'] + $calculated['resident_tax']; + $calculated['total_deductions'] = (int) ($newStatutory + $extraDeductions); + $calculated['net_salary'] = (int) max(0, $calculated['gross_salary'] - $calculated['total_deductions']); + } + + /** + * 근로소득세 계산 (2024 국세청 간이세액표 기반) + */ + public function calculateIncomeTax(float $taxableBase, int $familyCount = 1): int + { + if ($taxableBase <= 0) { + return 0; + } + + $salaryThousand = (int) floor($taxableBase / 1000); + $familyCount = max(1, min(11, $familyCount)); + + if ($salaryThousand < 770) { + return 0; + } + + if ($salaryThousand > 10000) { + return $this->calculateHighIncomeTax($salaryThousand, $familyCount); + } + + return IncomeTaxBracket::lookupTax(self::TAX_TABLE_YEAR, $salaryThousand, $familyCount); + } + + /** + * 10,000천원 초과 구간 근로소득세 공식 계산 (소득세법 시행령 별표2) + */ + private function calculateHighIncomeTax(int $salaryThousand, int $familyCount): int + { + $baseTax = IncomeTaxBracket::where('tax_year', self::TAX_TABLE_YEAR) + ->where('salary_from', 10000) + ->whereColumn('salary_from', 'salary_to') + ->where('family_count', $familyCount) + ->value('tax_amount') ?? 0; + + if ($salaryThousand <= 14000) { + $excessWon = ($salaryThousand - 10000) * 1000; + $tax = $baseTax + ($excessWon * 0.98 * 0.35) + 25000; + } elseif ($salaryThousand <= 28000) { + $excessWon = ($salaryThousand - 14000) * 1000; + $tax = $baseTax + 1397000 + ($excessWon * 0.98 * 0.38); + } elseif ($salaryThousand <= 30000) { + $excessWon = ($salaryThousand - 28000) * 1000; + $tax = $baseTax + 6610600 + ($excessWon * 0.98 * 0.40); + } elseif ($salaryThousand <= 45000) { + $excessWon = ($salaryThousand - 30000) * 1000; + $tax = $baseTax + 7394600 + ($excessWon * 0.40); + } elseif ($salaryThousand <= 87000) { + $excessWon = ($salaryThousand - 45000) * 1000; + $tax = $baseTax + 13394600 + ($excessWon * 0.42); + } else { + $excessWon = ($salaryThousand - 87000) * 1000; + $tax = $baseTax + 31034600 + ($excessWon * 0.45); + } + + return (int) (floor($tax / 10) * 10); + } + + private function calcHealthInsurance(float $taxableBase, PayrollSetting $settings): int + { + return (int) (floor($taxableBase * ($settings->health_insurance_rate / 100) / 10) * 10); + } + + private function calcLongTermCare(float $taxableBase, PayrollSetting $settings): int + { + $healthInsurance = $taxableBase * ($settings->health_insurance_rate / 100); + + return (int) (floor($healthInsurance * ($settings->long_term_care_rate / 100) / 10) * 10); + } + + private function calcPension(float $taxableBase, PayrollSetting $settings): int + { + $base = min(max($taxableBase, (float) $settings->pension_min_salary), (float) $settings->pension_max_salary); + + return (int) (floor($base * ($settings->pension_rate / 100) / 10) * 10); + } + + private function calcEmploymentInsurance(float $taxableBase, PayrollSetting $settings): int + { + return (int) (floor($taxableBase * ($settings->employment_insurance_rate / 100) / 10) * 10); + } + + /** + * user_id로 공제대상가족수 산출 (본인 1 + 피부양자) + */ + public function resolveFamilyCount(int $userId): int + { + $tenantId = $this->tenantId(); + + $profile = TenantUserProfile::query() + ->where('tenant_id', $tenantId) + ->where('user_id', $userId) + ->first(['json_extra']); + + if (! $profile) { + return 1; + } + + $dependents = $profile->json_extra['dependents'] ?? []; + $dependentCount = collect($dependents) + ->where('is_dependent', true) + ->count(); + + return max(1, min(11, 1 + $dependentCount)); } } diff --git a/lang/ko/error.php b/lang/ko/error.php index bc537ea..6418ac0 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -282,7 +282,10 @@ 'not_editable' => '작성중 상태의 급여만 수정할 수 있습니다.', 'not_deletable' => '작성중 상태의 급여만 삭제할 수 있습니다.', 'not_confirmable' => '작성중 상태의 급여만 확정할 수 있습니다.', + 'not_unconfirmable' => '확정된 급여만 확정 취소할 수 있습니다.', 'not_payable' => '확정된 급여만 지급 처리할 수 있습니다.', + 'not_unpayable' => '지급완료된 급여만 지급 취소할 수 있습니다.', + 'no_previous_month' => '전월 급여 데이터가 없습니다.', 'invalid_withdrawal' => '유효하지 않은 출금 내역입니다.', 'user_not_found' => '직원 정보를 찾을 수 없습니다.', 'no_base_salary' => '기본급이 설정되지 않았습니다.', diff --git a/lang/ko/message.php b/lang/ko/message.php index 60a6b53..a727c99 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -320,8 +320,12 @@ 'updated' => '급여가 수정되었습니다.', 'deleted' => '급여가 삭제되었습니다.', 'confirmed' => '급여가 확정되었습니다.', + 'unconfirmed' => '급여 확정이 취소되었습니다.', 'paid' => '급여가 지급 처리되었습니다.', + 'unpaid' => '급여 지급이 취소되었습니다.', 'bulk_confirmed' => '급여가 일괄 확정되었습니다.', + 'bulk_generated' => '급여가 일괄 생성되었습니다.', + 'copied' => '전월 급여가 복사되었습니다.', 'calculated' => '급여가 일괄 계산되었습니다.', 'payslip_fetched' => '급여명세서를 조회했습니다.', ], diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index 2b996bd..77f1080 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -96,12 +96,17 @@ 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('/calculate-preview', [PayrollController::class, 'calculatePreview'])->name('v1.payrolls.calculate-preview'); Route::post('/bulk-confirm', [PayrollController::class, 'bulkConfirm'])->name('v1.payrolls.bulk-confirm'); + Route::post('/bulk-generate', [PayrollController::class, 'bulkGenerate'])->name('v1.payrolls.bulk-generate'); + Route::post('/copy-from-previous', [PayrollController::class, 'copyFromPrevious'])->name('v1.payrolls.copy-from-previous'); 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}/unconfirm', [PayrollController::class, 'unconfirm'])->whereNumber('id')->name('v1.payrolls.unconfirm'); Route::post('/{id}/pay', [PayrollController::class, 'pay'])->whereNumber('id')->name('v1.payrolls.pay'); + Route::post('/{id}/unpay', [PayrollController::class, 'unpay'])->whereNumber('id')->name('v1.payrolls.unpay'); Route::get('/{id}/payslip', [PayrollController::class, 'payslip'])->whereNumber('id')->name('v1.payrolls.payslip'); }); From eeda6d980e60fe1f8f092b557b66a7d62066ade2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 11 Mar 2026 19:41:07 +0900 Subject: [PATCH 143/166] =?UTF-8?q?feat:=20[barobill]=20React=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=EC=9A=A9=20=EB=B0=94=EB=A1=9C=EB=B9=8C=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C/=EC=9D=80=ED=96=89/=ED=99=88=ED=83=9D=EC=8A=A4=20REST?= =?UTF-8?q?=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 바로빌 카드 거래 API (16 엔드포인트): 조회, 분할, 수동입력, 숨김/복원, 금액수정, 분개 - 바로빌 은행 거래 API (13 엔드포인트): 조회, 분할, 오버라이드, 수동입력, 잔액요약, 분개 - 홈택스 세금계산서 API (13 엔드포인트): 매출/매입 조회, 수동입력, 자체분개, 통합분개 - JournalEntry 소스 타입 상수 추가 (barobill_card, barobill_bank, hometax_invoice) --- .../V1/BarobillBankTransactionController.php | 287 +++++++++++++++ .../V1/BarobillCardTransactionController.php | 326 ++++++++++++++++++ .../Api/V1/HometaxInvoiceController.php | 278 +++++++++++++++ app/Models/Tenants/JournalEntry.php | 10 + .../BarobillBankTransactionService.php | 249 +++++++++++++ .../BarobillCardTransactionService.php | 308 +++++++++++++++++ app/Services/HometaxInvoiceService.php | 222 ++++++++++++ routes/api/v1/finance.php | 57 +++ 8 files changed, 1737 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/BarobillBankTransactionController.php create mode 100644 app/Http/Controllers/Api/V1/BarobillCardTransactionController.php create mode 100644 app/Http/Controllers/Api/V1/HometaxInvoiceController.php create mode 100644 app/Services/BarobillBankTransactionService.php create mode 100644 app/Services/BarobillCardTransactionService.php create mode 100644 app/Services/HometaxInvoiceService.php diff --git a/app/Http/Controllers/Api/V1/BarobillBankTransactionController.php b/app/Http/Controllers/Api/V1/BarobillBankTransactionController.php new file mode 100644 index 0000000..2a8d14e --- /dev/null +++ b/app/Http/Controllers/Api/V1/BarobillBankTransactionController.php @@ -0,0 +1,287 @@ +validate([ + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'bank_account_num' => 'nullable|string|max:50', + 'search' => 'nullable|string|max:100', + 'per_page' => 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + ]); + + return $this->service->index($params); + }, __('message.fetched')); + } + + /** + * 계좌 목록 (필터용) + */ + public function accounts(): JsonResponse + { + return ApiResponse::handle(function () { + return $this->service->accounts(); + }, __('message.fetched')); + } + + /** + * 잔액 요약 + */ + public function balanceSummary(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $params = $request->validate([ + 'date' => 'nullable|date', + ]); + + return $this->service->balanceSummary($params); + }, __('message.fetched')); + } + + // ========================================================================= + // 분할 (Splits) + // ========================================================================= + + /** + * 거래 분할 조회 + */ + public function getSplits(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'unique_key' => 'required|string|max:500', + ]); + + return $this->service->getSplits($validated['unique_key']); + }, __('message.fetched')); + } + + /** + * 거래 분할 저장 + */ + public function saveSplits(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'unique_key' => 'required|string|max:500', + 'items' => 'required|array|min:1', + 'items.*.split_amount' => 'required|numeric', + 'items.*.account_code' => 'nullable|string|max:20', + 'items.*.account_name' => 'nullable|string|max:100', + 'items.*.deduction_type' => 'nullable|string|max:20', + 'items.*.evidence_name' => 'nullable|string|max:100', + 'items.*.description' => 'nullable|string|max:500', + 'items.*.memo' => 'nullable|string|max:500', + 'items.*.bank_account_num' => 'nullable|string|max:50', + 'items.*.trans_dt' => 'nullable|string|max:20', + 'items.*.trans_date' => 'nullable|date', + 'items.*.original_deposit' => 'nullable|numeric', + 'items.*.original_withdraw' => 'nullable|numeric', + 'items.*.summary' => 'nullable|string|max:500', + ]); + + return $this->service->saveSplits($validated['unique_key'], $validated['items']); + }, __('message.created')); + } + + /** + * 거래 분할 삭제 + */ + public function deleteSplits(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'unique_key' => 'required|string|max:500', + ]); + + return $this->service->deleteSplits($validated['unique_key']); + }, __('message.deleted')); + } + + // ========================================================================= + // 오버라이드 (Override) + // ========================================================================= + + /** + * 적요/분류 오버라이드 저장 + */ + public function saveOverride(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'unique_key' => 'required|string|max:500', + 'modified_summary' => 'nullable|string|max:500', + 'modified_cast' => 'nullable|string|max:100', + ]); + + return $this->service->saveOverride( + $validated['unique_key'], + $validated['modified_summary'] ?? null, + $validated['modified_cast'] ?? null + ); + }, __('message.updated')); + } + + // ========================================================================= + // 수동 입력 (Manual) + // ========================================================================= + + /** + * 수동 은행 거래 등록 + */ + public function storeManual(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'bank_account_num' => 'required|string|max:50', + 'bank_code' => 'nullable|string|max:10', + 'bank_name' => 'nullable|string|max:50', + 'trans_date' => 'required|date', + 'trans_time' => 'nullable|string|max:10', + 'trans_dt' => 'nullable|string|max:20', + 'deposit' => 'nullable|numeric|min:0', + 'withdraw' => 'nullable|numeric|min:0', + 'balance' => 'nullable|numeric', + 'summary' => 'nullable|string|max:500', + 'cast' => 'nullable|string|max:100', + 'memo' => 'nullable|string|max:500', + 'trans_office' => 'nullable|string|max:100', + 'account_code' => 'nullable|string|max:20', + 'account_name' => 'nullable|string|max:100', + 'client_code' => 'nullable|string|max:20', + 'client_name' => 'nullable|string|max:200', + ]); + + return $this->service->storeManual($validated); + }, __('message.created')); + } + + /** + * 수동 은행 거래 수정 + */ + public function updateManual(Request $request, int $id): JsonResponse + { + return ApiResponse::handle(function () use ($request, $id) { + $validated = $request->validate([ + 'deposit' => 'nullable|numeric|min:0', + 'withdraw' => 'nullable|numeric|min:0', + 'balance' => 'nullable|numeric', + 'summary' => 'nullable|string|max:500', + 'cast' => 'nullable|string|max:100', + 'memo' => 'nullable|string|max:500', + 'account_code' => 'nullable|string|max:20', + 'account_name' => 'nullable|string|max:100', + 'client_code' => 'nullable|string|max:20', + 'client_name' => 'nullable|string|max:200', + ]); + + return $this->service->updateManual($id, $validated); + }, __('message.updated')); + } + + /** + * 수동 은행 거래 삭제 + */ + public function destroyManual(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->destroyManual($id); + }, __('message.deleted')); + } + + // ========================================================================= + // 분개 (Journal Entries) + // ========================================================================= + + /** + * 은행 거래 분개 조회 + */ + public function getJournalEntries(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $sourceKey = "barobill_bank_{$id}"; + + return $this->journalSyncService->getForSource( + JournalEntry::SOURCE_BAROBILL_BANK, + $sourceKey + ) ?? ['items' => []]; + }, __('message.fetched')); + } + + /** + * 은행 거래 분개 저장 + */ + public function storeJournalEntries(Request $request, int $id): JsonResponse + { + return ApiResponse::handle(function () use ($request, $id) { + $validated = $request->validate([ + 'items' => 'required|array|min:1', + 'items.*.side' => 'required|in:debit,credit', + 'items.*.account_code' => 'required|string|max:20', + 'items.*.account_name' => 'nullable|string|max:100', + 'items.*.debit_amount' => 'required|integer|min:0', + 'items.*.credit_amount' => 'required|integer|min:0', + 'items.*.vendor_name' => 'nullable|string|max:200', + 'items.*.memo' => 'nullable|string|max:500', + ]); + + $bankTx = \App\Models\Barobill\BarobillBankTransaction::find($id); + if (! $bankTx) { + throw new \Illuminate\Database\Eloquent\ModelNotFoundException; + } + + $entryDate = $bankTx->trans_date ?? now()->format('Y-m-d'); + $sourceKey = "barobill_bank_{$id}"; + + return $this->journalSyncService->saveForSource( + JournalEntry::SOURCE_BAROBILL_BANK, + $sourceKey, + $entryDate, + "바로빌 은행거래 분개 (#{$id})", + $validated['items'], + ); + }, __('message.created')); + } + + /** + * 은행 거래 분개 삭제 + */ + public function deleteJournalEntries(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $sourceKey = "barobill_bank_{$id}"; + + return $this->journalSyncService->deleteForSource( + JournalEntry::SOURCE_BAROBILL_BANK, + $sourceKey + ); + }, __('message.deleted')); + } +} diff --git a/app/Http/Controllers/Api/V1/BarobillCardTransactionController.php b/app/Http/Controllers/Api/V1/BarobillCardTransactionController.php new file mode 100644 index 0000000..e99648c --- /dev/null +++ b/app/Http/Controllers/Api/V1/BarobillCardTransactionController.php @@ -0,0 +1,326 @@ +validate([ + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'card_num' => 'nullable|string|max:50', + 'search' => 'nullable|string|max:100', + 'include_hidden' => 'nullable|boolean', + 'per_page' => 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + ]); + + return $this->service->index($params); + }, __('message.fetched')); + } + + /** + * 단일 카드 거래 상세 + */ + public function show(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $tx = $this->service->show($id); + if (! $tx) { + throw new \Illuminate\Database\Eloquent\ModelNotFoundException; + } + + return $tx; + }, __('message.fetched')); + } + + /** + * 카드 번호 목록 (필터용) + */ + public function cardNumbers(): JsonResponse + { + return ApiResponse::handle(function () { + return $this->service->cardNumbers(); + }, __('message.fetched')); + } + + // ========================================================================= + // 분할 (Splits) + // ========================================================================= + + /** + * 카드 거래 분할 조회 + */ + public function getSplits(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'unique_key' => 'required|string|max:500', + ]); + + return $this->service->getSplits($validated['unique_key']); + }, __('message.fetched')); + } + + /** + * 카드 거래 분할 저장 + */ + public function saveSplits(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'unique_key' => 'required|string|max:500', + 'items' => 'required|array|min:1', + 'items.*.split_amount' => 'required|numeric', + 'items.*.split_supply_amount' => 'nullable|numeric', + 'items.*.split_tax' => 'nullable|numeric', + 'items.*.account_code' => 'nullable|string|max:20', + 'items.*.account_name' => 'nullable|string|max:100', + 'items.*.deduction_type' => 'nullable|string|max:20', + 'items.*.evidence_name' => 'nullable|string|max:100', + 'items.*.description' => 'nullable|string|max:500', + 'items.*.memo' => 'nullable|string|max:500', + 'items.*.card_num' => 'nullable|string|max:50', + 'items.*.use_dt' => 'nullable|string|max:20', + 'items.*.use_date' => 'nullable|date', + 'items.*.approval_num' => 'nullable|string|max:50', + 'items.*.original_amount' => 'nullable|numeric', + 'items.*.merchant_name' => 'nullable|string|max:200', + ]); + + return $this->service->saveSplits($validated['unique_key'], $validated['items']); + }, __('message.created')); + } + + /** + * 카드 거래 분할 삭제 + */ + public function deleteSplits(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'unique_key' => 'required|string|max:500', + ]); + + return $this->service->deleteSplits($validated['unique_key']); + }, __('message.deleted')); + } + + // ========================================================================= + // 수동 입력 (Manual) + // ========================================================================= + + /** + * 수동 카드 거래 등록 + */ + public function storeManual(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'card_num' => 'required|string|max:50', + 'card_company' => 'nullable|string|max:10', + 'card_company_name' => 'nullable|string|max:50', + 'use_dt' => 'required|string|max:20', + 'use_date' => 'required|date', + 'use_time' => 'nullable|string|max:10', + 'approval_num' => 'nullable|string|max:50', + 'approval_type' => 'nullable|string|max:10', + 'approval_amount' => 'required|numeric', + 'tax' => 'nullable|numeric', + 'service_charge' => 'nullable|numeric', + 'merchant_name' => 'required|string|max:200', + 'merchant_biz_num' => 'nullable|string|max:20', + 'account_code' => 'nullable|string|max:20', + 'account_name' => 'nullable|string|max:100', + 'deduction_type' => 'nullable|string|max:20', + 'evidence_name' => 'nullable|string|max:100', + 'description' => 'nullable|string|max:500', + 'memo' => 'nullable|string|max:500', + ]); + + return $this->service->storeManual($validated); + }, __('message.created')); + } + + /** + * 수동 카드 거래 수정 + */ + public function updateManual(Request $request, int $id): JsonResponse + { + return ApiResponse::handle(function () use ($request, $id) { + $validated = $request->validate([ + 'approval_amount' => 'nullable|numeric', + 'tax' => 'nullable|numeric', + 'merchant_name' => 'nullable|string|max:200', + 'account_code' => 'nullable|string|max:20', + 'account_name' => 'nullable|string|max:100', + 'deduction_type' => 'nullable|string|max:20', + 'description' => 'nullable|string|max:500', + 'memo' => 'nullable|string|max:500', + ]); + + return $this->service->updateManual($id, $validated); + }, __('message.updated')); + } + + /** + * 수동 카드 거래 삭제 + */ + public function destroyManual(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->destroyManual($id); + }, __('message.deleted')); + } + + // ========================================================================= + // 숨김/복원 (Hide/Restore) + // ========================================================================= + + /** + * 카드 거래 숨김 + */ + public function hide(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->hide($id); + }, __('message.updated')); + } + + /** + * 카드 거래 숨김 복원 + */ + public function restore(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->restore($id); + }, __('message.updated')); + } + + /** + * 숨겨진 거래 목록 + */ + public function hiddenList(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $params = $request->validate([ + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + ]); + + return $this->service->hiddenList($params); + }, __('message.fetched')); + } + + // ========================================================================= + // 금액 수정 + // ========================================================================= + + /** + * 공급가액/세액 수정 + */ + public function updateAmount(Request $request, int $id): JsonResponse + { + return ApiResponse::handle(function () use ($request, $id) { + $validated = $request->validate([ + 'supply_amount' => 'required|numeric', + 'tax' => 'required|numeric', + 'modified_by_name' => 'nullable|string|max:50', + ]); + + return $this->service->updateAmount($id, $validated); + }, __('message.updated')); + } + + // ========================================================================= + // 분개 (Journal Entries) + // ========================================================================= + + /** + * 카드 거래 분개 조회 + */ + public function getJournalEntries(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $sourceKey = "barobill_card_{$id}"; + + return $this->journalSyncService->getForSource( + JournalEntry::SOURCE_BAROBILL_CARD, + $sourceKey + ) ?? ['items' => []]; + }, __('message.fetched')); + } + + /** + * 카드 거래 분개 저장 + */ + public function storeJournalEntries(Request $request, int $id): JsonResponse + { + return ApiResponse::handle(function () use ($request, $id) { + $validated = $request->validate([ + 'items' => 'required|array|min:1', + 'items.*.side' => 'required|in:debit,credit', + 'items.*.account_code' => 'required|string|max:20', + 'items.*.account_name' => 'nullable|string|max:100', + 'items.*.debit_amount' => 'required|integer|min:0', + 'items.*.credit_amount' => 'required|integer|min:0', + 'items.*.vendor_name' => 'nullable|string|max:200', + 'items.*.memo' => 'nullable|string|max:500', + ]); + + $tx = $this->service->show($id); + if (! $tx) { + throw new \Illuminate\Database\Eloquent\ModelNotFoundException; + } + + $entryDate = $tx->use_date ?? now()->format('Y-m-d'); + $sourceKey = "barobill_card_{$id}"; + + return $this->journalSyncService->saveForSource( + JournalEntry::SOURCE_BAROBILL_CARD, + $sourceKey, + $entryDate, + "바로빌 카드거래 분개 (#{$id})", + $validated['items'], + ); + }, __('message.created')); + } + + /** + * 카드 거래 분개 삭제 + */ + public function deleteJournalEntries(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $sourceKey = "barobill_card_{$id}"; + + return $this->journalSyncService->deleteForSource( + JournalEntry::SOURCE_BAROBILL_CARD, + $sourceKey + ); + }, __('message.deleted')); + } +} diff --git a/app/Http/Controllers/Api/V1/HometaxInvoiceController.php b/app/Http/Controllers/Api/V1/HometaxInvoiceController.php new file mode 100644 index 0000000..772d6b9 --- /dev/null +++ b/app/Http/Controllers/Api/V1/HometaxInvoiceController.php @@ -0,0 +1,278 @@ +validate([ + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'search' => 'nullable|string|max:100', + 'per_page' => 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + ]); + + return $this->service->sales($params); + }, __('message.fetched')); + } + + /** + * 매입 세금계산서 목록 + */ + public function purchases(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $params = $request->validate([ + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'search' => 'nullable|string|max:100', + 'per_page' => 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + ]); + + return $this->service->purchases($params); + }, __('message.fetched')); + } + + /** + * 세금계산서 상세 조회 + */ + public function show(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $invoice = $this->service->show($id); + if (! $invoice) { + throw new \Illuminate\Database\Eloquent\ModelNotFoundException; + } + + return $invoice; + }, __('message.fetched')); + } + + /** + * 요약 통계 (매출/매입 합계) + */ + public function summary(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $params = $request->validate([ + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + ]); + + return $this->service->summary($params); + }, __('message.fetched')); + } + + // ========================================================================= + // 수동 입력 (Manual) + // ========================================================================= + + /** + * 수동 세금계산서 등록 + */ + public function store(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validate([ + 'invoice_type' => 'required|in:sales,purchase', + 'nts_confirm_num' => 'nullable|string|max:50', + 'write_date' => 'required|date', + 'issue_date' => 'nullable|date', + 'invoicer_corp_num' => 'nullable|string|max:20', + 'invoicer_corp_name' => 'nullable|string|max:200', + 'invoicer_ceo_name' => 'nullable|string|max:100', + 'invoicee_corp_num' => 'nullable|string|max:20', + 'invoicee_corp_name' => 'nullable|string|max:200', + 'invoicee_ceo_name' => 'nullable|string|max:100', + 'supply_amount' => 'required|integer', + 'tax_amount' => 'required|integer', + 'total_amount' => 'required|integer', + 'tax_type' => 'nullable|string|max:10', + 'purpose_type' => 'nullable|string|max:10', + 'issue_type' => 'nullable|string|max:10', + 'item_name' => 'nullable|string|max:200', + 'account_code' => 'nullable|string|max:20', + 'account_name' => 'nullable|string|max:100', + 'deduction_type' => 'nullable|string|max:20', + 'remark1' => 'nullable|string|max:500', + ]); + + return $this->service->storeManual($validated); + }, __('message.created')); + } + + /** + * 수동 세금계산서 수정 + */ + public function update(Request $request, int $id): JsonResponse + { + return ApiResponse::handle(function () use ($request, $id) { + $validated = $request->validate([ + 'write_date' => 'nullable|date', + 'issue_date' => 'nullable|date', + 'invoicer_corp_num' => 'nullable|string|max:20', + 'invoicer_corp_name' => 'nullable|string|max:200', + 'invoicee_corp_num' => 'nullable|string|max:20', + 'invoicee_corp_name' => 'nullable|string|max:200', + 'supply_amount' => 'nullable|integer', + 'tax_amount' => 'nullable|integer', + 'total_amount' => 'nullable|integer', + 'item_name' => 'nullable|string|max:200', + 'account_code' => 'nullable|string|max:20', + 'account_name' => 'nullable|string|max:100', + 'deduction_type' => 'nullable|string|max:20', + 'remark1' => 'nullable|string|max:500', + ]); + + return $this->service->updateManual($id, $validated); + }, __('message.updated')); + } + + /** + * 수동 세금계산서 삭제 + */ + public function destroy(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->destroyManual($id); + }, __('message.deleted')); + } + + // ========================================================================= + // 분개 (자체 테이블: hometax_invoice_journals) + // ========================================================================= + + /** + * 분개 조회 + */ + public function getJournals(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->getJournals($id); + }, __('message.fetched')); + } + + /** + * 분개 저장 + */ + public function saveJournals(Request $request, int $id): JsonResponse + { + return ApiResponse::handle(function () use ($request, $id) { + $validated = $request->validate([ + 'items' => 'required|array|min:1', + 'items.*.dc_type' => 'required|in:debit,credit', + 'items.*.account_code' => 'required|string|max:20', + 'items.*.account_name' => 'nullable|string|max:100', + 'items.*.debit_amount' => 'required|integer|min:0', + 'items.*.credit_amount' => 'required|integer|min:0', + 'items.*.description' => 'nullable|string|max:500', + ]); + + return $this->service->saveJournals($id, $validated['items']); + }, __('message.created')); + } + + /** + * 분개 삭제 + */ + public function deleteJournals(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->deleteJournals($id); + }, __('message.deleted')); + } + + // ========================================================================= + // 통합 분개 (JournalSyncService - CEO 대시보드 연동) + // ========================================================================= + + /** + * 통합 분개 조회 + */ + public function getJournalEntries(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $sourceKey = "hometax_{$id}"; + + return $this->journalSyncService->getForSource( + JournalEntry::SOURCE_HOMETAX_INVOICE, + $sourceKey + ) ?? ['items' => []]; + }, __('message.fetched')); + } + + /** + * 통합 분개 저장 + */ + public function storeJournalEntries(Request $request, int $id): JsonResponse + { + return ApiResponse::handle(function () use ($request, $id) { + $validated = $request->validate([ + 'items' => 'required|array|min:1', + 'items.*.side' => 'required|in:debit,credit', + 'items.*.account_code' => 'required|string|max:20', + 'items.*.account_name' => 'nullable|string|max:100', + 'items.*.debit_amount' => 'required|integer|min:0', + 'items.*.credit_amount' => 'required|integer|min:0', + 'items.*.vendor_name' => 'nullable|string|max:200', + 'items.*.memo' => 'nullable|string|max:500', + ]); + + $invoice = $this->service->show($id); + if (! $invoice) { + throw new \Illuminate\Database\Eloquent\ModelNotFoundException; + } + + $entryDate = $invoice->write_date?->format('Y-m-d') ?? now()->format('Y-m-d'); + $sourceKey = "hometax_{$id}"; + + return $this->journalSyncService->saveForSource( + JournalEntry::SOURCE_HOMETAX_INVOICE, + $sourceKey, + $entryDate, + "홈택스 세금계산서 분개 (#{$id})", + $validated['items'], + ); + }, __('message.created')); + } + + /** + * 통합 분개 삭제 + */ + public function deleteJournalEntries(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $sourceKey = "hometax_{$id}"; + + return $this->journalSyncService->deleteForSource( + JournalEntry::SOURCE_HOMETAX_INVOICE, + $sourceKey + ); + }, __('message.deleted')); + } +} diff --git a/app/Models/Tenants/JournalEntry.php b/app/Models/Tenants/JournalEntry.php index 6a0970a..6b2b415 100644 --- a/app/Models/Tenants/JournalEntry.php +++ b/app/Models/Tenants/JournalEntry.php @@ -34,14 +34,24 @@ class JournalEntry extends Model // Status public const STATUS_DRAFT = 'draft'; + public const STATUS_CONFIRMED = 'confirmed'; // Source type public const SOURCE_MANUAL = 'manual'; + public const SOURCE_BANK_TRANSACTION = 'bank_transaction'; + public const SOURCE_TAX_INVOICE = 'tax_invoice'; + public const SOURCE_CARD_TRANSACTION = 'card_transaction'; + public const SOURCE_BAROBILL_CARD = 'barobill_card'; + + public const SOURCE_BAROBILL_BANK = 'barobill_bank'; + + public const SOURCE_HOMETAX_INVOICE = 'hometax_invoice'; + // Entry type public const TYPE_GENERAL = 'general'; diff --git a/app/Services/BarobillBankTransactionService.php b/app/Services/BarobillBankTransactionService.php new file mode 100644 index 0000000..3af3ca5 --- /dev/null +++ b/app/Services/BarobillBankTransactionService.php @@ -0,0 +1,249 @@ +tenantId(); + $startDate = $params['start_date'] ?? now()->startOfMonth()->format('Y-m-d'); + $endDate = $params['end_date'] ?? now()->format('Y-m-d'); + $accountNum = $params['bank_account_num'] ?? null; + $search = $params['search'] ?? null; + $perPage = $params['per_page'] ?? 50; + + $query = BarobillBankTransaction::where('tenant_id', $tenantId) + ->whereBetween('trans_date', [$startDate, $endDate]); + + if ($accountNum) { + $query->where('bank_account_num', $accountNum); + } + + if ($search) { + $query->where(function ($q) use ($search) { + $q->where('summary', 'like', "%{$search}%") + ->orWhere('memo', 'like', "%{$search}%") + ->orWhere('client_name', 'like', "%{$search}%"); + }); + } + + $query->orderByDesc('trans_date')->orderByDesc('trans_dt'); + + $transactions = $query->paginate($perPage); + + // 분할/오버라이드 정보 로드 + $uniqueKeys = $transactions->getCollection()->map->unique_key->toArray(); + + $splits = BarobillBankTransactionSplit::where('tenant_id', $tenantId) + ->whereIn('original_unique_key', $uniqueKeys) + ->orderBy('sort_order') + ->get() + ->groupBy('original_unique_key'); + + $overrides = BarobillBankTransactionOverride::getByUniqueKeys($tenantId, $uniqueKeys); + + $transactions->getCollection()->transform(function ($tx) use ($splits, $overrides) { + $tx->splits = $splits->get($tx->unique_key, collect()); + $tx->has_splits = $tx->splits->isNotEmpty(); + $tx->override = $overrides->get($tx->unique_key); + + return $tx; + }); + + return [ + 'data' => $transactions, + ]; + } + + /** + * 계좌 목록 (필터용) + */ + public function accounts(): array + { + $tenantId = $this->tenantId(); + + $accounts = BarobillBankTransaction::where('tenant_id', $tenantId) + ->select('bank_account_num', 'bank_name') + ->distinct() + ->orderBy('bank_account_num') + ->get(); + + return ['items' => $accounts]; + } + + /** + * 거래 분할 조회 + */ + public function getSplits(string $uniqueKey): array + { + $tenantId = $this->tenantId(); + $splits = BarobillBankTransactionSplit::getByUniqueKey($tenantId, $uniqueKey); + + return ['items' => $splits]; + } + + /** + * 거래 분할 저장 + */ + public function saveSplits(string $uniqueKey, array $items): array + { + $tenantId = $this->tenantId(); + + return DB::transaction(function () use ($tenantId, $uniqueKey, $items) { + BarobillBankTransactionSplit::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->delete(); + + $created = []; + foreach ($items as $index => $item) { + $created[] = BarobillBankTransactionSplit::create([ + 'tenant_id' => $tenantId, + 'original_unique_key' => $uniqueKey, + 'split_amount' => $item['split_amount'], + 'account_code' => $item['account_code'] ?? null, + 'account_name' => $item['account_name'] ?? null, + 'deduction_type' => $item['deduction_type'] ?? null, + 'evidence_name' => $item['evidence_name'] ?? null, + 'description' => $item['description'] ?? null, + 'memo' => $item['memo'] ?? null, + 'sort_order' => $index + 1, + 'bank_account_num' => $item['bank_account_num'] ?? null, + 'trans_dt' => $item['trans_dt'] ?? null, + 'trans_date' => $item['trans_date'] ?? null, + 'original_deposit' => $item['original_deposit'] ?? 0, + 'original_withdraw' => $item['original_withdraw'] ?? 0, + 'summary' => $item['summary'] ?? null, + ]); + } + + return ['items' => $created, 'count' => count($created)]; + }); + } + + /** + * 거래 분할 삭제 + */ + public function deleteSplits(string $uniqueKey): array + { + $tenantId = $this->tenantId(); + $deleted = BarobillBankTransactionSplit::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->delete(); + + return ['deleted_count' => $deleted]; + } + + /** + * 적요/분류 오버라이드 저장 + */ + public function saveOverride(string $uniqueKey, ?string $modifiedSummary, ?string $modifiedCast): BarobillBankTransactionOverride + { + $tenantId = $this->tenantId(); + + return BarobillBankTransactionOverride::saveOverride($tenantId, $uniqueKey, $modifiedSummary, $modifiedCast); + } + + /** + * 수동 은행 거래 등록 + */ + public function storeManual(array $data): BarobillBankTransaction + { + $tenantId = $this->tenantId(); + + return BarobillBankTransaction::create([ + 'tenant_id' => $tenantId, + 'bank_account_num' => $data['bank_account_num'], + 'bank_code' => $data['bank_code'] ?? null, + 'bank_name' => $data['bank_name'] ?? null, + 'trans_date' => $data['trans_date'], + 'trans_time' => $data['trans_time'] ?? null, + 'trans_dt' => $data['trans_dt'] ?? $data['trans_date'].($data['trans_time'] ?? '000000'), + 'deposit' => $data['deposit'] ?? 0, + 'withdraw' => $data['withdraw'] ?? 0, + 'balance' => $data['balance'] ?? 0, + 'summary' => $data['summary'] ?? null, + 'cast' => $data['cast'] ?? null, + 'memo' => $data['memo'] ?? null, + 'trans_office' => $data['trans_office'] ?? null, + 'account_code' => $data['account_code'] ?? null, + 'account_name' => $data['account_name'] ?? null, + 'client_code' => $data['client_code'] ?? null, + 'client_name' => $data['client_name'] ?? null, + 'is_manual' => true, + ]); + } + + /** + * 수동 은행 거래 수정 + */ + public function updateManual(int $id, array $data): BarobillBankTransaction + { + $tx = BarobillBankTransaction::where('tenant_id', $this->tenantId()) + ->where('is_manual', true) + ->findOrFail($id); + + $tx->update($data); + + return $tx->fresh(); + } + + /** + * 수동 은행 거래 삭제 + */ + public function destroyManual(int $id): bool + { + $tx = BarobillBankTransaction::where('tenant_id', $this->tenantId()) + ->where('is_manual', true) + ->findOrFail($id); + + return $tx->delete(); + } + + /** + * 잔액 요약 + */ + public function balanceSummary(array $params): array + { + $tenantId = $this->tenantId(); + $date = $params['date'] ?? now()->format('Y-m-d'); + + $accounts = BarobillBankTransaction::where('tenant_id', $tenantId) + ->select('bank_account_num', 'bank_name') + ->distinct() + ->get(); + + $summary = []; + foreach ($accounts as $account) { + $lastTx = BarobillBankTransaction::where('tenant_id', $tenantId) + ->where('bank_account_num', $account->bank_account_num) + ->where('trans_date', '<=', $date) + ->orderByDesc('trans_date') + ->orderByDesc('trans_dt') + ->first(); + + $summary[] = [ + 'bank_account_num' => $account->bank_account_num, + 'bank_name' => $account->bank_name, + 'balance' => $lastTx ? $lastTx->balance : 0, + 'last_trans_date' => $lastTx?->trans_date, + ]; + } + + return ['items' => $summary]; + } +} diff --git a/app/Services/BarobillCardTransactionService.php b/app/Services/BarobillCardTransactionService.php new file mode 100644 index 0000000..688c433 --- /dev/null +++ b/app/Services/BarobillCardTransactionService.php @@ -0,0 +1,308 @@ +tenantId(); + $startDate = $params['start_date'] ?? now()->startOfMonth()->format('Y-m-d'); + $endDate = $params['end_date'] ?? now()->format('Y-m-d'); + $cardNum = $params['card_num'] ?? null; + $search = $params['search'] ?? null; + $includeHidden = $params['include_hidden'] ?? false; + $perPage = $params['per_page'] ?? 50; + + $query = BarobillCardTransaction::where('tenant_id', $tenantId) + ->whereBetween('use_date', [$startDate, $endDate]); + + if ($cardNum) { + $query->where('card_num', $cardNum); + } + + if ($search) { + $query->where(function ($q) use ($search) { + $q->where('merchant_name', 'like', "%{$search}%") + ->orWhere('memo', 'like', "%{$search}%") + ->orWhere('approval_num', 'like', "%{$search}%"); + }); + } + + // 숨김 거래 필터링 + if (! $includeHidden) { + $hiddenKeys = BarobillCardTransactionHide::getHiddenKeys($tenantId, $startDate, $endDate); + if (! empty($hiddenKeys)) { + $query->whereNotIn( + DB::raw("CONCAT(card_num, '|', use_dt, '|', approval_num, '|', approval_amount)"), + $hiddenKeys + ); + } + } + + $query->orderByDesc('use_date')->orderByDesc('use_dt'); + + $transactions = $query->paginate($perPage); + + // 분할 거래 정보 로드 + $uniqueKeys = $transactions->getCollection()->map->unique_key->toArray(); + $splits = BarobillCardTransactionSplit::where('tenant_id', $tenantId) + ->whereIn('original_unique_key', $uniqueKeys) + ->orderBy('sort_order') + ->get() + ->groupBy('original_unique_key'); + + $transactions->getCollection()->transform(function ($tx) use ($splits) { + $tx->splits = $splits->get($tx->unique_key, collect()); + $tx->has_splits = $tx->splits->isNotEmpty(); + + return $tx; + }); + + return [ + 'data' => $transactions, + ]; + } + + /** + * 단일 카드 거래 상세 + */ + public function show(int $id): ?BarobillCardTransaction + { + return BarobillCardTransaction::where('tenant_id', $this->tenantId()) + ->find($id); + } + + /** + * 카드 거래 분할 조회 + */ + public function getSplits(string $uniqueKey): array + { + $tenantId = $this->tenantId(); + $splits = BarobillCardTransactionSplit::getByUniqueKey($tenantId, $uniqueKey); + + return ['items' => $splits]; + } + + /** + * 카드 거래 분할 저장 + */ + public function saveSplits(string $uniqueKey, array $items): array + { + $tenantId = $this->tenantId(); + + return DB::transaction(function () use ($tenantId, $uniqueKey, $items) { + // 기존 분할 삭제 + BarobillCardTransactionSplit::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->delete(); + + $created = []; + foreach ($items as $index => $item) { + $created[] = BarobillCardTransactionSplit::create([ + 'tenant_id' => $tenantId, + 'original_unique_key' => $uniqueKey, + 'split_amount' => $item['split_amount'], + 'split_supply_amount' => $item['split_supply_amount'] ?? 0, + 'split_tax' => $item['split_tax'] ?? 0, + 'account_code' => $item['account_code'] ?? null, + 'account_name' => $item['account_name'] ?? null, + 'deduction_type' => $item['deduction_type'] ?? null, + 'evidence_name' => $item['evidence_name'] ?? null, + 'description' => $item['description'] ?? null, + 'memo' => $item['memo'] ?? null, + 'sort_order' => $index + 1, + 'card_num' => $item['card_num'] ?? null, + 'use_dt' => $item['use_dt'] ?? null, + 'use_date' => $item['use_date'] ?? null, + 'approval_num' => $item['approval_num'] ?? null, + 'original_amount' => $item['original_amount'] ?? 0, + 'merchant_name' => $item['merchant_name'] ?? null, + ]); + } + + return ['items' => $created, 'count' => count($created)]; + }); + } + + /** + * 카드 거래 분할 삭제 + */ + public function deleteSplits(string $uniqueKey): array + { + $tenantId = $this->tenantId(); + $deleted = BarobillCardTransactionSplit::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->delete(); + + return ['deleted_count' => $deleted]; + } + + /** + * 수동 카드 거래 등록 + */ + public function storeManual(array $data): BarobillCardTransaction + { + $tenantId = $this->tenantId(); + + return BarobillCardTransaction::create([ + 'tenant_id' => $tenantId, + 'card_num' => $data['card_num'], + 'card_company' => $data['card_company'] ?? null, + 'card_company_name' => $data['card_company_name'] ?? null, + 'use_dt' => $data['use_dt'], + 'use_date' => $data['use_date'], + 'use_time' => $data['use_time'] ?? null, + 'approval_num' => $data['approval_num'] ?? 'MANUAL-'.now()->format('YmdHis'), + 'approval_type' => $data['approval_type'] ?? '1', + 'approval_amount' => $data['approval_amount'], + 'tax' => $data['tax'] ?? 0, + 'service_charge' => $data['service_charge'] ?? 0, + 'payment_plan' => $data['payment_plan'] ?? null, + 'merchant_name' => $data['merchant_name'], + 'merchant_biz_num' => $data['merchant_biz_num'] ?? null, + 'account_code' => $data['account_code'] ?? null, + 'account_name' => $data['account_name'] ?? null, + 'deduction_type' => $data['deduction_type'] ?? null, + 'evidence_name' => $data['evidence_name'] ?? null, + 'description' => $data['description'] ?? null, + 'memo' => $data['memo'] ?? null, + 'is_manual' => true, + ]); + } + + /** + * 수동 카드 거래 수정 + */ + public function updateManual(int $id, array $data): BarobillCardTransaction + { + $tx = BarobillCardTransaction::where('tenant_id', $this->tenantId()) + ->where('is_manual', true) + ->findOrFail($id); + + $tx->update($data); + + return $tx->fresh(); + } + + /** + * 수동 카드 거래 삭제 + */ + public function destroyManual(int $id): bool + { + $tx = BarobillCardTransaction::where('tenant_id', $this->tenantId()) + ->where('is_manual', true) + ->findOrFail($id); + + return $tx->delete(); + } + + /** + * 카드 거래 숨김 + */ + public function hide(int $id): BarobillCardTransactionHide + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $tx = BarobillCardTransaction::where('tenant_id', $tenantId)->findOrFail($id); + + return BarobillCardTransactionHide::hideTransaction($tenantId, $tx->unique_key, [ + 'card_num' => $tx->card_num, + 'use_date' => $tx->use_date, + 'approval_num' => $tx->approval_num, + 'approval_amount' => $tx->approval_amount, + 'merchant_name' => $tx->merchant_name, + ], $userId); + } + + /** + * 카드 거래 숨김 복원 + */ + public function restore(int $id): bool + { + $tenantId = $this->tenantId(); + $tx = BarobillCardTransaction::where('tenant_id', $tenantId)->findOrFail($id); + + return BarobillCardTransactionHide::restoreTransaction($tenantId, $tx->unique_key); + } + + /** + * 숨겨진 거래 목록 + */ + public function hiddenList(array $params): array + { + $tenantId = $this->tenantId(); + $startDate = $params['start_date'] ?? now()->startOfMonth()->format('Y-m-d'); + $endDate = $params['end_date'] ?? now()->format('Y-m-d'); + + $hiddenItems = BarobillCardTransactionHide::where('tenant_id', $tenantId) + ->whereBetween('use_date', [$startDate, $endDate]) + ->orderByDesc('created_at') + ->get(); + + return ['items' => $hiddenItems]; + } + + /** + * 금액 수정 (공급가액/세액 수정) + */ + public function updateAmount(int $id, array $data): BarobillCardTransaction + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $tx = BarobillCardTransaction::where('tenant_id', $tenantId)->findOrFail($id); + + // 변경 이력 기록 + BarobillCardTransactionAmountLog::create([ + 'card_transaction_id' => $tx->id, + 'original_unique_key' => $tx->unique_key, + 'before_supply_amount' => $tx->modified_supply_amount ?? $tx->approval_amount, + 'before_tax' => $tx->modified_tax ?? $tx->tax, + 'after_supply_amount' => $data['supply_amount'], + 'after_tax' => $data['tax'], + 'modified_by' => $userId, + 'modified_by_name' => $data['modified_by_name'] ?? '', + 'ip_address' => request()->ip(), + ]); + + $tx->update([ + 'modified_supply_amount' => $data['supply_amount'], + 'modified_tax' => $data['tax'], + ]); + + return $tx->fresh(); + } + + /** + * 카드 번호 목록 (필터용) + */ + public function cardNumbers(): array + { + $tenantId = $this->tenantId(); + + $cards = BarobillCardTransaction::where('tenant_id', $tenantId) + ->select('card_num', 'card_company_name') + ->distinct() + ->orderBy('card_num') + ->get(); + + return ['items' => $cards]; + } +} diff --git a/app/Services/HometaxInvoiceService.php b/app/Services/HometaxInvoiceService.php new file mode 100644 index 0000000..75a7bf9 --- /dev/null +++ b/app/Services/HometaxInvoiceService.php @@ -0,0 +1,222 @@ +listByType('sales', $params); + } + + /** + * 매입 세금계산서 목록 + */ + public function purchases(array $params): array + { + return $this->listByType('purchase', $params); + } + + /** + * 세금계산서 상세 조회 + */ + public function show(int $id): ?HometaxInvoice + { + return HometaxInvoice::where('tenant_id', $this->tenantId()) + ->with('journals') + ->find($id); + } + + /** + * 분개 저장 (홈택스 자체 분개 테이블 사용) + */ + public function saveJournals(int $invoiceId, array $items): array + { + $tenantId = $this->tenantId(); + $invoice = HometaxInvoice::where('tenant_id', $tenantId)->findOrFail($invoiceId); + + return DB::transaction(function () use ($tenantId, $invoice, $items) { + // 기존 분개 삭제 + HometaxInvoiceJournal::where('tenant_id', $tenantId) + ->where('hometax_invoice_id', $invoice->id) + ->delete(); + + $created = []; + foreach ($items as $index => $item) { + $created[] = HometaxInvoiceJournal::create([ + 'tenant_id' => $tenantId, + 'hometax_invoice_id' => $invoice->id, + 'nts_confirm_num' => $invoice->nts_confirm_num, + 'dc_type' => $item['dc_type'], + 'account_code' => $item['account_code'], + 'account_name' => $item['account_name'] ?? null, + 'debit_amount' => $item['debit_amount'] ?? 0, + 'credit_amount' => $item['credit_amount'] ?? 0, + 'description' => $item['description'] ?? null, + 'sort_order' => $index + 1, + 'invoice_type' => $invoice->invoice_type, + 'write_date' => $invoice->write_date, + 'supply_amount' => $invoice->supply_amount, + 'tax_amount' => $invoice->tax_amount, + 'total_amount' => $invoice->total_amount, + 'trading_partner_name' => $invoice->invoice_type === 'sales' + ? $invoice->invoicee_corp_name + : $invoice->invoicer_corp_name, + ]); + } + + return ['items' => $created, 'count' => count($created)]; + }); + } + + /** + * 분개 조회 + */ + public function getJournals(int $invoiceId): array + { + $tenantId = $this->tenantId(); + $journals = HometaxInvoiceJournal::getByInvoiceId($tenantId, $invoiceId); + + return ['items' => $journals]; + } + + /** + * 분개 삭제 + */ + public function deleteJournals(int $invoiceId): array + { + $tenantId = $this->tenantId(); + $deleted = HometaxInvoiceJournal::where('tenant_id', $tenantId) + ->where('hometax_invoice_id', $invoiceId) + ->delete(); + + return ['deleted_count' => $deleted]; + } + + /** + * 수동 세금계산서 등록 + */ + public function storeManual(array $data): HometaxInvoice + { + $tenantId = $this->tenantId(); + + return HometaxInvoice::create(array_merge($data, [ + 'tenant_id' => $tenantId, + ])); + } + + /** + * 수동 세금계산서 수정 + */ + public function updateManual(int $id, array $data): HometaxInvoice + { + $invoice = HometaxInvoice::where('tenant_id', $this->tenantId())->findOrFail($id); + $invoice->update($data); + + return $invoice->fresh(); + } + + /** + * 수동 세금계산서 삭제 (soft delete) + */ + public function destroyManual(int $id): bool + { + $invoice = HometaxInvoice::where('tenant_id', $this->tenantId())->findOrFail($id); + + return $invoice->delete(); + } + + /** + * 요약 통계 + */ + public function summary(array $params): array + { + $tenantId = $this->tenantId(); + $startDate = $params['start_date'] ?? now()->startOfMonth()->format('Y-m-d'); + $endDate = $params['end_date'] ?? now()->format('Y-m-d'); + + $salesQuery = HometaxInvoice::where('tenant_id', $tenantId) + ->sales() + ->period($startDate, $endDate); + + $purchaseQuery = HometaxInvoice::where('tenant_id', $tenantId) + ->purchase() + ->period($startDate, $endDate); + + return [ + 'sales' => [ + 'count' => (clone $salesQuery)->count(), + 'supply_amount' => (int) (clone $salesQuery)->sum('supply_amount'), + 'tax_amount' => (int) (clone $salesQuery)->sum('tax_amount'), + 'total_amount' => (int) (clone $salesQuery)->sum('total_amount'), + ], + 'purchase' => [ + 'count' => (clone $purchaseQuery)->count(), + 'supply_amount' => (int) (clone $purchaseQuery)->sum('supply_amount'), + 'tax_amount' => (int) (clone $purchaseQuery)->sum('tax_amount'), + 'total_amount' => (int) (clone $purchaseQuery)->sum('total_amount'), + ], + ]; + } + + /** + * 타입별 목록 조회 (공통) + */ + private function listByType(string $invoiceType, array $params): array + { + $tenantId = $this->tenantId(); + $startDate = $params['start_date'] ?? now()->startOfMonth()->format('Y-m-d'); + $endDate = $params['end_date'] ?? now()->format('Y-m-d'); + $search = $params['search'] ?? null; + $perPage = $params['per_page'] ?? 50; + + $query = HometaxInvoice::where('tenant_id', $tenantId) + ->where('invoice_type', $invoiceType) + ->period($startDate, $endDate); + + if ($search) { + $query->where(function ($q) use ($search, $invoiceType) { + if ($invoiceType === 'sales') { + $q->where('invoicee_corp_name', 'like', "%{$search}%") + ->orWhere('invoicee_corp_num', 'like', "%{$search}%"); + } else { + $q->where('invoicer_corp_name', 'like', "%{$search}%") + ->orWhere('invoicer_corp_num', 'like', "%{$search}%"); + } + $q->orWhere('nts_confirm_num', 'like', "%{$search}%") + ->orWhere('item_name', 'like', "%{$search}%"); + }); + } + + $query->orderByDesc('write_date')->orderByDesc('issue_date'); + + $invoices = $query->paginate($perPage); + + // 분개 존재 여부 로드 + $invoiceIds = $invoices->getCollection()->pluck('id')->toArray(); + $journaledIds = HometaxInvoiceJournal::getJournaledInvoiceIds($tenantId, $invoiceIds); + + $invoices->getCollection()->transform(function ($invoice) use ($journaledIds) { + $invoice->has_journal = in_array($invoice->id, $journaledIds); + + return $invoice; + }); + + return [ + 'data' => $invoices, + ]; + } +} diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index 77f1080..d4a580a 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -16,6 +16,8 @@ use App\Http\Controllers\Api\V1\BadDebtController; use App\Http\Controllers\Api\V1\BankAccountController; use App\Http\Controllers\Api\V1\BankTransactionController; +use App\Http\Controllers\Api\V1\BarobillBankTransactionController; +use App\Http\Controllers\Api\V1\BarobillCardTransactionController; use App\Http\Controllers\Api\V1\BarobillController; use App\Http\Controllers\Api\V1\BarobillSettingController; use App\Http\Controllers\Api\V1\BillController; @@ -28,6 +30,7 @@ use App\Http\Controllers\Api\V1\EntertainmentController; use App\Http\Controllers\Api\V1\ExpectedExpenseController; use App\Http\Controllers\Api\V1\GeneralJournalEntryController; +use App\Http\Controllers\Api\V1\HometaxInvoiceController; use App\Http\Controllers\Api\V1\LoanController; use App\Http\Controllers\Api\V1\PaymentController; use App\Http\Controllers\Api\V1\PayrollController; @@ -282,6 +285,60 @@ Route::get('/certificate-url', [BarobillController::class, 'certificateUrl'])->name('v1.barobill.certificate-url'); }); +// Barobill Card Transaction API (바로빌 카드 거래 - React 연동) +Route::prefix('barobill-card-transactions')->group(function () { + Route::get('', [BarobillCardTransactionController::class, 'index'])->name('v1.barobill-card-transactions.index'); + Route::get('/card-numbers', [BarobillCardTransactionController::class, 'cardNumbers'])->name('v1.barobill-card-transactions.card-numbers'); + Route::get('/hidden', [BarobillCardTransactionController::class, 'hiddenList'])->name('v1.barobill-card-transactions.hidden'); + Route::get('/splits', [BarobillCardTransactionController::class, 'getSplits'])->name('v1.barobill-card-transactions.splits.show'); + Route::post('/splits', [BarobillCardTransactionController::class, 'saveSplits'])->name('v1.barobill-card-transactions.splits.store'); + Route::delete('/splits', [BarobillCardTransactionController::class, 'deleteSplits'])->name('v1.barobill-card-transactions.splits.destroy'); + Route::post('/manual', [BarobillCardTransactionController::class, 'storeManual'])->name('v1.barobill-card-transactions.manual.store'); + Route::put('/manual/{id}', [BarobillCardTransactionController::class, 'updateManual'])->whereNumber('id')->name('v1.barobill-card-transactions.manual.update'); + Route::delete('/manual/{id}', [BarobillCardTransactionController::class, 'destroyManual'])->whereNumber('id')->name('v1.barobill-card-transactions.manual.destroy'); + Route::get('/{id}', [BarobillCardTransactionController::class, 'show'])->whereNumber('id')->name('v1.barobill-card-transactions.show'); + Route::post('/{id}/hide', [BarobillCardTransactionController::class, 'hide'])->whereNumber('id')->name('v1.barobill-card-transactions.hide'); + Route::post('/{id}/restore', [BarobillCardTransactionController::class, 'restore'])->whereNumber('id')->name('v1.barobill-card-transactions.restore'); + Route::put('/{id}/amount', [BarobillCardTransactionController::class, 'updateAmount'])->whereNumber('id')->name('v1.barobill-card-transactions.update-amount'); + Route::get('/{id}/journal-entries', [BarobillCardTransactionController::class, 'getJournalEntries'])->whereNumber('id')->name('v1.barobill-card-transactions.journal-entries.show'); + Route::post('/{id}/journal-entries', [BarobillCardTransactionController::class, 'storeJournalEntries'])->whereNumber('id')->name('v1.barobill-card-transactions.journal-entries.store'); + Route::delete('/{id}/journal-entries', [BarobillCardTransactionController::class, 'deleteJournalEntries'])->whereNumber('id')->name('v1.barobill-card-transactions.journal-entries.destroy'); +}); + +// Barobill Bank Transaction API (바로빌 은행 거래 - React 연동) +Route::prefix('barobill-bank-transactions')->group(function () { + Route::get('', [BarobillBankTransactionController::class, 'index'])->name('v1.barobill-bank-transactions.index'); + Route::get('/accounts', [BarobillBankTransactionController::class, 'accounts'])->name('v1.barobill-bank-transactions.accounts'); + Route::get('/balance-summary', [BarobillBankTransactionController::class, 'balanceSummary'])->name('v1.barobill-bank-transactions.balance-summary'); + Route::get('/splits', [BarobillBankTransactionController::class, 'getSplits'])->name('v1.barobill-bank-transactions.splits.show'); + Route::post('/splits', [BarobillBankTransactionController::class, 'saveSplits'])->name('v1.barobill-bank-transactions.splits.store'); + Route::delete('/splits', [BarobillBankTransactionController::class, 'deleteSplits'])->name('v1.barobill-bank-transactions.splits.destroy'); + Route::post('/override', [BarobillBankTransactionController::class, 'saveOverride'])->name('v1.barobill-bank-transactions.override'); + Route::post('/manual', [BarobillBankTransactionController::class, 'storeManual'])->name('v1.barobill-bank-transactions.manual.store'); + Route::put('/manual/{id}', [BarobillBankTransactionController::class, 'updateManual'])->whereNumber('id')->name('v1.barobill-bank-transactions.manual.update'); + Route::delete('/manual/{id}', [BarobillBankTransactionController::class, 'destroyManual'])->whereNumber('id')->name('v1.barobill-bank-transactions.manual.destroy'); + Route::get('/{id}/journal-entries', [BarobillBankTransactionController::class, 'getJournalEntries'])->whereNumber('id')->name('v1.barobill-bank-transactions.journal-entries.show'); + Route::post('/{id}/journal-entries', [BarobillBankTransactionController::class, 'storeJournalEntries'])->whereNumber('id')->name('v1.barobill-bank-transactions.journal-entries.store'); + Route::delete('/{id}/journal-entries', [BarobillBankTransactionController::class, 'deleteJournalEntries'])->whereNumber('id')->name('v1.barobill-bank-transactions.journal-entries.destroy'); +}); + +// Hometax Invoice API (홈택스 세금계산서 - React 연동) +Route::prefix('hometax-invoices')->group(function () { + Route::get('/sales', [HometaxInvoiceController::class, 'sales'])->name('v1.hometax-invoices.sales'); + Route::get('/purchases', [HometaxInvoiceController::class, 'purchases'])->name('v1.hometax-invoices.purchases'); + Route::get('/summary', [HometaxInvoiceController::class, 'summary'])->name('v1.hometax-invoices.summary'); + Route::post('', [HometaxInvoiceController::class, 'store'])->name('v1.hometax-invoices.store'); + Route::get('/{id}', [HometaxInvoiceController::class, 'show'])->whereNumber('id')->name('v1.hometax-invoices.show'); + Route::put('/{id}', [HometaxInvoiceController::class, 'update'])->whereNumber('id')->name('v1.hometax-invoices.update'); + Route::delete('/{id}', [HometaxInvoiceController::class, 'destroy'])->whereNumber('id')->name('v1.hometax-invoices.destroy'); + Route::get('/{id}/journals', [HometaxInvoiceController::class, 'getJournals'])->whereNumber('id')->name('v1.hometax-invoices.journals.show'); + Route::post('/{id}/journals', [HometaxInvoiceController::class, 'saveJournals'])->whereNumber('id')->name('v1.hometax-invoices.journals.store'); + Route::delete('/{id}/journals', [HometaxInvoiceController::class, 'deleteJournals'])->whereNumber('id')->name('v1.hometax-invoices.journals.destroy'); + Route::get('/{id}/journal-entries', [HometaxInvoiceController::class, 'getJournalEntries'])->whereNumber('id')->name('v1.hometax-invoices.journal-entries.show'); + Route::post('/{id}/journal-entries', [HometaxInvoiceController::class, 'storeJournalEntries'])->whereNumber('id')->name('v1.hometax-invoices.journal-entries.store'); + Route::delete('/{id}/journal-entries', [HometaxInvoiceController::class, 'deleteJournalEntries'])->whereNumber('id')->name('v1.hometax-invoices.journal-entries.destroy'); +}); + // Tax Invoice API (세금계산서) Route::prefix('tax-invoices')->group(function () { Route::get('', [TaxInvoiceController::class, 'index'])->name('v1.tax-invoices.index'); From 2c9e5ae2da8089353392fc4667ce25b9cb3a26be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Wed, 11 Mar 2026 21:32:45 +0900 Subject: [PATCH 144/166] =?UTF-8?q?feat:=20[receiving]=20=EC=9E=85?= =?UTF-8?q?=EA=B3=A0=20=EC=84=B1=EC=A0=81=EC=84=9C=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=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 - receivings 테이블에 certificate_file_id 컬럼 추가 (마이그레이션) - Receiving 모델에 certificateFile 관계 및 fillable/casts 추가 - Store/Update Request에 certificate_file_id 검증 규칙 추가 - ReceivingService index/show에 certificateFile eager loading 추가 - store/update 시 certificate_file_id 저장 처리 Co-Authored-By: Claude Opus 4.6 --- .../V1/Receiving/StoreReceivingRequest.php | 1 + .../V1/Receiving/UpdateReceivingRequest.php | 1 + app/Models/Tenants/Receiving.php | 10 ++++++ app/Services/ReceivingService.php | 14 +++++++-- ...ertificate_file_id_to_receivings_table.php | 31 +++++++++++++++++++ 5 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 database/migrations/2026_03_11_100000_add_certificate_file_id_to_receivings_table.php diff --git a/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php b/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php index 56904de..8b94a5f 100644 --- a/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php +++ b/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php @@ -31,6 +31,7 @@ public function rules(): array 'remark' => ['nullable', 'string', 'max:1000'], 'manufacturer' => ['nullable', 'string', 'max:100'], 'material_no' => ['nullable', 'string', 'max:50'], + 'certificate_file_id' => ['nullable', 'integer', 'exists:files,id'], ]; } diff --git a/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php b/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php index 4273062..b418e2f 100644 --- a/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php +++ b/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php @@ -33,6 +33,7 @@ public function rules(): array 'inspection_result' => ['nullable', 'string', 'max:20'], 'manufacturer' => ['nullable', 'string', 'max:100'], 'material_no' => ['nullable', 'string', 'max:50'], + 'certificate_file_id' => ['nullable', 'integer', 'exists:files,id'], ]; } diff --git a/app/Models/Tenants/Receiving.php b/app/Models/Tenants/Receiving.php index d5799e4..68cedf5 100644 --- a/app/Models/Tenants/Receiving.php +++ b/app/Models/Tenants/Receiving.php @@ -34,6 +34,7 @@ class Receiving extends Model 'status', 'remark', 'options', + 'certificate_file_id', 'created_by', 'updated_by', 'deleted_by', @@ -47,6 +48,7 @@ class Receiving extends Model 'receiving_qty' => 'decimal:2', 'item_id' => 'integer', 'options' => 'array', + 'certificate_file_id' => 'integer', ]; /** @@ -92,6 +94,14 @@ public function item(): BelongsTo return $this->belongsTo(\App\Models\Items\Item::class); } + /** + * 업체 제공 성적서 파일 관계 + */ + public function certificateFile(): BelongsTo + { + return $this->belongsTo(\App\Models\Commons\File::class, 'certificate_file_id'); + } + /** * 생성자 관계 */ diff --git a/app/Services/ReceivingService.php b/app/Services/ReceivingService.php index 6ecf6ff..2b2c78c 100644 --- a/app/Services/ReceivingService.php +++ b/app/Services/ReceivingService.php @@ -16,7 +16,7 @@ public function index(array $params): LengthAwarePaginator $tenantId = $this->tenantId(); $query = Receiving::query() - ->with(['creator:id,name', 'item:id,item_type,code,name']) + ->with(['creator:id,name', 'item:id,item_type,code,name', 'certificateFile:id,display_name,file_path']) ->where('tenant_id', $tenantId); // 검색어 필터 @@ -162,7 +162,7 @@ public function show(int $id): Receiving return Receiving::query() ->where('tenant_id', $tenantId) - ->with(['creator:id,name', 'item:id,item_type,code,name']) + ->with(['creator:id,name', 'item:id,item_type,code,name', 'certificateFile:id,display_name,file_path']) ->findOrFail($id); } @@ -203,6 +203,11 @@ public function store(array $data): Receiving $receiving->status = $data['status'] ?? 'receiving_pending'; $receiving->remark = $data['remark'] ?? null; + // 성적서 파일 ID + if (isset($data['certificate_file_id'])) { + $receiving->certificate_file_id = $data['certificate_file_id']; + } + // options 필드 처리 (제조사, 수입검사 등 확장 필드) $receiving->options = $this->buildOptions($data); @@ -299,6 +304,11 @@ public function update(int $id, array $data): Receiving } } + // 성적서 파일 ID + if (array_key_exists('certificate_file_id', $data)) { + $receiving->certificate_file_id = $data['certificate_file_id']; + } + // options 필드 업데이트 (제조사, 수입검사 등 확장 필드) $receiving->options = $this->mergeOptions($receiving->options, $data); diff --git a/database/migrations/2026_03_11_100000_add_certificate_file_id_to_receivings_table.php b/database/migrations/2026_03_11_100000_add_certificate_file_id_to_receivings_table.php new file mode 100644 index 0000000..719942b --- /dev/null +++ b/database/migrations/2026_03_11_100000_add_certificate_file_id_to_receivings_table.php @@ -0,0 +1,31 @@ +unsignedBigInteger('certificate_file_id') + ->nullable() + ->after('options') + ->comment('업체 제공 성적서 파일 ID'); + + $table->foreign('certificate_file_id') + ->references('id') + ->on('files') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('receivings', function (Blueprint $table) { + $table->dropForeign(['certificate_file_id']); + $table->dropColumn('certificate_file_id'); + }); + } +}; From 918ae0ebc1be1486a7bcec72063dad8d34dee277 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 12 Mar 2026 07:42:06 +0900 Subject: [PATCH 145/166] =?UTF-8?q?feat:=20[email]=20=ED=85=8C=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EB=A9=94=EC=9D=BC=20=EC=84=A4=EC=A0=95=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=B0=8F=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tenant_mail_configs 테이블 생성 (SMTP 설정, 브랜딩, 연결 테스트 결과) - mail_logs 테이블 생성 (발송 이력 추적) - TenantMailConfig, MailLog 모델 추가 (options JSON 정책 준수) --- app/Models/Tenants/MailLog.php | 47 ++++++++ app/Models/Tenants/TenantMailConfig.php | 103 ++++++++++++++++++ ...00000_create_tenant_mail_configs_table.php | 38 +++++++ ...26_03_12_100001_create_mail_logs_table.php | 34 ++++++ 4 files changed, 222 insertions(+) create mode 100644 app/Models/Tenants/MailLog.php create mode 100644 app/Models/Tenants/TenantMailConfig.php create mode 100644 database/migrations/2026_03_12_100000_create_tenant_mail_configs_table.php create mode 100644 database/migrations/2026_03_12_100001_create_mail_logs_table.php diff --git a/app/Models/Tenants/MailLog.php b/app/Models/Tenants/MailLog.php new file mode 100644 index 0000000..5bdde7f --- /dev/null +++ b/app/Models/Tenants/MailLog.php @@ -0,0 +1,47 @@ + 'datetime', + 'options' => 'array', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function getOption(string $key, mixed $default = null): mixed + { + return data_get($this->options, $key, $default); + } + + public function setOption(string $key, mixed $value): static + { + $options = $this->options ?? []; + data_set($options, $key, $value); + $this->options = $options; + + return $this; + } +} diff --git a/app/Models/Tenants/TenantMailConfig.php b/app/Models/Tenants/TenantMailConfig.php new file mode 100644 index 0000000..ec842a3 --- /dev/null +++ b/app/Models/Tenants/TenantMailConfig.php @@ -0,0 +1,103 @@ + 'boolean', + 'daily_limit' => 'integer', + 'is_active' => 'boolean', + 'options' => 'array', + ]; + + // Options 키 상수 + public const OPTION_SMTP = 'smtp'; + + public const OPTION_PRESET = 'preset'; + + public const OPTION_BRANDING = 'branding'; + + public const OPTION_CONNECTION_TEST = 'connection_test'; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function getOption(string $key, mixed $default = null): mixed + { + return data_get($this->options, $key, $default); + } + + public function setOption(string $key, mixed $value): static + { + $options = $this->options ?? []; + data_set($options, $key, $value); + $this->options = $options; + + return $this; + } + + public function getSmtpHost(): ?string + { + return $this->getOption('smtp.host'); + } + + public function getSmtpPort(): int + { + return (int) $this->getOption('smtp.port', 587); + } + + public function getSmtpEncryption(): string + { + return $this->getOption('smtp.encryption', 'tls'); + } + + public function getSmtpUsername(): ?string + { + return $this->getOption('smtp.username'); + } + + public function getSmtpPassword(): ?string + { + $encrypted = $this->getOption('smtp.password'); + if (! $encrypted) { + return null; + } + + try { + return decrypt($encrypted); + } catch (\Exception $e) { + return null; + } + } + + public function getPreset(): ?string + { + return $this->getOption('preset'); + } +} diff --git a/database/migrations/2026_03_12_100000_create_tenant_mail_configs_table.php b/database/migrations/2026_03_12_100000_create_tenant_mail_configs_table.php new file mode 100644 index 0000000..c1f40ce --- /dev/null +++ b/database/migrations/2026_03_12_100000_create_tenant_mail_configs_table.php @@ -0,0 +1,38 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->string('provider', 20)->default('platform')->comment('발송 방식: platform, smtp, ses, mailgun'); + $table->string('from_address', 255)->comment('발신 이메일'); + $table->string('from_name', 255)->comment('발신자명'); + $table->string('reply_to', 255)->nullable()->comment('회신 주소'); + $table->boolean('is_verified')->default(false)->comment('도메인 검증 여부'); + $table->unsignedInteger('daily_limit')->default(500)->comment('일일 발송 한도'); + $table->boolean('is_active')->default(true)->comment('활성 여부'); + $table->json('options')->nullable()->comment('SMTP 설정, 브랜딩, 연결 테스트 결과'); + $table->unsignedBigInteger('created_by')->nullable()->comment('생성자'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자'); + $table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자'); + $table->timestamps(); + $table->softDeletes(); + + $table->unique('tenant_id', 'uq_tenant_mail_configs'); + $table->index(['tenant_id', 'is_active']); + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('tenant_mail_configs'); + } +}; diff --git a/database/migrations/2026_03_12_100001_create_mail_logs_table.php b/database/migrations/2026_03_12_100001_create_mail_logs_table.php new file mode 100644 index 0000000..a9a1aa7 --- /dev/null +++ b/database/migrations/2026_03_12_100001_create_mail_logs_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->string('mailable_type', 100)->comment('Mailable 클래스명'); + $table->string('to_address', 255)->comment('수신자'); + $table->string('from_address', 255)->comment('발신자'); + $table->string('subject', 500)->comment('제목'); + $table->string('status', 20)->default('queued')->comment('상태: queued, sent, failed, bounced'); + $table->timestamp('sent_at')->nullable()->comment('발송 시각'); + $table->json('options')->nullable()->comment('에러 메시지, 재시도 횟수, 관련 모델'); + $table->timestamps(); + + $table->index(['tenant_id', 'status'], 'idx_mail_logs_tenant_status'); + $table->index(['tenant_id', 'created_at'], 'idx_mail_logs_tenant_date'); + $table->index(['tenant_id', 'mailable_type'], 'idx_mail_logs_mailable'); + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('mail_logs'); + } +}; From f0b1b5e33acb4ad3cb92b22431dfc4ffaa1929ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 11 Mar 2026 09:17:56 +0900 Subject: [PATCH 146/166] =?UTF-8?q?feat:=20[=EB=B0=B0=ED=8F=AC]=20Jenkinsf?= =?UTF-8?q?ile=20=EB=A1=A4=EB=B0=B1=20=EA=B8=B0=EB=8A=A5=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 - parameters 블록 추가 (ACTION, ROLLBACK_TARGET, ROLLBACK_RELEASE) - Jenkins 웹에서 Build with Parameters로 롤백 실행 가능 - 릴리스 목록 조회 + symlink 전환 + 캐시 재생성 - production/stage 환경 선택 가능 - 서버 IP를 PROD_SERVER 환경변수로 추출 - 롤백 시 Slack 알림 추가 Co-Authored-By: Claude Opus 4.6 --- Jenkinsfile | 130 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 109 insertions(+), 21 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index cbbe5e7..6f98d9d 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,12 @@ pipeline { agent any + parameters { + choice(name: 'ACTION', choices: ['deploy', 'rollback'], description: '배포 또는 롤백') + choice(name: 'ROLLBACK_TARGET', choices: ['production', 'stage'], description: '롤백 대상 환경') + string(name: 'ROLLBACK_RELEASE', defaultValue: '', description: '롤백할 릴리스 ID (예: 20260310_120000). 비워두면 직전 릴리스로 롤백') + } + options { disableConcurrentBuilds() } @@ -8,10 +14,73 @@ pipeline { environment { DEPLOY_USER = 'hskwon' RELEASE_ID = new Date().format('yyyyMMdd_HHmmss') + PROD_SERVER = '211.117.60.189' } stages { + + // ── 롤백: 릴리스 목록 조회 ── + stage('Rollback: List Releases') { + when { expression { params.ACTION == 'rollback' } } + steps { + script { + def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/api' : '/home/webservice/api-stage' + sshagent(credentials: ['deploy-ssh-key']) { + def releases = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | head -6 | xargs -I{} basename {}'", returnStdout: true).trim() + def current = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'basename \$(readlink -f ${basePath}/current)'", returnStdout: true).trim() + echo "=== ${params.ROLLBACK_TARGET} 릴리스 목록 ===" + echo "현재 활성: ${current}" + echo "사용 가능:\n${releases}" + } + } + } + } + + // ── 롤백: symlink 전환 ── + stage('Rollback: Switch Release') { + when { expression { params.ACTION == 'rollback' } } + steps { + script { + def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/api' : '/home/webservice/api-stage' + + sshagent(credentials: ['deploy-ssh-key']) { + def targetRelease = params.ROLLBACK_RELEASE + if (!targetRelease?.trim()) { + // 비워두면 직전 릴리스로 롤백 + targetRelease = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | sed -n 2p | xargs basename'", returnStdout: true).trim() + } + + // 릴리스 존재 여부 확인 + sh "ssh ${DEPLOY_USER}@${PROD_SERVER} 'test -d ${basePath}/releases/${targetRelease}'" + + slackSend channel: '#deploy_api', color: '#FF9800', tokenCredentialId: 'slack-token', + message: "🔄 *api* ${params.ROLLBACK_TARGET} 롤백 시작 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" + + sh """ + ssh ${DEPLOY_USER}@${PROD_SERVER} ' + ln -sfn ${basePath}/releases/${targetRelease} ${basePath}/current && + cd ${basePath}/current && + php artisan config:cache && + php artisan route:cache && + php artisan view:cache && + sudo systemctl reload php8.4-fpm + ' + """ + + if (params.ROLLBACK_TARGET == 'production') { + sh "ssh ${DEPLOY_USER}@${PROD_SERVER} 'sudo supervisorctl restart sam-queue-worker:*'" + } + + slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token', + message: "✅ *api* ${params.ROLLBACK_TARGET} 롤백 완료 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" + } + } + } + } + + // ── 일반 배포: Checkout ── stage('Checkout') { + when { expression { params.ACTION == 'deploy' } } steps { checkout scm script { @@ -24,17 +93,22 @@ pipeline { // ── main → 운영서버 Stage 배포 ── stage('Deploy Stage') { - when { branch 'main' } + when { + allOf { + branch 'main' + expression { params.ACTION == 'deploy' } + } + } steps { sshagent(credentials: ['deploy-ssh-key']) { sh """ - ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api-stage/releases/${RELEASE_ID}' + ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/api-stage/releases/${RELEASE_ID}' rsync -az --delete \ --exclude='.git' --exclude='.env' \ --exclude='storage/app' --exclude='storage/logs' \ --exclude='storage/framework/sessions' --exclude='storage/framework/cache' \ - . ${DEPLOY_USER}@211.117.60.189:/home/webservice/api-stage/releases/${RELEASE_ID}/ - ssh ${DEPLOY_USER}@211.117.60.189 ' + . ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/api-stage/releases/${RELEASE_ID}/ + ssh ${DEPLOY_USER}@${PROD_SERVER} ' cd /home/webservice/api-stage/releases/${RELEASE_ID} && mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs && sudo chown -R www-data:webservice storage bootstrap/cache && @@ -71,17 +145,22 @@ pipeline { // ── main → 운영서버 Production 배포 ── stage('Deploy Production') { - when { branch 'main' } + when { + allOf { + branch 'main' + expression { params.ACTION == 'deploy' } + } + } steps { sshagent(credentials: ['deploy-ssh-key']) { sh """ - ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api/releases/${RELEASE_ID}' + ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/api/releases/${RELEASE_ID}' rsync -az --delete \ --exclude='.git' --exclude='.env' \ --exclude='storage/app' --exclude='storage/logs' \ --exclude='storage/framework/sessions' --exclude='storage/framework/cache' \ - . ${DEPLOY_USER}@211.117.60.189:/home/webservice/api/releases/${RELEASE_ID}/ - ssh ${DEPLOY_USER}@211.117.60.189 ' + . ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/api/releases/${RELEASE_ID}/ + ssh ${DEPLOY_USER}@${PROD_SERVER} ' cd /home/webservice/api/releases/${RELEASE_ID} && mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs && sudo chown -R www-data:webservice storage bootstrap/cache && @@ -109,23 +188,32 @@ pipeline { post { success { - slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token', - message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" + script { + if (params.ACTION == 'deploy') { + slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token', + message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" + } + } } failure { - slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token', - message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" script { - if (env.BRANCH_NAME == 'main') { - sshagent(credentials: ['deploy-ssh-key']) { - sh """ - ssh ${DEPLOY_USER}@211.117.60.189 ' - PREV=\$(ls -1dt /home/webservice/api/releases/*/ | sed -n "2p" | xargs basename 2>/dev/null) && - [ -n "\$PREV" ] && ln -sfn /home/webservice/api/releases/\$PREV /home/webservice/api/current && - sudo systemctl reload php8.4-fpm - ' || true - """ + if (params.ACTION == 'deploy') { + slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token', + message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" + if (env.BRANCH_NAME == 'main') { + sshagent(credentials: ['deploy-ssh-key']) { + sh """ + ssh ${DEPLOY_USER}@${PROD_SERVER} ' + PREV=\$(ls -1dt /home/webservice/api/releases/*/ | sed -n "2p" | xargs basename 2>/dev/null) && + [ -n "\$PREV" ] && ln -sfn /home/webservice/api/releases/\$PREV /home/webservice/api/current && + sudo systemctl reload php8.4-fpm + ' || true + """ + } } + } else { + slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token', + message: "❌ *api* ${params.ROLLBACK_TARGET} 롤백 실패\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" } } } From b55cbc2ec45e81ddab890d3558915994e117b3b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 11 Mar 2026 16:54:40 +0900 Subject: [PATCH 147/166] =?UTF-8?q?feat:=20[=EA=B2=AC=EC=A0=81]=20?= =?UTF-8?q?=EC=A0=9C=EC=96=B4=EA=B8=B0=20=ED=83=80=EC=9E=85=20=EC=B2=B4?= =?UTF-8?q?=EA=B3=84=20=EB=B3=80=EA=B2=BD=20(basic/smart/premium=20?= =?UTF-8?q?=E2=86=92=20exposed/embedded/embedded=5Fno=5Fbox)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QuoteBomBulkCalculateRequest: controller validation 값 변경, 기본값 exposed - QuoteBomCalculateRequest: 동일 변경 - FormulaEvaluatorService: CT → controller_type 매핑 추가 (exposed→노출형, embedded→매립형) - FormulaEvaluatorService: CT 값에 따라 backbox_qty 자동 설정 (embedded만 뒷박스 포함) - QuoteService: CT 기본값 exposed로 변경 Co-Authored-By: Claude Opus 4.6 --- .../Quote/QuoteBomBulkCalculateRequest.php | 6 +++--- .../Quote/QuoteBomCalculateRequest.php | 4 ++-- .../Quote/FormulaEvaluatorService.php | 20 ++++++++++++++++++- app/Services/Quote/QuoteService.php | 2 +- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/app/Http/Requests/Quote/QuoteBomBulkCalculateRequest.php b/app/Http/Requests/Quote/QuoteBomBulkCalculateRequest.php index a4daae7..6742b7e 100644 --- a/app/Http/Requests/Quote/QuoteBomBulkCalculateRequest.php +++ b/app/Http/Requests/Quote/QuoteBomBulkCalculateRequest.php @@ -34,7 +34,7 @@ public function rules(): array 'items.*.productCategory' => 'nullable|string|in:SCREEN,STEEL', 'items.*.guideRailType' => 'nullable|string|in:wall,ceiling,floor,mixed', 'items.*.motorPower' => 'nullable|string|in:single,three', - 'items.*.controller' => 'nullable|string|in:basic,smart,premium', + 'items.*.controller' => 'nullable|string|in:exposed,embedded,embedded_no_box', 'items.*.wingSize' => 'nullable|numeric|min:0|max:500', 'items.*.inspectionFee' => 'nullable|numeric|min:0', @@ -45,7 +45,7 @@ public function rules(): array 'items.*.PC' => 'nullable|string|in:SCREEN,STEEL', 'items.*.GT' => 'nullable|string|in:wall,ceiling,floor,mixed', 'items.*.MP' => 'nullable|string|in:single,three', - 'items.*.CT' => 'nullable|string|in:basic,smart,premium', + 'items.*.CT' => 'nullable|string|in:exposed,embedded,embedded_no_box', 'items.*.WS' => 'nullable|numeric|min:0|max:500', 'items.*.INSP' => 'nullable|numeric|min:0', @@ -128,7 +128,7 @@ private function normalizeInputVariables(array $item): array 'PC' => $item['productCategory'] ?? $item['PC'] ?? 'SCREEN', 'GT' => $item['guideRailType'] ?? $item['GT'] ?? 'wall', 'MP' => $item['motorPower'] ?? $item['MP'] ?? 'single', - 'CT' => $item['controller'] ?? $item['CT'] ?? 'basic', + 'CT' => $item['controller'] ?? $item['CT'] ?? 'exposed', 'WS' => (float) ($item['wingSize'] ?? $item['WS'] ?? 50), 'INSP' => (float) ($item['inspectionFee'] ?? $item['INSP'] ?? 50000), ]; diff --git a/app/Http/Requests/Quote/QuoteBomCalculateRequest.php b/app/Http/Requests/Quote/QuoteBomCalculateRequest.php index 5aa1b99..24790e7 100644 --- a/app/Http/Requests/Quote/QuoteBomCalculateRequest.php +++ b/app/Http/Requests/Quote/QuoteBomCalculateRequest.php @@ -30,7 +30,7 @@ public function rules(): array 'PC' => 'nullable|string|in:SCREEN,STEEL', 'GT' => 'nullable|string|in:wall,ceiling,floor,mixed', 'MP' => 'nullable|string|in:single,three', - 'CT' => 'nullable|string|in:basic,smart,premium', + 'CT' => 'nullable|string|in:exposed,embedded,embedded_no_box', 'WS' => 'nullable|numeric|min:0|max:500', 'INSP' => 'nullable|numeric|min:0', @@ -82,7 +82,7 @@ public function getInputVariables(): array 'PC' => $validated['PC'] ?? 'SCREEN', 'GT' => $validated['GT'] ?? 'wall', 'MP' => $validated['MP'] ?? 'single', - 'CT' => $validated['CT'] ?? 'basic', + 'CT' => $validated['CT'] ?? 'exposed', 'WS' => (float) ($validated['WS'] ?? 50), 'INSP' => (float) ($validated['INSP'] ?? 50000), ]; diff --git a/app/Services/Quote/FormulaEvaluatorService.php b/app/Services/Quote/FormulaEvaluatorService.php index 3720f3a..0659156 100644 --- a/app/Services/Quote/FormulaEvaluatorService.php +++ b/app/Services/Quote/FormulaEvaluatorService.php @@ -633,7 +633,7 @@ public function calculateBomWithDebug( 'PC' => $inputVariables['PC'] ?? '', 'GT' => $inputVariables['GT'] ?? 'wall', 'MP' => $inputVariables['MP'] ?? 'single', - 'CT' => $inputVariables['CT'] ?? 'basic', + 'CT' => $inputVariables['CT'] ?? 'exposed', 'WS' => $inputVariables['WS'] ?? 50, 'INSP' => $inputVariables['INSP'] ?? 50000, 'finished_goods' => $finishedGoodsCode, @@ -1708,6 +1708,22 @@ private function calculateTenantBom( default => '220V', }; + // 제어기 타입: 프론트 CT(exposed/embedded/embedded_no_box) → controller_type(노출형/매립형) 매핑 + // - exposed: 노출형 (뒷박스 불필요) + // - embedded: 매립형 (뒷박스 포함) + // - embedded_no_box: 매립형 (뒷박스 제외 — 업체 자체 보유) + $ctValue = $inputVariables['CT'] ?? 'exposed'; + $controllerType = $inputVariables['controller_type'] ?? match ($ctValue) { + 'embedded', 'embedded_no_box' => '매립형', + 'exposed' => '노출형', + default => '노출형', + }; + // 뒷박스: embedded만 포함, exposed/embedded_no_box는 제외 + $backboxQty = (int) ($inputVariables['backbox_qty'] ?? match ($ctValue) { + 'embedded' => 1, + default => 0, + }); + $calculatedVariables = array_merge($inputVariables, [ 'W0' => $W0, 'H0' => $H0, @@ -1724,6 +1740,8 @@ private function calculateTenantBom( 'finishing_type' => $finishingType, 'installation_type' => $installationType, 'motor_voltage' => $motorVoltage, + 'controller_type' => $controllerType, + 'backbox_qty' => $backboxQty, ]); $this->addDebugStep(3, '변수계산', [ diff --git a/app/Services/Quote/QuoteService.php b/app/Services/Quote/QuoteService.php index b42cc01..9dc55bb 100644 --- a/app/Services/Quote/QuoteService.php +++ b/app/Services/Quote/QuoteService.php @@ -237,7 +237,7 @@ private function calculateBomMaterials(Quote $quote): array 'PC' => $input['productCategory'] ?? 'SCREEN', 'GT' => $input['guideRailType'] ?? 'wall', 'MP' => $input['motorPower'] ?? 'single', - 'CT' => $input['controller'] ?? 'basic', + 'CT' => $input['controller'] ?? 'exposed', 'WS' => (float) ($input['wingSize'] ?? 50), 'INSP' => (float) ($input['inspectionFee'] ?? 50000), ]; From 3bae303447aa4a2db3dfb9d5eeed49b94052c7ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 11 Mar 2026 17:35:24 +0900 Subject: [PATCH 148/166] =?UTF-8?q?fix:=20[=EC=9E=91=EC=97=85=EC=A7=80?= =?UTF-8?q?=EC=8B=9C]=20syncOrderStatus=20=EC=A7=91=EA=B3=84=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=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 - 기존: 단일 작업지시 상태만 보고 수주 상태 매핑 (첫 WO 완료 시 즉시 PRODUCED) - 변경: 수주의 모든 비보조 작업지시 상태를 집계하여 결정 - 전부 shipped → SHIPPED - 전부 completed/shipped → PRODUCED - 하나라도 진행중/완료/출하 → IN_PRODUCTION - 감사 로그에 집계 내역(work_order_counts) 포함 --- app/Services/WorkOrderService.php | 47 +++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index ba39c09..bbb5ed0 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -836,14 +836,38 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void return; } - // 작업지시 상태 → 수주 상태 매핑 - $statusMap = [ - WorkOrder::STATUS_IN_PROGRESS => Order::STATUS_IN_PRODUCTION, - WorkOrder::STATUS_COMPLETED => Order::STATUS_PRODUCED, - WorkOrder::STATUS_SHIPPED => Order::STATUS_SHIPPED, - ]; + // 해당 수주의 모든 비보조 작업지시 상태 집계 + $allWorkOrders = WorkOrder::where('tenant_id', $tenantId) + ->where('sales_order_id', $workOrder->sales_order_id) + ->where('status', '!=', WorkOrder::STATUS_CANCELLED) + ->get(); - $newOrderStatus = $statusMap[$workOrder->status] ?? null; + // 보조 공정 제외 + $mainWorkOrders = $allWorkOrders->filter(fn ($wo) => ! $this->isAuxiliaryWorkOrder($wo)); + + if ($mainWorkOrders->isEmpty()) { + return; + } + + $totalCount = $mainWorkOrders->count(); + $statusCounts = $mainWorkOrders->groupBy('status')->map->count(); + + $shippedCount = $statusCounts->get(WorkOrder::STATUS_SHIPPED, 0); + $completedCount = $statusCounts->get(WorkOrder::STATUS_COMPLETED, 0); + $inProgressCount = $statusCounts->get(WorkOrder::STATUS_IN_PROGRESS, 0); + + // 집계 기반 수주 상태 결정 + // 전부 출하 → SHIPPED + // 전부 완료(또는 완료+출하) → PRODUCED + // 하나라도 진행중/완료/출하 → IN_PRODUCTION + $newOrderStatus = null; + if ($shippedCount === $totalCount) { + $newOrderStatus = Order::STATUS_SHIPPED; + } elseif (($completedCount + $shippedCount) === $totalCount) { + $newOrderStatus = Order::STATUS_PRODUCED; + } elseif ($inProgressCount > 0 || $completedCount > 0 || $shippedCount > 0) { + $newOrderStatus = Order::STATUS_IN_PRODUCTION; + } // 매핑되는 상태가 없거나 이미 동일한 상태면 스킵 if (! $newOrderStatus || $order->status_code === $newOrderStatus) { @@ -861,8 +885,13 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void 'order', $order->id, 'status_synced_from_work_order', - ['status_code' => $oldOrderStatus, 'work_order_id' => $workOrder->id], - ['status_code' => $newOrderStatus, 'work_order_id' => $workOrder->id] + ['status_code' => $oldOrderStatus, 'work_order_id' => $workOrder->id, 'aggregated' => true], + ['status_code' => $newOrderStatus, 'work_order_counts' => [ + 'total' => $totalCount, + 'shipped' => $shippedCount, + 'completed' => $completedCount, + 'in_progress' => $inProgressCount, + ]] ); } From 12373edf8ce92f7dafb164e9eb73e52a1fccd1ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 11 Mar 2026 20:04:29 +0900 Subject: [PATCH 149/166] =?UTF-8?q?feat:=20[QMS]=20=EC=A0=90=EA=B2=80?= =?UTF-8?q?=ED=91=9C=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - checklist_templates 테이블 마이그레이션 + 기본 시딩 - ChecklistTemplate 모델 (BelongsToTenant, Auditable, SoftDeletes) - ChecklistTemplateService: 조회/저장/파일 업로드/삭제 - SaveChecklistTemplateRequest: 중첩 JSON 검증 - ChecklistTemplateController: 5개 엔드포인트 - 라우트 등록 (quality/checklist-templates, quality/qms-documents) --- .../Api/V1/ChecklistTemplateController.php | 82 +++++++ .../Quality/SaveChecklistTemplateRequest.php | 40 ++++ app/Models/Qualitys/ChecklistTemplate.php | 76 +++++++ app/Services/ChecklistTemplateService.php | 214 ++++++++++++++++++ ...74640_create_checklist_templates_table.php | 110 +++++++++ routes/api/v1/quality.php | 14 ++ 6 files changed, 536 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/ChecklistTemplateController.php create mode 100644 app/Http/Requests/Quality/SaveChecklistTemplateRequest.php create mode 100644 app/Models/Qualitys/ChecklistTemplate.php create mode 100644 app/Services/ChecklistTemplateService.php create mode 100644 database/migrations/2026_03_11_174640_create_checklist_templates_table.php diff --git a/app/Http/Controllers/Api/V1/ChecklistTemplateController.php b/app/Http/Controllers/Api/V1/ChecklistTemplateController.php new file mode 100644 index 0000000..a749df7 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ChecklistTemplateController.php @@ -0,0 +1,82 @@ +query('type', 'day1_audit'); + + return $this->service->getByType($type); + }, __('message.fetched')); + } + + /** + * 템플릿 저장 (전체 덮어쓰기) + */ + public function update(SaveChecklistTemplateRequest $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->save($id, $request->validated()); + }, __('message.updated')); + } + + /** + * 항목별 파일 목록 조회 + */ + public function documents(Request $request) + { + return ApiResponse::handle(function () use ($request) { + $templateId = (int) $request->query('template_id'); + $subItemId = $request->query('sub_item_id'); + + return $this->service->getDocuments($templateId, $subItemId); + }, __('message.fetched')); + } + + /** + * 파일 업로드 + */ + public function uploadDocument(Request $request) + { + $request->validate([ + 'template_id' => ['required', 'integer'], + 'sub_item_id' => ['required', 'string', 'max:50'], + 'file' => ['required', 'file', 'max:10240'], // 10MB + ]); + + return ApiResponse::handle(function () use ($request) { + return $this->service->uploadDocument( + (int) $request->input('template_id'), + $request->input('sub_item_id'), + $request->file('file') + ); + }, __('message.created')); + } + + /** + * 파일 삭제 + */ + public function deleteDocument(int $id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + $replace = filter_var($request->query('replace', false), FILTER_VALIDATE_BOOLEAN); + $this->service->deleteDocument($id, $replace); + + return 'success'; + }, __('message.deleted')); + } +} diff --git a/app/Http/Requests/Quality/SaveChecklistTemplateRequest.php b/app/Http/Requests/Quality/SaveChecklistTemplateRequest.php new file mode 100644 index 0000000..a37c696 --- /dev/null +++ b/app/Http/Requests/Quality/SaveChecklistTemplateRequest.php @@ -0,0 +1,40 @@ + ['nullable', 'string', 'max:255'], + 'categories' => ['required', 'array', 'min:1'], + 'categories.*.id' => ['required', 'string', 'max:50'], + 'categories.*.title' => ['required', 'string', 'max:255'], + 'categories.*.subItems' => ['required', 'array'], + 'categories.*.subItems.*.id' => ['required', 'string', 'max:50'], + 'categories.*.subItems.*.name' => ['required', 'string', 'max:255'], + 'options' => ['nullable', 'array'], + ]; + } + + public function messages(): array + { + return [ + 'categories.required' => __('validation.required', ['attribute' => '카테고리']), + 'categories.min' => __('validation.min.array', ['attribute' => '카테고리', 'min' => 1]), + 'categories.*.id.required' => __('validation.required', ['attribute' => '카테고리 ID']), + 'categories.*.title.required' => __('validation.required', ['attribute' => '카테고리 제목']), + 'categories.*.subItems.required' => __('validation.required', ['attribute' => '점검항목']), + 'categories.*.subItems.*.id.required' => __('validation.required', ['attribute' => '항목 ID']), + 'categories.*.subItems.*.name.required' => __('validation.required', ['attribute' => '항목명']), + ]; + } +} diff --git a/app/Models/Qualitys/ChecklistTemplate.php b/app/Models/Qualitys/ChecklistTemplate.php new file mode 100644 index 0000000..3201593 --- /dev/null +++ b/app/Models/Qualitys/ChecklistTemplate.php @@ -0,0 +1,76 @@ + 'array', + 'options' => 'array', + ]; + + public function creator(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'created_by'); + } + + public function updater(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'updated_by'); + } + + /** + * 점검항목별 연결 파일 (files 테이블 polymorphic) + * document_type = 'checklist_template', document_id = this.id + * field_key = sub_item_id (e.g. 'cat-1-1') + */ + public function documents(): MorphMany + { + return $this->morphMany(File::class, 'document', 'document_type', 'document_id'); + } + + /** + * 특정 항목의 파일 조회 + */ + public function documentsForItem(string $subItemId) + { + return $this->documents()->where('field_key', $subItemId); + } + + /** + * categories JSON에서 모든 sub_item_id 추출 + */ + public function getAllSubItemIds(): array + { + $ids = []; + foreach ($this->categories ?? [] as $category) { + foreach ($category['subItems'] ?? [] as $subItem) { + $ids[] = $subItem['id']; + } + } + + return $ids; + } +} diff --git a/app/Services/ChecklistTemplateService.php b/app/Services/ChecklistTemplateService.php new file mode 100644 index 0000000..a0ca96e --- /dev/null +++ b/app/Services/ChecklistTemplateService.php @@ -0,0 +1,214 @@ +where('type', $type) + ->first(); + + if (! $template) { + throw new NotFoundHttpException(__('error.not_found')); + } + + // 각 항목별 파일 수 포함 + $fileCounts = File::query() + ->where('document_type', self::DOCUMENT_TYPE) + ->where('document_id', $template->id) + ->whereNull('deleted_at') + ->selectRaw('field_key, COUNT(*) as count') + ->groupBy('field_key') + ->pluck('count', 'field_key') + ->toArray(); + + return [ + 'id' => $template->id, + 'name' => $template->name, + 'type' => $template->type, + 'categories' => $template->categories, + 'options' => $template->options, + 'file_counts' => $fileCounts, + 'updated_at' => $template->updated_at?->toIso8601String(), + 'updated_by' => $template->updater?->name, + ]; + } + + /** + * 템플릿 저장 (전체 덮어쓰기) + */ + public function save(int $id, array $data): array + { + $template = ChecklistTemplate::findOrFail($id); + $before = $template->toArray(); + + // 삭제된 항목의 파일 처리 + $oldSubItemIds = $template->getAllSubItemIds(); + $newSubItemIds = $this->extractSubItemIds($data['categories']); + $removedIds = array_diff($oldSubItemIds, $newSubItemIds); + + DB::transaction(function () use ($template, $data, $removedIds) { + // 템플릿 업데이트 + $template->update([ + 'name' => $data['name'] ?? $template->name, + 'categories' => $data['categories'], + 'options' => $data['options'] ?? $template->options, + 'updated_by' => $this->apiUserId(), + ]); + + // 삭제된 항목의 파일 → soft delete + if (! empty($removedIds)) { + $orphanFiles = File::query() + ->where('document_type', self::DOCUMENT_TYPE) + ->where('document_id', $template->id) + ->whereIn('field_key', $removedIds) + ->get(); + + foreach ($orphanFiles as $file) { + $file->softDeleteFile($this->apiUserId()); + } + } + }); + + $template->refresh(); + + $this->auditLogger->log( + self::AUDIT_TARGET, + $template->id, + 'updated', + $before, + $template->toArray(), + $this->apiUserId() + ); + + return $this->getByType($template->type); + } + + /** + * 항목별 파일 목록 조회 + */ + public function getDocuments(int $templateId, ?string $subItemId = null): array + { + $query = File::query() + ->where('document_type', self::DOCUMENT_TYPE) + ->where('document_id', $templateId) + ->with('uploader:id,name'); + + if ($subItemId) { + $query->where('field_key', $subItemId); + } + + $files = $query->orderBy('field_key')->orderByDesc('id')->get(); + + return $files->map(fn (File $file) => [ + 'id' => $file->id, + 'field_key' => $file->field_key, + 'display_name' => $file->display_name ?? $file->original_name, + 'file_size' => $file->file_size, + 'mime_type' => $file->mime_type, + 'uploaded_by' => $file->uploader?->name, + 'created_at' => $file->created_at?->toIso8601String(), + ])->toArray(); + } + + /** + * 파일 업로드 (polymorphic) + */ + public function uploadDocument(int $templateId, string $subItemId, $uploadedFile): array + { + $template = ChecklistTemplate::findOrFail($templateId); + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 저장 경로: {tenant_id}/checklist-templates/{year}/{month}/{stored_name} + $date = now(); + $storedName = bin2hex(random_bytes(16)).'.'.$uploadedFile->getClientOriginalExtension(); + $filePath = sprintf( + '%d/checklist-templates/%s/%s/%s', + $tenantId, + $date->format('Y'), + $date->format('m'), + $storedName + ); + + // 파일 저장 + Storage::disk('tenant')->put($filePath, file_get_contents($uploadedFile->getPathname())); + + // DB 레코드 생성 + $file = File::create([ + 'tenant_id' => $tenantId, + 'document_type' => self::DOCUMENT_TYPE, + 'document_id' => $template->id, + 'field_key' => $subItemId, + 'display_name' => $uploadedFile->getClientOriginalName(), + 'stored_name' => $storedName, + 'file_path' => $filePath, + 'file_size' => $uploadedFile->getSize(), + 'mime_type' => $uploadedFile->getClientMimeType(), + 'uploaded_by' => $userId, + 'created_by' => $userId, + ]); + + return [ + 'id' => $file->id, + 'field_key' => $file->field_key, + 'display_name' => $file->display_name, + 'file_size' => $file->file_size, + 'mime_type' => $file->mime_type, + 'created_at' => $file->created_at?->toIso8601String(), + ]; + } + + /** + * 파일 삭제 + * - 교체(replace=true): hard delete (물리 파일 + DB) + * - 일반 삭제: soft delete (휴지통) + */ + public function deleteDocument(int $fileId, bool $replace = false): void + { + $file = File::query() + ->where('document_type', self::DOCUMENT_TYPE) + ->findOrFail($fileId); + + if ($replace) { + $file->permanentDelete(); + } else { + $file->softDeleteFile($this->apiUserId()); + } + } + + /** + * categories JSON에서 sub_item_id 목록 추출 + */ + private function extractSubItemIds(array $categories): array + { + $ids = []; + foreach ($categories as $category) { + foreach ($category['subItems'] ?? [] as $subItem) { + $ids[] = $subItem['id']; + } + } + + return $ids; + } +} diff --git a/database/migrations/2026_03_11_174640_create_checklist_templates_table.php b/database/migrations/2026_03_11_174640_create_checklist_templates_table.php new file mode 100644 index 0000000..881f58d --- /dev/null +++ b/database/migrations/2026_03_11_174640_create_checklist_templates_table.php @@ -0,0 +1,110 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트ID'); + $table->string('name', 255)->default('품질인정심사 점검표')->comment('템플릿명'); + $table->string('type', 50)->default('day1_audit')->comment('심사유형: day1_audit, day2_lot 등'); + $table->json('categories')->comment('카테고리/항목 JSON'); + $table->json('options')->nullable()->comment('확장 속성'); + $table->unsignedBigInteger('created_by')->nullable()->comment('생성자'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자'); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['tenant_id', 'type'], 'uq_checklist_templates_tenant_type'); + $table->index(['tenant_id', 'type'], 'idx_checklist_templates_tenant_type'); + $table->foreign('tenant_id')->references('id')->on('tenants'); + $table->foreign('created_by')->references('id')->on('users')->onDelete('set null'); + $table->foreign('updated_by')->references('id')->on('users')->onDelete('set null'); + }); + + // 기존 테넌트에 기본 템플릿 시딩 + $this->seedDefaultTemplates(); + } + + public function down(): void + { + Schema::dropIfExists('checklist_templates'); + } + + private function seedDefaultTemplates(): void + { + $defaultCategories = json_encode([ + [ + 'id' => 'cat-1', + 'title' => '원재료 품질관리 기준', + 'subItems' => [ + ['id' => 'cat-1-1', 'name' => '수입검사 기준 확인'], + ['id' => 'cat-1-2', 'name' => '불합격품 처리 기준 확인'], + ['id' => 'cat-1-3', 'name' => '자재 보관 기준 확인'], + ], + ], + [ + 'id' => 'cat-2', + 'title' => '제조공정 관리 기준', + 'subItems' => [ + ['id' => 'cat-2-1', 'name' => '작업표준서 확인'], + ['id' => 'cat-2-2', 'name' => '공정검사 기준 확인'], + ['id' => 'cat-2-3', 'name' => '부적합품 처리 기준 확인'], + ], + ], + [ + 'id' => 'cat-3', + 'title' => '제품 품질관리 기준', + 'subItems' => [ + ['id' => 'cat-3-1', 'name' => '제품검사 기준 확인'], + ['id' => 'cat-3-2', 'name' => '출하검사 기준 확인'], + ['id' => 'cat-3-3', 'name' => '클레임 처리 기준 확인'], + ], + ], + [ + 'id' => 'cat-4', + 'title' => '제조설비 관리', + 'subItems' => [ + ['id' => 'cat-4-1', 'name' => '설비관리 기준 확인'], + ['id' => 'cat-4-2', 'name' => '설비점검 이력 확인'], + ], + ], + [ + 'id' => 'cat-5', + 'title' => '검사설비 관리', + 'subItems' => [ + ['id' => 'cat-5-1', 'name' => '검사설비 관리 기준 확인'], + ['id' => 'cat-5-2', 'name' => '교정 이력 확인'], + ], + ], + [ + 'id' => 'cat-6', + 'title' => '문서 및 인증 관리', + 'subItems' => [ + ['id' => 'cat-6-1', 'name' => '문서관리 기준 확인'], + ['id' => 'cat-6-2', 'name' => 'KS/인증 관리 현황 확인'], + ], + ], + ], JSON_UNESCAPED_UNICODE); + + $tenantIds = DB::table('tenants')->pluck('id'); + $now = now(); + + foreach ($tenantIds as $tenantId) { + DB::table('checklist_templates')->insert([ + 'tenant_id' => $tenantId, + 'name' => '품질인정심사 점검표', + 'type' => 'day1_audit', + 'categories' => $defaultCategories, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } +}; diff --git a/routes/api/v1/quality.php b/routes/api/v1/quality.php index 5edb7ca..b266e15 100644 --- a/routes/api/v1/quality.php +++ b/routes/api/v1/quality.php @@ -8,6 +8,7 @@ */ use App\Http\Controllers\Api\V1\AuditChecklistController; +use App\Http\Controllers\Api\V1\ChecklistTemplateController; use App\Http\Controllers\Api\V1\PerformanceReportController; use App\Http\Controllers\Api\V1\QmsLotAuditController; use App\Http\Controllers\Api\V1\QualityDocumentController; @@ -50,6 +51,19 @@ Route::patch('/units/{id}/confirm', [QmsLotAuditController::class, 'confirm'])->whereNumber('id')->name('v1.qms.lot-audit.units.confirm'); }); +// QMS 점검표 템플릿 관리 +Route::prefix('quality/checklist-templates')->group(function () { + Route::get('', [ChecklistTemplateController::class, 'show'])->name('v1.quality.checklist-templates.show'); + Route::put('/{id}', [ChecklistTemplateController::class, 'update'])->whereNumber('id')->name('v1.quality.checklist-templates.update'); +}); + +// QMS 점검표 문서 (파일) 관리 +Route::prefix('quality/qms-documents')->group(function () { + Route::get('', [ChecklistTemplateController::class, 'documents'])->name('v1.quality.qms-documents.index'); + Route::post('', [ChecklistTemplateController::class, 'uploadDocument'])->name('v1.quality.qms-documents.store'); + Route::delete('/{id}', [ChecklistTemplateController::class, 'deleteDocument'])->whereNumber('id')->name('v1.quality.qms-documents.destroy'); +}); + // QMS 기준/매뉴얼 심사 (1일차) Route::prefix('qms')->group(function () { Route::get('/checklists', [AuditChecklistController::class, 'index'])->name('v1.qms.checklists.index'); From 479059747bd35ad3844074a0ec6e82d3b49a60f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 12 Mar 2026 01:32:22 +0900 Subject: [PATCH 150/166] =?UTF-8?q?feat:=20[=EC=83=9D=EC=82=B0/=EC=B6=9C?= =?UTF-8?q?=ED=95=98]=20=EC=88=98=EC=A3=BC=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=EC=B6=9C=ED=95=98=20=EC=9E=90=EB=8F=99=EC=83=9D=EC=84=B1=20+?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=ED=9D=90=EB=A6=84=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 출하를 작업지시(WO) 단위 → 수주(Order) 단위로 변경 - createShipmentFromOrder: 모든 메인 WO 품목을 통합하여 출하 1건 생성 - 출하에 수주 정보 복사 안함 (order_info accessor로 조인 참조) - syncOrderStatus에서 PRODUCED 전환 시 자동 출하 생성 - ensureShipmentExists: 이미 PRODUCED인데 출하 없으면 재생성 - POST /shipments/from-order/{orderId} 수동 출하 생성 API 추가 - createShipmentForOrder: 상태 검증 + 작업지시 조회 + 출하 생성 - Shipment order_info accessor 확장 (receiver, delivery_address_detail, delivery_method) - ShipmentService index에 creator 관계 추가 (목록 작성자 표시) - autoCompleteWorkOrderIfAllStepsDone: 전체 step 완료 시 WO 자동완료 - autoCompleteOrphanedSteps: 고아 step 자동보정 - syncOrderStatus: 공정 미지정 WO 바이패스 - ApiResponse::success 201 인자 오류 수정 Co-Authored-By: Claude Opus 4.6 --- .../Controllers/Api/V1/ShipmentController.php | 22 +- app/Models/Tenants/Shipment.php | 16 +- app/Services/ShipmentService.php | 2 +- app/Services/WorkOrderService.php | 275 +++++++++++++++++- lang/ko/error.php | 3 + routes/api/v1/inventory.php | 1 + 6 files changed, 298 insertions(+), 21 deletions(-) diff --git a/app/Http/Controllers/Api/V1/ShipmentController.php b/app/Http/Controllers/Api/V1/ShipmentController.php index 6262700..08f4b2b 100644 --- a/app/Http/Controllers/Api/V1/ShipmentController.php +++ b/app/Http/Controllers/Api/V1/ShipmentController.php @@ -8,13 +8,15 @@ use App\Http\Requests\Shipment\ShipmentUpdateRequest; use App\Http\Requests\Shipment\ShipmentUpdateStatusRequest; use App\Services\ShipmentService; +use App\Services\WorkOrderService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class ShipmentController extends Controller { public function __construct( - private readonly ShipmentService $service + private readonly ShipmentService $service, + private readonly WorkOrderService $workOrderService ) {} /** @@ -83,7 +85,7 @@ public function store(ShipmentStoreRequest $request): JsonResponse { $shipment = $this->service->store($request->validated()); - return ApiResponse::success($shipment, __('message.created'), 201); + return ApiResponse::success($shipment, __('message.created'), [], 201); } /** @@ -132,6 +134,22 @@ public function destroy(int $id): JsonResponse } } + /** + * 수주 기반 출하 생성 + */ + public function createFromOrder(int $orderId): JsonResponse + { + try { + $shipment = $this->workOrderService->createShipmentForOrder($orderId); + + return ApiResponse::success($shipment, __('message.created'), [], 201); + } catch (\Symfony\Component\HttpKernel\Exception\BadRequestHttpException $e) { + return ApiResponse::error($e->getMessage(), 400); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return ApiResponse::error(__('error.order.not_found'), 404); + } + } + /** * LOT 옵션 조회 */ diff --git a/app/Models/Tenants/Shipment.php b/app/Models/Tenants/Shipment.php index df96ff5..90e8f1a 100644 --- a/app/Models/Tenants/Shipment.php +++ b/app/Models/Tenants/Shipment.php @@ -283,6 +283,12 @@ public function getOrderContactAttribute(): ?string */ public function getOrderInfoAttribute(): array { + $orderOptions = $this->order?->options; + if (is_string($orderOptions)) { + $orderOptions = json_decode($orderOptions, true) ?? []; + } + $orderOptions = $orderOptions ?? []; + return [ 'order_id' => $this->order_id, 'order_no' => $this->order?->order_no, @@ -290,10 +296,16 @@ public function getOrderInfoAttribute(): array 'client_id' => $this->order_client_id, 'customer_name' => $this->order_customer_name, 'site_name' => $this->order_site_name, - 'delivery_address' => $this->order_delivery_address, + 'delivery_address' => $orderOptions['shipping_address'] ?? $this->order_delivery_address, + 'delivery_address_detail' => $orderOptions['shipping_address_detail'] ?? null, 'contact' => $this->order_contact, + // 수신자 정보 (수주 options에서) + 'receiver' => $orderOptions['receiver'] ?? null, + 'receiver_contact' => $orderOptions['receiver_contact'] ?? $this->order_contact, // 추가 정보 - 'delivery_date' => $this->order?->delivery_date?->format('Y-m-d'), 'writer_id' => $this->order?->writer_id, + 'delivery_date' => $this->order?->delivery_date?->format('Y-m-d'), + 'delivery_method' => $this->order?->delivery_method_code, + 'writer_id' => $this->order?->writer_id, 'writer_name' => $this->order?->writer?->name, ]; } diff --git a/app/Services/ShipmentService.php b/app/Services/ShipmentService.php index df8b35a..3db0bec 100644 --- a/app/Services/ShipmentService.php +++ b/app/Services/ShipmentService.php @@ -20,7 +20,7 @@ public function index(array $params): LengthAwarePaginator $query = Shipment::query() ->where('tenant_id', $tenantId) - ->with(['items', 'vehicleDispatches', 'order.client', 'order.writer', 'workOrder']); + ->with(['items', 'vehicleDispatches', 'order.client', 'order.writer', 'workOrder', 'creator']); // 검색어 필터 if (! empty($params['search'])) { diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index bbb5ed0..3da8978 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -602,13 +602,9 @@ public function updateStatus(int $id, string $status, ?array $resultData = null) // 연결된 수주(Order) 상태 동기화 $this->syncOrderStatus($workOrder, $tenantId); - // 작업완료 시: 수주 연동 → 출하 생성 / 선생산 → 재고 입고 - if ($status === WorkOrder::STATUS_COMPLETED) { - if ($workOrder->sales_order_id) { - $this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId); - } else { - $this->stockInFromProduction($workOrder); - } + // 작업완료 시: 선생산(수주 없음) → 재고 입고 + if ($status === WorkOrder::STATUS_COMPLETED && ! $workOrder->sales_order_id) { + $this->stockInFromProduction($workOrder); } return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']); @@ -659,11 +655,167 @@ private function shouldStockIn(WorkOrderItem $woItem): bool } /** - * 작업지시 완료 시 자동 출하 생성 + * PRODUCED 수주에 출하가 없으면 재생성 * - * 작업지시가 완료(completed) 상태가 되면 출하(Shipment)를 자동 생성하여 출하관리로 넘깁니다. - * 발주처/배송 정보는 출하에 복사하지 않고, 수주(Order)를 참조합니다. - * (Shipment 모델의 accessor 메서드로 수주 정보 참조) + * syncOrderStatus에서 이미 PRODUCED인데 출하가 삭제된 경우 호출됩니다. + */ + private function ensureShipmentExists(Order $order, $mainWorkOrders, int $tenantId): void + { + $hasShipment = Shipment::where('tenant_id', $tenantId) + ->where('order_id', $order->id) + ->exists(); + + if (! $hasShipment) { + $this->createShipmentFromOrder($order, $mainWorkOrders, $tenantId, $this->apiUserId()); + } + } + + /** + * 수주 기반 출하 수동 생성 (API 엔드포인트용) + * + * 출하관리 UI에서 수주를 선택하여 출하를 수동 생성할 때 사용합니다. + * PRODUCED 이상 상태의 수주만 가능합니다. + */ + public function createShipmentForOrder(int $orderId): Shipment + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $order = Order::where('tenant_id', $tenantId)->findOrFail($orderId); + + // PRODUCED 또는 SHIPPED 상태만 출하 생성 가능 + $allowedStatuses = [Order::STATUS_PRODUCED, Order::STATUS_SHIPPED]; + if (! in_array($order->status_code, $allowedStatuses)) { + throw new BadRequestHttpException(__('error.shipment.order_not_produced')); + } + + // 메인 작업지시 조회 + $allWorkOrders = WorkOrder::where('tenant_id', $tenantId) + ->where('sales_order_id', $orderId) + ->where('status', '!=', WorkOrder::STATUS_CANCELLED) + ->get(); + + $mainWorkOrders = $allWorkOrders->filter(fn ($wo) => ! $this->isAuxiliaryWorkOrder($wo) && $wo->process_id !== null); + + if ($mainWorkOrders->isEmpty()) { + throw new BadRequestHttpException(__('error.shipment.no_work_orders')); + } + + $shipment = $this->createShipmentFromOrder($order, $mainWorkOrders, $tenantId, $userId); + + if (! $shipment) { + throw new BadRequestHttpException(__('error.shipment.already_exists')); + } + + return $shipment->load('items'); + } + + /** + * 수주 단위 자동 출하 생성 (생산완료 시) + * + * 수주의 모든 메인 작업지시가 완료되면, 전체 WO 품목을 합쳐서 출하 1건을 생성합니다. + * - 이미 수주에 연결된 출하가 있으면 스킵 (중복 방지) + * - 부분 출고는 출하관리 UI에서 수동 생성 + * + * @param \Illuminate\Support\Collection $mainWorkOrders 메인 작업지시 컬렉션 + */ + private function createShipmentFromOrder(Order $order, $mainWorkOrders, int $tenantId, int $userId): ?Shipment + { + // 이미 이 수주에 연결된 출하가 있으면 스킵 + $existingShipment = Shipment::where('tenant_id', $tenantId) + ->where('order_id', $order->id) + ->first(); + + if ($existingShipment) { + return $existingShipment; + } + + $shipmentNo = Shipment::generateShipmentNo($tenantId); + + $shipment = Shipment::create([ + 'tenant_id' => $tenantId, + 'shipment_no' => $shipmentNo, + 'work_order_id' => null, // 수주 단위이므로 개별 WO 연결 안함 + 'order_id' => $order->id, + 'scheduled_date' => $order->delivery_date ?? now()->toDateString(), + 'status' => 'scheduled', + 'priority' => 'normal', + 'delivery_method' => $order->delivery_method_code ?? 'pickup', + 'can_ship' => true, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + // 모든 메인 작업지시의 품목을 출하 품목으로 복사 + $seq = 0; + foreach ($mainWorkOrders as $wo) { + $workOrderItems = $wo->items()->get(); + + foreach ($workOrderItems as $woItem) { + $result = $woItem->options['result'] ?? []; + $lotNo = $result['lot_no'] ?? null; + $floorUnit = $this->getFloorUnitFromOrderItem($woItem->source_order_item_id, $tenantId); + + ShipmentItem::create([ + 'tenant_id' => $tenantId, + 'shipment_id' => $shipment->id, + 'seq' => ++$seq, + 'item_code' => $woItem->item_id ? "ITEM-{$woItem->item_id}" : null, + 'item_name' => $woItem->item_name, + 'floor_unit' => $floorUnit, + 'specification' => $woItem->specification, + 'quantity' => $result['good_qty'] ?? $woItem->quantity, + 'unit' => $woItem->unit, + 'lot_no' => $lotNo, + 'remarks' => null, + ]); + } + + // WO에 품목이 없으면 수주 품목에서 fallback (해당 WO의 공정에 매칭되는 품목) + if ($workOrderItems->isEmpty() && $wo->salesOrder) { + $orderItems = $wo->salesOrder->items()->get(); + foreach ($orderItems as $orderItem) { + $floorUnit = $this->getFloorUnitFromOrderItem($orderItem->id, $tenantId); + ShipmentItem::create([ + 'tenant_id' => $tenantId, + 'shipment_id' => $shipment->id, + 'seq' => ++$seq, + 'item_code' => $orderItem->item_id ? "ITEM-{$orderItem->item_id}" : null, + 'item_name' => $orderItem->item_name, + 'floor_unit' => $floorUnit, + 'specification' => $orderItem->specification, + 'quantity' => $orderItem->quantity, + 'unit' => $orderItem->unit, + 'lot_no' => null, + 'remarks' => null, + ]); + } + } + } + + $this->auditLogger->log( + $tenantId, + 'shipment', + $shipment->id, + 'auto_created_from_order', + null, + [ + 'order_id' => $order->id, + 'order_no' => $order->order_no, + 'shipment_no' => $shipmentNo, + 'work_order_count' => $mainWorkOrders->count(), + 'items_count' => $shipment->items()->count(), + ] + ); + + return $shipment; + } + + /** + * [DEPRECATED] 작업지시 단위 자동 출하 생성 + * + * 수주 단위 출하(createShipmentFromOrder)로 대체됨. + * 부분 출고 등 특수 케이스에서 개별 WO 기반 출하가 필요할 경우를 위해 유지. */ private function createShipmentFromWorkOrder(WorkOrder $workOrder, int $tenantId, int $userId): ?Shipment { @@ -842,8 +994,8 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void ->where('status', '!=', WorkOrder::STATUS_CANCELLED) ->get(); - // 보조 공정 제외 - $mainWorkOrders = $allWorkOrders->filter(fn ($wo) => ! $this->isAuxiliaryWorkOrder($wo)); + // 보조 공정 및 공정 미지정 작업지시 제외 + $mainWorkOrders = $allWorkOrders->filter(fn ($wo) => ! $this->isAuxiliaryWorkOrder($wo) && $wo->process_id !== null); if ($mainWorkOrders->isEmpty()) { return; @@ -869,8 +1021,17 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void $newOrderStatus = Order::STATUS_IN_PRODUCTION; } - // 매핑되는 상태가 없거나 이미 동일한 상태면 스킵 - if (! $newOrderStatus || $order->status_code === $newOrderStatus) { + // 매핑되는 상태가 없으면 스킵 + if (! $newOrderStatus) { + return; + } + + // 이미 동일한 상태면 상태 변경은 스킵하되, PRODUCED인데 출하 없으면 재생성 + if ($order->status_code === $newOrderStatus) { + if ($newOrderStatus === Order::STATUS_PRODUCED) { + $this->ensureShipmentExists($order, $mainWorkOrders, $tenantId); + } + return; } @@ -893,6 +1054,11 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void 'in_progress' => $inProgressCount, ]] ); + + // 생산완료(PRODUCED) 전환 시 → 수주 단위 출하 자동 생성 + if ($newOrderStatus === Order::STATUS_PRODUCED) { + $this->createShipmentFromOrder($order, $mainWorkOrders, $tenantId, $this->apiUserId()); + } } /** @@ -981,7 +1147,7 @@ private function saveItemResults(WorkOrder $workOrder, ?array $resultData, int $ */ private function isAuxiliaryWorkOrder(WorkOrder $workOrder): bool { - $options = is_array($workOrder->options) ? $workOrder->options : (json_decode($workOrder->options, true) ?? []); + $options = is_array($workOrder->options) ? $workOrder->options : (json_decode($workOrder->options ?? '{}', true) ?? []); return ! empty($options['is_auxiliary']); } @@ -1875,15 +2041,92 @@ public function toggleStepProgress(int $workOrderId, int $progressId): array $after ); + // 모든 공정 단계 완료 시 → 작업지시 자동 완료 + $workOrderStatusChanged = false; + if ($progress->isCompleted()) { + $workOrderStatusChanged = $this->autoCompleteWorkOrderIfAllStepsDone($workOrder, $tenantId, $userId); + } + return [ 'id' => $progress->id, 'status' => $progress->status, 'is_completed' => $progress->isCompleted(), 'completed_at' => $progress->completed_at?->toDateTimeString(), 'completed_by' => $progress->completed_by, + 'work_order_status_changed' => $workOrderStatusChanged, ]; } + /** + * 모든 공정 단계 완료 시 작업지시를 자동으로 완료 처리 + * + * 트리거: 마지막 공정 단계(포장 등) 완료 체크 시 + * 흐름: 전 단계 완료 → 작업지시 completed → 수주 상태 동기화 → 출하 자동 생성 + */ + private function autoCompleteWorkOrderIfAllStepsDone(WorkOrder $workOrder, int $tenantId, int $userId): bool + { + // 이미 완료/출하 상태면 스킵 + if (in_array($workOrder->status, [WorkOrder::STATUS_COMPLETED, WorkOrder::STATUS_SHIPPED])) { + return false; + } + + // 해당 작업지시의 모든 공정 단계 조회 + $allSteps = WorkOrderStepProgress::where('work_order_id', $workOrder->id)->get(); + + if ($allSteps->isEmpty()) { + return false; + } + + // 미완료 step 자동 보정: 같은 개소(work_order_item)의 다른 step이 모두 완료된 경우 + // 자재투입 등 모달 방식 step이 DB에 waiting으로 남아있을 수 있음 + $incompleteSteps = $allSteps->where('status', '!=', WorkOrderStepProgress::STATUS_COMPLETED); + if ($incompleteSteps->isNotEmpty()) { + $this->autoCompleteOrphanedSteps($allSteps, $incompleteSteps, $userId); + + // 보정 후 다시 확인 + $allSteps = WorkOrderStepProgress::where('work_order_id', $workOrder->id)->get(); + $allCompleted = $allSteps->every(fn ($step) => $step->status === WorkOrderStepProgress::STATUS_COMPLETED); + + if (! $allCompleted) { + return false; + } + } + + // 작업지시 완료 처리 (updateStatus 재사용으로 출하 생성/수주 동기화 모두 트리거) + $this->updateStatus($workOrder->id, WorkOrder::STATUS_COMPLETED); + + return true; + } + + /** + * 같은 개소(work_order_item)의 나머지 step이 모두 완료되었으면 + * 남은 미완료 step(자재투입 등)도 자동 완료 처리 + */ + private function autoCompleteOrphanedSteps($allSteps, $incompleteSteps, int $userId): void + { + // 개소(item)별로 그룹핑 + $stepsByItem = $allSteps->groupBy('work_order_item_id'); + + foreach ($incompleteSteps as $incomplete) { + $itemSteps = $stepsByItem->get($incomplete->work_order_item_id); + if (! $itemSteps) { + continue; + } + + // 이 개소에서 이 step만 미완료인지 확인 + $otherIncomplete = $itemSteps->where('id', '!=', $incomplete->id) + ->where('status', '!=', WorkOrderStepProgress::STATUS_COMPLETED); + + if ($otherIncomplete->isEmpty()) { + // 이 step만 남았으면 자동 완료 + $incomplete->status = WorkOrderStepProgress::STATUS_COMPLETED; + $incomplete->completed_at = now(); + $incomplete->completed_by = $userId; + $incomplete->save(); + } + } + } + /** * 자재 투입 이력 조회 */ diff --git a/lang/ko/error.php b/lang/ko/error.php index bc537ea..0b824c8 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -99,6 +99,9 @@ 'cannot_delete' => '현재 상태에서는 삭제할 수 없습니다.', 'invalid_status' => '유효하지 않은 상태입니다.', 'cannot_ship' => '출하 가능 상태가 아닙니다.', + 'order_not_produced' => '생산완료 상태의 수주만 출하를 생성할 수 있습니다.', + 'no_work_orders' => '해당 수주에 유효한 작업지시가 없습니다.', + 'already_exists' => '이미 해당 수주에 출하가 존재합니다.', ], // 파일 관리 관련 diff --git a/routes/api/v1/inventory.php b/routes/api/v1/inventory.php index 73b66fd..f148d75 100644 --- a/routes/api/v1/inventory.php +++ b/routes/api/v1/inventory.php @@ -119,6 +119,7 @@ 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::post('/from-order/{orderId}', [ShipmentController::class, 'createFromOrder'])->whereNumber('orderId')->name('v1.shipments.from-order'); 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'); From 073ad11ecdc6df97d72dc38fbd6b825d483f1e28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 12 Mar 2026 01:45:53 +0900 Subject: [PATCH 151/166] =?UTF-8?q?fix:=20[QMS]=20=EC=A0=90=EA=B2=80?= =?UTF-8?q?=ED=91=9C=20=ED=86=A0=EA=B8=80=20=ED=95=B4=EC=A0=9C=20=EC=95=88?= =?UTF-8?q?=EB=90=98=EB=8A=94=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 - PHP foreach 참조(&)와 ?? 연산자 조합 시 임시 복사본이 생성되어 원본 배열 수정 불가 - `$category['subItems'] ?? []` → `empty() + continue` + `$category['subItems']` 로 변경 - 토글 API가 항상 is_completed: true 반환하던 문제 해결 Co-Authored-By: Claude Opus 4.6 --- app/Services/ChecklistTemplateService.php | 48 +++++++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/app/Services/ChecklistTemplateService.php b/app/Services/ChecklistTemplateService.php index a0ca96e..018928f 100644 --- a/app/Services/ChecklistTemplateService.php +++ b/app/Services/ChecklistTemplateService.php @@ -93,17 +93,59 @@ public function save(int $id, array $data): array $template->refresh(); $this->auditLogger->log( + $this->tenantId(), self::AUDIT_TARGET, $template->id, 'updated', $before, - $template->toArray(), - $this->apiUserId() + $template->toArray() ); return $this->getByType($template->type); } + /** + * 항목 완료 토글 + */ + public function toggleItem(int $id, string $subItemId): array + { + $template = ChecklistTemplate::findOrFail($id); + $categories = $template->categories; + $toggled = null; + + foreach ($categories as &$category) { + if (empty($category['subItems'])) { + continue; + } + foreach ($category['subItems'] as &$subItem) { + if ($subItem['id'] === $subItemId) { + $subItem['is_completed'] = ! ($subItem['is_completed'] ?? false); + $subItem['completed_at'] = $subItem['is_completed'] ? now()->toIso8601String() : null; + $toggled = $subItem; + break 2; + } + } + unset($subItem); + } + unset($category); + + if (! $toggled) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $template->update([ + 'categories' => $categories, + 'updated_by' => $this->apiUserId(), + ]); + + return [ + 'id' => $toggled['id'], + 'name' => $toggled['name'], + 'is_completed' => $toggled['is_completed'], + 'completed_at' => $toggled['completed_at'], + ]; + } + /** * 항목별 파일 목록 조회 */ @@ -152,7 +194,7 @@ public function uploadDocument(int $templateId, string $subItemId, $uploadedFile ); // 파일 저장 - Storage::disk('tenant')->put($filePath, file_get_contents($uploadedFile->getPathname())); + Storage::disk('r2')->put($filePath, file_get_contents($uploadedFile->getPathname())); // DB 레코드 생성 $file = File::create([ From 3a889b33ef9fef50515d58ac21b4210ab44d8b03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 12 Mar 2026 09:48:22 +0900 Subject: [PATCH 152/166] =?UTF-8?q?fix:=20[QMS]=20=EC=9D=B8=EC=A0=95?= =?UTF-8?q?=ED=92=88=EB=AA=A9=20=ED=91=9C=EC=8B=9C=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?+=20=EC=A0=9C=ED=92=88=EA=B2=80=EC=82=AC=20=EC=84=B1=EC=A0=81?= =?UTF-8?q?=EC=84=9C=20=ED=95=84=ED=84=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getFgProductName(): BOM 순회 대신 Order.item_id 직접 참조로 변경 - 제품검사 성적서 필터: document_id만 → document_id || inspection_status=completed - getLocationDetail(): FQC 문서 데이터 포함 (template + data) - formatFqcTemplate(): DB item → item_name 매핑 추가 - formatDocumentItem('product'): 개소별 층/기호 코드 표시 Co-Authored-By: Claude Opus 4.6 --- app/Services/QmsLotAuditService.php | 146 +++++++++++++++++++++------- 1 file changed, 111 insertions(+), 35 deletions(-) diff --git a/app/Services/QmsLotAuditService.php b/app/Services/QmsLotAuditService.php index dbd367c..951be48 100644 --- a/app/Services/QmsLotAuditService.php +++ b/app/Services/QmsLotAuditService.php @@ -23,8 +23,7 @@ class QmsLotAuditService extends Service public function index(array $params): array { $query = QualityDocument::with([ - 'documentOrders.order.nodes' => fn ($q) => $q->whereNull('parent_id'), - 'documentOrders.order.nodes.items.item', + 'documentOrders.order.item', 'locations', 'performanceReport', ]) @@ -89,7 +88,7 @@ public function routeDocuments(int $qualityDocumentOrderId): array { $docOrder = QualityDocumentOrder::with([ 'order.workOrders.process', - 'locations', + 'locations.orderItem', 'qualityDocument', ])->findOrFail($qualityDocumentOrderId); @@ -119,17 +118,20 @@ public function routeDocuments(int $qualityDocumentOrderId): array // 2. 수주서 $documents[] = $this->formatDocument('order', '수주서', collect([$order])); - // 3. 작업일지 (subType: process.process_name 기반) - $documents[] = $this->formatDocumentWithSubType('log', '작업일지', $workOrders); + // 3. 작업일지 (공정별 1개씩 — 같은 공정의 WO는 그룹핑) + $workOrdersByProcess = $workOrders->groupBy('process_id')->map(fn ($group) => $group->first()); + $documents[] = $this->formatDocumentWithSubType('log', '작업일지', $workOrdersByProcess); - // 4. 중간검사 성적서 (PQC) + // 4. 중간검사 성적서 (PQC — 공정별 1개씩) $pqcInspections = Inspection::where('inspection_type', 'PQC') ->whereIn('work_order_id', $workOrders->pluck('id')) - ->where('status', 'completed') ->with('workOrder.process') ->get(); - $documents[] = $this->formatDocumentWithSubType('report', '중간검사 성적서', $pqcInspections, 'workOrder'); + // 공정별 그룹핑 (같은 공정의 PQC는 최신 1개만) + $pqcByProcess = $pqcInspections->groupBy(fn ($insp) => $insp->workOrder?->process_id) + ->map(fn ($group) => $group->sortByDesc('inspection_date')->first()); + $documents[] = $this->formatDocumentWithSubType('report', '중간검사 성적서', $pqcByProcess, 'workOrder'); // 5. 납품확인서 $shipments = $order->shipments()->get(); @@ -138,9 +140,11 @@ public function routeDocuments(int $qualityDocumentOrderId): array // 6. 출고증 $documents[] = $this->formatDocument('shipping', '출고증', $shipments); - // 7. 제품검사 성적서 - $locationsWithDoc = $docOrder->locations->filter(fn ($loc) => $loc->document_id); - $documents[] = $this->formatDocument('product', '제품검사 성적서', $locationsWithDoc); + // 7. 제품검사 성적서 (FQC 문서 또는 inspection_data 완료건) + $locationsWithInspection = $docOrder->locations->filter( + fn ($loc) => $loc->document_id || $loc->inspection_status === 'completed' + ); + $documents[] = $this->formatDocument('product', '제품검사 성적서', $locationsWithInspection); // 8. 품질관리서 $documents[] = $this->formatDocument('quality', '품질관리서', collect([$qualityDoc])); @@ -220,30 +224,14 @@ private function transformReportToFrontend(QualityDocument $doc): array } /** - * BOM 최상위(FG) 제품명 추출 - * Order → root OrderNode(parent_id=null) → 대표 OrderItem → Item(FG).name + * 수주 대표 제품명 추출 + * Order.item_id → Item.name */ private function getFgProductName(QualityDocument $doc): string { - $firstDocOrder = $doc->documentOrders->first(); - if (! $firstDocOrder) { - return ''; - } + $order = $doc->documentOrders->first()?->order; - $order = $firstDocOrder->order; - if (! $order) { - return ''; - } - - // eager loaded with whereNull('parent_id') filter - $rootNode = $order->nodes->first(); - if (! $rootNode) { - return ''; - } - - $representativeItem = $rootNode->items->first(); - - return $representativeItem?->item?->name ?? ''; + return $order?->item?->name ?? ''; } private function transformRouteToFrontend(QualityDocumentOrder $docOrder, QualityDocument $qualityDoc): array @@ -313,11 +301,12 @@ private function formatDocumentWithSubType(string $type, string $title, $collect 'items' => $collection->values()->map(function ($item) use ($type, $workOrderRelation) { $formatted = $this->formatDocumentItem($type, $item); - // subType: process.process_name 기반 + // subType: process.process_name 기반 + work_order_id 전달 $workOrder = $workOrderRelation ? $item->{$workOrderRelation} : $item; if ($workOrder instanceof WorkOrder) { $processName = $workOrder->process?->process_name; $formatted['sub_type'] = $this->mapProcessToSubType($processName); + $formatted['work_order_id'] = $workOrder->id; } return $formatted; @@ -354,7 +343,7 @@ private function formatDocumentItem(string $type, $item): array ], 'product' => [ 'id' => (string) $item->id, - 'title' => '제품검사 성적서', + 'title' => trim(($item->orderItem?->floor_code ?? '').' '.($item->orderItem?->symbol_code ?? '')) ?: '제품검사 성적서', 'date' => $item->updated_at?->toDateString() ?? '', 'code' => '', ], @@ -479,9 +468,16 @@ private function getShipmentDetail(int $id): array private function getLocationDetail(int $id): array { - $location = QualityDocumentLocation::with(['orderItem', 'document'])->findOrFail($id); + $location = QualityDocumentLocation::with([ + 'orderItem', + 'document.template.sections.items', + 'document.template.columns', + 'document.template.approvalLines', + 'document.template.basicFields', + 'document.data', + ])->findOrFail($id); - return [ + $result = [ 'type' => 'product', 'data' => [ 'id' => $location->id, @@ -494,6 +490,86 @@ private function getLocationDetail(int $id): array 'document_id' => $location->document_id, ], ]; + + // FQC 문서가 있으면 template + data 포함 + if ($location->document) { + $doc = $location->document; + $result['data']['fqc_document'] = [ + 'id' => $doc->id, + 'template_id' => $doc->template_id, + 'document_no' => $doc->document_no, + 'title' => $doc->title, + 'status' => $doc->status, + 'created_at' => $doc->created_at?->toIso8601String(), + 'template' => $this->formatFqcTemplate($doc->template), + 'data' => $doc->data->map(fn ($d) => [ + 'section_id' => $d->section_id, + 'column_id' => $d->column_id, + 'row_index' => $d->row_index, + 'field_key' => $d->field_key, + 'field_value' => $d->field_value, + ])->all(), + ]; + } + + return $result; + } + + private function formatFqcTemplate($template): ?array + { + if (! $template) { + return null; + } + + return [ + 'id' => $template->id, + 'name' => $template->name, + 'category' => $template->category, + 'title' => $template->title, + 'approval_lines' => $template->approvalLines->map(fn ($a) => [ + 'id' => $a->id, + 'name' => $a->name, + 'department' => $a->department, + 'sort_order' => $a->sort_order, + ])->all(), + 'basic_fields' => $template->basicFields->map(fn ($f) => [ + 'id' => $f->id, + 'label' => $f->label, + 'field_key' => $f->field_key, + 'field_type' => $f->field_type, + 'default_value' => $f->default_value, + 'is_required' => $f->is_required, + 'sort_order' => $f->sort_order, + ])->all(), + 'sections' => $template->sections->map(fn ($s) => [ + 'id' => $s->id, + 'name' => $s->name, + 'title' => $s->title, + 'description' => $s->description, + 'image_path' => $s->image_path, + 'sort_order' => $s->sort_order, + 'items' => $s->items->map(fn ($i) => [ + 'id' => $i->id, + 'section_id' => $i->section_id, + 'item_name' => $i->item ?? '', + 'standard' => $i->standard, + 'tolerance' => $i->tolerance, + 'measurement_type' => $i->measurement_type, + 'frequency' => $i->frequency, + 'sort_order' => $i->sort_order, + 'category' => $i->category, + 'method' => $i->method, + ])->all(), + ])->all(), + 'columns' => $template->columns->map(fn ($c) => [ + 'id' => $c->id, + 'label' => $c->label, + 'column_type' => $c->column_type, + 'width' => $c->width, + 'group_name' => $c->group_name, + 'sort_order' => $c->sort_order, + ])->all(), + ]; } private function getQualityDocDetail(int $id): array From 8c16993746e79d202671f464162aba15dee94a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 12 Mar 2026 10:18:58 +0900 Subject: [PATCH 153/166] =?UTF-8?q?fix:=20token-login=20API=20KEY=20?= =?UTF-8?q?=EB=AF=B8=EB=93=A4=EC=9B=A8=EC=96=B4=20=ED=99=94=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EC=8A=A4=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 --- app/Http/Middleware/ApiKeyMiddleware.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Http/Middleware/ApiKeyMiddleware.php b/app/Http/Middleware/ApiKeyMiddleware.php index 5a6d769..64717fd 100644 --- a/app/Http/Middleware/ApiKeyMiddleware.php +++ b/app/Http/Middleware/ApiKeyMiddleware.php @@ -117,6 +117,7 @@ public function handle(Request $request, Closure $next) // 화이트리스트(인증 예외 라우트) - Bearer 토큰 없이 접근 가능 $allowWithoutAuth = [ 'api/v1/login', + 'api/v1/token-login', // MNG → SAM 자동 로그인 (API Key만 필요) 'api/v1/signup', 'api/v1/register', 'api/v1/refresh', From 069d0206a06ad134f897e9a70eed220646a63271 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 12 Mar 2026 10:52:30 +0900 Subject: [PATCH 154/166] =?UTF-8?q?feat:=20[equipment]=20=EC=84=A4?= =?UTF-8?q?=EB=B9=84=EA=B4=80=EB=A6=AC=20API=20=EB=B0=B1=EC=97=94=EB=93=9C?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(Phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모델 6개: Equipment, EquipmentInspection, EquipmentInspectionDetail, EquipmentInspectionTemplate, EquipmentRepair, EquipmentProcess - InspectionCycle Enum: 6주기(일/주/월/격월/분기/반기) 날짜 해석 - 서비스 4개: EquipmentService, EquipmentInspectionService, EquipmentRepairService, EquipmentPhotoService - 컨트롤러 4개: CRUD + 점검 토글/결과 설정/메모/초기화 + 템플릿 관리 + 수리이력 + 사진 - FormRequest 6개: 설비등록/수정, 수리이력, 점검템플릿, 토글, 메모 - 라우트 26개: equipment prefix 하위 RESTful 엔드포인트 - i18n 메시지: message.equipment.*, error.equipment.* - 마이그레이션: equipments/equipment_repairs options JSON 컬럼 추가 --- app/Enums/InspectionCycle.php | 232 +++++++++++ .../V1/Equipment/EquipmentController.php | 91 +++++ .../EquipmentInspectionController.php | 130 ++++++ .../V1/Equipment/EquipmentPhotoController.php | 38 ++ .../Equipment/EquipmentRepairController.php | 49 +++ .../Equipment/StoreEquipmentRepairRequest.php | 29 ++ .../V1/Equipment/StoreEquipmentRequest.php | 41 ++ .../StoreInspectionTemplateRequest.php | 28 ++ .../ToggleInspectionDetailRequest.php | 23 ++ .../V1/Equipment/UpdateEquipmentRequest.php | 41 ++ .../UpdateInspectionNotesRequest.php | 26 ++ app/Models/Equipment/Equipment.php | 154 +++++++ app/Models/Equipment/EquipmentInspection.php | 43 ++ .../Equipment/EquipmentInspectionDetail.php | 55 +++ .../Equipment/EquipmentInspectionTemplate.php | 42 ++ app/Models/Equipment/EquipmentProcess.php | 31 ++ app/Models/Equipment/EquipmentRepair.php | 62 +++ .../Equipment/EquipmentInspectionService.php | 376 ++++++++++++++++++ .../Equipment/EquipmentPhotoService.php | 50 +++ .../Equipment/EquipmentRepairService.php | 102 +++++ app/Services/Equipment/EquipmentService.php | 153 +++++++ ...100000_add_options_to_equipment_tables.php | 38 ++ lang/ko/error.php | 11 + lang/ko/message.php | 14 + routes/api.php | 1 + routes/api/v1/equipment.php | 45 +++ 26 files changed, 1905 insertions(+) create mode 100644 app/Enums/InspectionCycle.php create mode 100644 app/Http/Controllers/V1/Equipment/EquipmentController.php create mode 100644 app/Http/Controllers/V1/Equipment/EquipmentInspectionController.php create mode 100644 app/Http/Controllers/V1/Equipment/EquipmentPhotoController.php create mode 100644 app/Http/Controllers/V1/Equipment/EquipmentRepairController.php create mode 100644 app/Http/Requests/V1/Equipment/StoreEquipmentRepairRequest.php create mode 100644 app/Http/Requests/V1/Equipment/StoreEquipmentRequest.php create mode 100644 app/Http/Requests/V1/Equipment/StoreInspectionTemplateRequest.php create mode 100644 app/Http/Requests/V1/Equipment/ToggleInspectionDetailRequest.php create mode 100644 app/Http/Requests/V1/Equipment/UpdateEquipmentRequest.php create mode 100644 app/Http/Requests/V1/Equipment/UpdateInspectionNotesRequest.php create mode 100644 app/Models/Equipment/Equipment.php create mode 100644 app/Models/Equipment/EquipmentInspection.php create mode 100644 app/Models/Equipment/EquipmentInspectionDetail.php create mode 100644 app/Models/Equipment/EquipmentInspectionTemplate.php create mode 100644 app/Models/Equipment/EquipmentProcess.php create mode 100644 app/Models/Equipment/EquipmentRepair.php create mode 100644 app/Services/Equipment/EquipmentInspectionService.php create mode 100644 app/Services/Equipment/EquipmentPhotoService.php create mode 100644 app/Services/Equipment/EquipmentRepairService.php create mode 100644 app/Services/Equipment/EquipmentService.php create mode 100644 database/migrations/2026_03_12_100000_add_options_to_equipment_tables.php create mode 100644 routes/api/v1/equipment.php diff --git a/app/Enums/InspectionCycle.php b/app/Enums/InspectionCycle.php new file mode 100644 index 0000000..d1e42fb --- /dev/null +++ b/app/Enums/InspectionCycle.php @@ -0,0 +1,232 @@ + '일일', + self::WEEKLY => '주간', + self::MONTHLY => '월간', + self::BIMONTHLY => '2개월', + self::QUARTERLY => '분기', + self::SEMIANNUAL => '반년', + ]; + } + + public static function label(string $cycle): string + { + return self::all()[$cycle] ?? $cycle; + } + + public static function periodType(string $cycle): string + { + return $cycle === self::DAILY ? 'month' : 'year'; + } + + public static function columnLabels(string $cycle, ?string $period = null): array + { + return match ($cycle) { + self::DAILY => self::dailyLabels($period), + self::WEEKLY => self::weeklyLabels(), + self::MONTHLY => self::monthlyLabels(), + self::BIMONTHLY => self::bimonthlyLabels(), + self::QUARTERLY => self::quarterlyLabels(), + self::SEMIANNUAL => self::semiannualLabels(), + default => self::dailyLabels($period), + }; + } + + public static function resolveCheckDate(string $cycle, string $period, int $colIndex): string + { + return match ($cycle) { + self::DAILY => self::dailyCheckDate($period, $colIndex), + self::WEEKLY => self::weeklyCheckDate($period, $colIndex), + self::MONTHLY => self::monthlyCheckDate($period, $colIndex), + self::BIMONTHLY => self::bimonthlyCheckDate($period, $colIndex), + self::QUARTERLY => self::quarterlyCheckDate($period, $colIndex), + self::SEMIANNUAL => self::semiannualCheckDate($period, $colIndex), + default => self::dailyCheckDate($period, $colIndex), + }; + } + + public static function resolvePeriod(string $cycle, string $checkDate): string + { + $date = Carbon::parse($checkDate); + + return match ($cycle) { + self::DAILY => $date->format('Y-m'), + self::WEEKLY => (string) $date->isoWeekYear, + default => $date->format('Y'), + }; + } + + public static function columnCount(string $cycle, ?string $period = null): int + { + return count(self::columnLabels($cycle, $period)); + } + + public static function isWeekend(string $period, int $colIndex): bool + { + $date = Carbon::createFromFormat('Y-m', $period)->startOfMonth()->addDays($colIndex - 1); + + return in_array($date->dayOfWeek, [0, 6]); + } + + public static function getHolidayDates(string $cycle, string $period, int $tenantId): array + { + if ($cycle === self::DAILY) { + $start = Carbon::createFromFormat('Y-m', $period)->startOfMonth(); + $end = $start->copy()->endOfMonth(); + } else { + $start = Carbon::create((int) $period, 1, 1); + $end = Carbon::create((int) $period, 12, 31); + } + + $holidays = Holiday::where('tenant_id', $tenantId) + ->where('start_date', '<=', $end->toDateString()) + ->where('end_date', '>=', $start->toDateString()) + ->get(); + + $dates = []; + foreach ($holidays as $holiday) { + $hStart = $holiday->start_date->copy()->max($start); + $hEnd = $holiday->end_date->copy()->min($end); + $current = $hStart->copy(); + while ($current->lte($hEnd)) { + $dates[$current->format('Y-m-d')] = true; + $current->addDay(); + } + } + + return $dates; + } + + public static function isNonWorkingDay(string $checkDate, array $holidayDates = []): bool + { + $date = Carbon::parse($checkDate); + + return $date->isWeekend() || isset($holidayDates[$checkDate]); + } + + // --- Daily --- + private static function dailyLabels(?string $period): array + { + $date = Carbon::createFromFormat('Y-m', $period ?? now()->format('Y-m')); + $days = $date->daysInMonth; + $labels = []; + for ($d = 1; $d <= $days; $d++) { + $labels[$d] = (string) $d; + } + + return $labels; + } + + private static function dailyCheckDate(string $period, int $colIndex): string + { + return Carbon::createFromFormat('Y-m', $period)->startOfMonth()->addDays($colIndex - 1)->format('Y-m-d'); + } + + // --- Weekly --- + private static function weeklyLabels(): array + { + $labels = []; + for ($w = 1; $w <= 52; $w++) { + $labels[$w] = $w.'주'; + } + + return $labels; + } + + private static function weeklyCheckDate(string $year, int $colIndex): string + { + return Carbon::create((int) $year)->setISODate((int) $year, $colIndex, 1)->format('Y-m-d'); + } + + // --- Monthly --- + private static function monthlyLabels(): array + { + $labels = []; + for ($m = 1; $m <= 12; $m++) { + $labels[$m] = $m.'월'; + } + + return $labels; + } + + private static function monthlyCheckDate(string $year, int $colIndex): string + { + return Carbon::create((int) $year, $colIndex, 1)->format('Y-m-d'); + } + + // --- Bimonthly --- + private static function bimonthlyLabels(): array + { + return [ + 1 => '1~2월', + 2 => '3~4월', + 3 => '5~6월', + 4 => '7~8월', + 5 => '9~10월', + 6 => '11~12월', + ]; + } + + private static function bimonthlyCheckDate(string $year, int $colIndex): string + { + $month = ($colIndex - 1) * 2 + 1; + + return Carbon::create((int) $year, $month, 1)->format('Y-m-d'); + } + + // --- Quarterly --- + private static function quarterlyLabels(): array + { + return [ + 1 => '1분기', + 2 => '2분기', + 3 => '3분기', + 4 => '4분기', + ]; + } + + private static function quarterlyCheckDate(string $year, int $colIndex): string + { + $month = ($colIndex - 1) * 3 + 1; + + return Carbon::create((int) $year, $month, 1)->format('Y-m-d'); + } + + // --- Semiannual --- + private static function semiannualLabels(): array + { + return [ + 1 => '상반기', + 2 => '하반기', + ]; + } + + private static function semiannualCheckDate(string $year, int $colIndex): string + { + $month = $colIndex === 1 ? 1 : 7; + + return Carbon::create((int) $year, $month, 1)->format('Y-m-d'); + } +} diff --git a/app/Http/Controllers/V1/Equipment/EquipmentController.php b/app/Http/Controllers/V1/Equipment/EquipmentController.php new file mode 100644 index 0000000..41c64b7 --- /dev/null +++ b/app/Http/Controllers/V1/Equipment/EquipmentController.php @@ -0,0 +1,91 @@ + $this->service->index($request->only([ + 'search', 'status', 'production_line', 'equipment_type', + 'sort_by', 'sort_direction', 'per_page', + ])), + __('message.fetched') + ); + } + + public function show(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->show($id), + __('message.fetched') + ); + } + + public function store(StoreEquipmentRequest $request): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->store($request->validated()), + __('message.equipment.created') + ); + } + + public function update(UpdateEquipmentRequest $request, int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->update($id, $request->validated()), + __('message.equipment.updated') + ); + } + + public function destroy(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->destroy($id), + __('message.equipment.deleted') + ); + } + + public function restore(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->restore($id), + __('message.equipment.restored') + ); + } + + public function toggleActive(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->toggleActive($id), + __('message.toggled') + ); + } + + public function stats(): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->stats(), + __('message.fetched') + ); + } + + public function options(): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->options(), + __('message.fetched') + ); + } +} diff --git a/app/Http/Controllers/V1/Equipment/EquipmentInspectionController.php b/app/Http/Controllers/V1/Equipment/EquipmentInspectionController.php new file mode 100644 index 0000000..7433efe --- /dev/null +++ b/app/Http/Controllers/V1/Equipment/EquipmentInspectionController.php @@ -0,0 +1,130 @@ + $this->service->getInspections( + $request->input('cycle', 'daily'), + $request->input('period', now()->format('Y-m')), + $request->input('production_line'), + $request->input('equipment_id') ? (int) $request->input('equipment_id') : null + ), + __('message.fetched') + ); + } + + public function toggleDetail(ToggleInspectionDetailRequest $request): JsonResponse + { + $data = $request->validated(); + + return ApiResponse::handle( + fn () => $this->service->toggleDetail( + $data['equipment_id'], + $data['template_item_id'], + $data['check_date'], + $data['cycle'] ?? 'daily' + ), + __('message.equipment.inspection_saved') + ); + } + + public function setResult(Request $request): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->setResult( + (int) $request->input('equipment_id'), + (int) $request->input('template_item_id'), + $request->input('check_date'), + $request->input('cycle', 'daily'), + $request->input('result') + ), + __('message.equipment.inspection_saved') + ); + } + + public function updateNotes(UpdateInspectionNotesRequest $request): JsonResponse + { + $data = $request->validated(); + + return ApiResponse::handle( + fn () => $this->service->updateNotes( + $data['equipment_id'], + $data['year_month'], + collect($data)->only(['overall_judgment', 'inspector_id', 'repair_note', 'issue_note'])->toArray(), + $data['cycle'] ?? 'daily' + ), + __('message.equipment.inspection_saved') + ); + } + + public function resetInspection(Request $request): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->resetInspection( + (int) $request->input('equipment_id'), + $request->input('cycle', 'daily'), + $request->input('period') + ), + __('message.equipment.inspection_reset') + ); + } + + public function templates(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->getActiveCycles($id), + __('message.fetched') + ); + } + + public function storeTemplate(StoreInspectionTemplateRequest $request, int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->saveTemplate($id, $request->validated()), + __('message.equipment.template_created') + ); + } + + public function updateTemplate(Request $request, int $templateId): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->updateTemplate($templateId, $request->all()), + __('message.updated') + ); + } + + public function deleteTemplate(int $templateId): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->deleteTemplate($templateId), + __('message.deleted') + ); + } + + public function copyTemplates(Request $request, int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->copyTemplates( + $id, + $request->input('source_cycle'), + $request->input('target_cycles', []) + ), + __('message.equipment.template_copied') + ); + } +} diff --git a/app/Http/Controllers/V1/Equipment/EquipmentPhotoController.php b/app/Http/Controllers/V1/Equipment/EquipmentPhotoController.php new file mode 100644 index 0000000..71ca832 --- /dev/null +++ b/app/Http/Controllers/V1/Equipment/EquipmentPhotoController.php @@ -0,0 +1,38 @@ + $this->service->index($id), + __('message.fetched') + ); + } + + public function store(Request $request, int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->store($id, $request->all()), + __('message.equipment.photo_uploaded') + ); + } + + public function destroy(int $id, int $fileId): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->destroy($id, $fileId), + __('message.deleted') + ); + } +} diff --git a/app/Http/Controllers/V1/Equipment/EquipmentRepairController.php b/app/Http/Controllers/V1/Equipment/EquipmentRepairController.php new file mode 100644 index 0000000..c31399a --- /dev/null +++ b/app/Http/Controllers/V1/Equipment/EquipmentRepairController.php @@ -0,0 +1,49 @@ + $this->service->index($request->only([ + 'equipment_id', 'repair_type', 'date_from', 'date_to', 'search', 'per_page', + ])), + __('message.fetched') + ); + } + + public function store(StoreEquipmentRepairRequest $request): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->store($request->validated()), + __('message.equipment.repair_created') + ); + } + + public function update(StoreEquipmentRepairRequest $request, int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->update($id, $request->validated()), + __('message.updated') + ); + } + + public function destroy(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->destroy($id), + __('message.deleted') + ); + } +} diff --git a/app/Http/Requests/V1/Equipment/StoreEquipmentRepairRequest.php b/app/Http/Requests/V1/Equipment/StoreEquipmentRepairRequest.php new file mode 100644 index 0000000..440e78a --- /dev/null +++ b/app/Http/Requests/V1/Equipment/StoreEquipmentRepairRequest.php @@ -0,0 +1,29 @@ + 'required|integer|exists:equipments,id', + 'repair_date' => 'required|date', + 'repair_type' => 'nullable|string|in:internal,external', + 'repair_hours' => 'nullable|numeric|min:0', + 'description' => 'nullable|string', + 'cost' => 'nullable|numeric|min:0', + 'vendor' => 'nullable|string|max:100', + 'repaired_by' => 'nullable|integer|exists:users,id', + 'memo' => 'nullable|string', + 'options' => 'nullable|array', + ]; + } +} diff --git a/app/Http/Requests/V1/Equipment/StoreEquipmentRequest.php b/app/Http/Requests/V1/Equipment/StoreEquipmentRequest.php new file mode 100644 index 0000000..1990daa --- /dev/null +++ b/app/Http/Requests/V1/Equipment/StoreEquipmentRequest.php @@ -0,0 +1,41 @@ + 'required|string|max:50', + 'name' => 'required|string|max:100', + 'equipment_type' => 'nullable|string|max:50', + 'specification' => 'nullable|string|max:200', + 'manufacturer' => 'nullable|string|max:100', + 'model_name' => 'nullable|string|max:100', + 'serial_no' => 'nullable|string|max:100', + 'location' => 'nullable|string|max:100', + 'production_line' => 'nullable|string|max:50', + 'purchase_date' => 'nullable|date', + 'install_date' => 'nullable|date', + 'purchase_price' => 'nullable|numeric|min:0', + 'useful_life' => 'nullable|integer|min:0', + 'status' => 'nullable|in:active,idle,disposed', + 'disposed_date' => 'nullable|date', + 'manager_id' => 'nullable|integer|exists:users,id', + 'sub_manager_id' => 'nullable|integer|exists:users,id', + 'photo_path' => 'nullable|string|max:500', + 'memo' => 'nullable|string', + 'options' => 'nullable|array', + 'is_active' => 'nullable|boolean', + 'sort_order' => 'nullable|integer|min:0', + ]; + } +} diff --git a/app/Http/Requests/V1/Equipment/StoreInspectionTemplateRequest.php b/app/Http/Requests/V1/Equipment/StoreInspectionTemplateRequest.php new file mode 100644 index 0000000..43d3ce0 --- /dev/null +++ b/app/Http/Requests/V1/Equipment/StoreInspectionTemplateRequest.php @@ -0,0 +1,28 @@ + 'required|string|in:daily,weekly,monthly,bimonthly,quarterly,semiannual', + 'item_no' => 'required|string|max:20', + 'check_point' => 'required|string|max:100', + 'check_item' => 'required|string|max:200', + 'check_timing' => 'nullable|string|max:50', + 'check_frequency' => 'nullable|string|max:50', + 'check_method' => 'nullable|string|max:200', + 'sort_order' => 'nullable|integer|min:0', + 'is_active' => 'nullable|boolean', + ]; + } +} diff --git a/app/Http/Requests/V1/Equipment/ToggleInspectionDetailRequest.php b/app/Http/Requests/V1/Equipment/ToggleInspectionDetailRequest.php new file mode 100644 index 0000000..56b2a98 --- /dev/null +++ b/app/Http/Requests/V1/Equipment/ToggleInspectionDetailRequest.php @@ -0,0 +1,23 @@ + 'required|integer|exists:equipments,id', + 'template_item_id' => 'required|integer|exists:equipment_inspection_templates,id', + 'check_date' => 'required|date', + 'cycle' => 'nullable|string|in:daily,weekly,monthly,bimonthly,quarterly,semiannual', + ]; + } +} diff --git a/app/Http/Requests/V1/Equipment/UpdateEquipmentRequest.php b/app/Http/Requests/V1/Equipment/UpdateEquipmentRequest.php new file mode 100644 index 0000000..922b484 --- /dev/null +++ b/app/Http/Requests/V1/Equipment/UpdateEquipmentRequest.php @@ -0,0 +1,41 @@ + 'sometimes|string|max:50', + 'name' => 'sometimes|string|max:100', + 'equipment_type' => 'nullable|string|max:50', + 'specification' => 'nullable|string|max:200', + 'manufacturer' => 'nullable|string|max:100', + 'model_name' => 'nullable|string|max:100', + 'serial_no' => 'nullable|string|max:100', + 'location' => 'nullable|string|max:100', + 'production_line' => 'nullable|string|max:50', + 'purchase_date' => 'nullable|date', + 'install_date' => 'nullable|date', + 'purchase_price' => 'nullable|numeric|min:0', + 'useful_life' => 'nullable|integer|min:0', + 'status' => 'nullable|in:active,idle,disposed', + 'disposed_date' => 'nullable|date', + 'manager_id' => 'nullable|integer|exists:users,id', + 'sub_manager_id' => 'nullable|integer|exists:users,id', + 'photo_path' => 'nullable|string|max:500', + 'memo' => 'nullable|string', + 'options' => 'nullable|array', + 'is_active' => 'nullable|boolean', + 'sort_order' => 'nullable|integer|min:0', + ]; + } +} diff --git a/app/Http/Requests/V1/Equipment/UpdateInspectionNotesRequest.php b/app/Http/Requests/V1/Equipment/UpdateInspectionNotesRequest.php new file mode 100644 index 0000000..d757053 --- /dev/null +++ b/app/Http/Requests/V1/Equipment/UpdateInspectionNotesRequest.php @@ -0,0 +1,26 @@ + 'required|integer|exists:equipments,id', + 'year_month' => 'required|string', + 'cycle' => 'nullable|string|in:daily,weekly,monthly,bimonthly,quarterly,semiannual', + 'overall_judgment' => 'nullable|string|in:OK,NG', + 'inspector_id' => 'nullable|integer|exists:users,id', + 'repair_note' => 'nullable|string', + 'issue_note' => 'nullable|string', + ]; + } +} diff --git a/app/Models/Equipment/Equipment.php b/app/Models/Equipment/Equipment.php new file mode 100644 index 0000000..3c7d996 --- /dev/null +++ b/app/Models/Equipment/Equipment.php @@ -0,0 +1,154 @@ + 'date', + 'install_date' => 'date', + 'disposed_date' => 'date', + 'purchase_price' => 'decimal:2', + 'is_active' => 'boolean', + 'options' => 'array', + ]; + + public function getOption(string $key, mixed $default = null): mixed + { + return data_get($this->options, $key, $default); + } + + public function setOption(string $key, mixed $value): self + { + $options = $this->options ?? []; + data_set($options, $key, $value); + $this->options = $options; + + return $this; + } + + public function manager(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'manager_id'); + } + + public function subManager(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'sub_manager_id'); + } + + public function canInspect(?int $userId = null): bool + { + if (! $userId) { + return false; + } + + return $this->manager_id === $userId || $this->sub_manager_id === $userId; + } + + public function inspectionTemplates(): HasMany + { + return $this->hasMany(EquipmentInspectionTemplate::class, 'equipment_id')->orderBy('sort_order'); + } + + public function inspections(): HasMany + { + return $this->hasMany(EquipmentInspection::class, 'equipment_id'); + } + + public function repairs(): HasMany + { + return $this->hasMany(EquipmentRepair::class, 'equipment_id'); + } + + public function photos(): HasMany + { + return $this->hasMany(File::class, 'document_id') + ->where('document_type', 'equipment') + ->orderBy('id'); + } + + public function processes(): BelongsToMany + { + return $this->belongsToMany(\App\Models\Process::class, 'equipment_process') + ->withPivot('is_primary') + ->withTimestamps(); + } + + public function scopeByLine($query, string $line) + { + return $query->where('production_line', $line); + } + + public function scopeByType($query, string $type) + { + return $query->where('equipment_type', $type); + } + + public function scopeByStatus($query, string $status) + { + return $query->where('status', $status); + } + + public static function getEquipmentTypes(): array + { + return ['포밍기', '미싱기', '샤링기', 'V컷팅기', '절곡기', '프레스', '드릴', '기타']; + } + + public static function getProductionLines(): array + { + return ['스라트', '스크린', '절곡', '기타']; + } + + public static function getStatuses(): array + { + return [ + 'active' => '가동', + 'idle' => '유휴', + 'disposed' => '폐기', + ]; + } +} diff --git a/app/Models/Equipment/EquipmentInspection.php b/app/Models/Equipment/EquipmentInspection.php new file mode 100644 index 0000000..b8c7e33 --- /dev/null +++ b/app/Models/Equipment/EquipmentInspection.php @@ -0,0 +1,43 @@ +belongsTo(Equipment::class, 'equipment_id'); + } + + public function inspector(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'inspector_id'); + } + + public function details(): HasMany + { + return $this->hasMany(EquipmentInspectionDetail::class, 'inspection_id'); + } +} diff --git a/app/Models/Equipment/EquipmentInspectionDetail.php b/app/Models/Equipment/EquipmentInspectionDetail.php new file mode 100644 index 0000000..382b23f --- /dev/null +++ b/app/Models/Equipment/EquipmentInspectionDetail.php @@ -0,0 +1,55 @@ + 'date', + ]; + + public function inspection(): BelongsTo + { + return $this->belongsTo(EquipmentInspection::class, 'inspection_id'); + } + + public function templateItem(): BelongsTo + { + return $this->belongsTo(EquipmentInspectionTemplate::class, 'template_item_id'); + } + + public static function getNextResult(?string $current): ?string + { + return match ($current) { + null, '' => 'good', + 'good' => 'bad', + 'bad' => 'repaired', + 'repaired' => null, + default => 'good', + }; + } + + public static function getResultSymbol(?string $result): string + { + return match ($result) { + 'good' => '○', + 'bad' => 'X', + 'repaired' => '△', + default => '', + }; + } +} diff --git a/app/Models/Equipment/EquipmentInspectionTemplate.php b/app/Models/Equipment/EquipmentInspectionTemplate.php new file mode 100644 index 0000000..539c6a6 --- /dev/null +++ b/app/Models/Equipment/EquipmentInspectionTemplate.php @@ -0,0 +1,42 @@ + 'boolean', + ]; + + public function equipment(): BelongsTo + { + return $this->belongsTo(Equipment::class, 'equipment_id'); + } + + public function scopeByCycle($query, string $cycle) + { + return $query->where('inspection_cycle', $cycle); + } +} diff --git a/app/Models/Equipment/EquipmentProcess.php b/app/Models/Equipment/EquipmentProcess.php new file mode 100644 index 0000000..ca8260f --- /dev/null +++ b/app/Models/Equipment/EquipmentProcess.php @@ -0,0 +1,31 @@ + 'boolean', + ]; + + public function equipment(): BelongsTo + { + return $this->belongsTo(Equipment::class, 'equipment_id'); + } + + public function process(): BelongsTo + { + return $this->belongsTo(\App\Models\Process::class, 'process_id'); + } +} diff --git a/app/Models/Equipment/EquipmentRepair.php b/app/Models/Equipment/EquipmentRepair.php new file mode 100644 index 0000000..60733ed --- /dev/null +++ b/app/Models/Equipment/EquipmentRepair.php @@ -0,0 +1,62 @@ + 'date', + 'repair_hours' => 'decimal:1', + 'cost' => 'decimal:2', + 'options' => 'array', + ]; + + public function getOption(string $key, mixed $default = null): mixed + { + return data_get($this->options, $key, $default); + } + + public function setOption(string $key, mixed $value): self + { + $options = $this->options ?? []; + data_set($options, $key, $value); + $this->options = $options; + + return $this; + } + + public function equipment(): BelongsTo + { + return $this->belongsTo(Equipment::class, 'equipment_id'); + } + + public function repairer(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'repaired_by'); + } +} diff --git a/app/Services/Equipment/EquipmentInspectionService.php b/app/Services/Equipment/EquipmentInspectionService.php new file mode 100644 index 0000000..ebc5972 --- /dev/null +++ b/app/Services/Equipment/EquipmentInspectionService.php @@ -0,0 +1,376 @@ +where('status', '!=', 'disposed') + ->with(['manager', 'subManager']); + + if ($productionLine) { + $equipmentQuery->byLine($productionLine); + } + + if ($equipmentId) { + $equipmentQuery->where('id', $equipmentId); + } + + $equipments = $equipmentQuery->orderBy('sort_order')->orderBy('name')->get(); + + $labels = InspectionCycle::columnLabels($cycle, $period); + $userId = $this->apiUserId(); + + $result = []; + + foreach ($equipments as $equipment) { + $templates = EquipmentInspectionTemplate::where('equipment_id', $equipment->id) + ->byCycle($cycle) + ->active() + ->orderBy('sort_order') + ->get(); + + if ($templates->isEmpty()) { + continue; + } + + $inspection = EquipmentInspection::where('equipment_id', $equipment->id) + ->where('inspection_cycle', $cycle) + ->where('year_month', $period) + ->first(); + + $details = []; + if ($inspection) { + $details = EquipmentInspectionDetail::where('inspection_id', $inspection->id) + ->get() + ->groupBy(function ($d) { + return $d->template_item_id.'_'.$d->check_date->format('Y-m-d'); + }); + } + + $result[] = [ + 'equipment' => $equipment, + 'templates' => $templates, + 'inspection' => $inspection, + 'details' => $details, + 'labels' => $labels, + 'can_inspect' => $equipment->canInspect($userId), + ]; + } + + return $result; + } + + public function toggleDetail(int $equipmentId, int $templateItemId, string $checkDate, string $cycle = 'daily'): array + { + return DB::transaction(function () use ($equipmentId, $templateItemId, $checkDate, $cycle) { + $equipment = Equipment::find($equipmentId); + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + $userId = $this->apiUserId(); + if (! $equipment->canInspect($userId)) { + throw new AccessDeniedHttpException(__('error.equipment.no_inspect_permission')); + } + + $period = InspectionCycle::resolvePeriod($cycle, $checkDate); + if ($cycle === InspectionCycle::DAILY) { + $tenantId = $this->tenantId(); + $holidayDates = InspectionCycle::getHolidayDates($cycle, $period, $tenantId); + if (InspectionCycle::isNonWorkingDay($checkDate, $holidayDates)) { + throw new BadRequestHttpException(__('error.equipment.non_working_day')); + } + } + + $tenantId = $this->tenantId(); + + $inspection = EquipmentInspection::firstOrCreate( + [ + 'tenant_id' => $tenantId, + 'equipment_id' => $equipmentId, + 'inspection_cycle' => $cycle, + 'year_month' => $period, + ], + [ + 'created_by' => $userId, + ] + ); + + $detail = EquipmentInspectionDetail::where('inspection_id', $inspection->id) + ->where('template_item_id', $templateItemId) + ->where('check_date', $checkDate) + ->first(); + + if ($detail) { + $nextResult = EquipmentInspectionDetail::getNextResult($detail->result); + if ($nextResult === null) { + $detail->delete(); + + return ['result' => null, 'symbol' => '']; + } + $detail->update(['result' => $nextResult]); + } else { + $detail = EquipmentInspectionDetail::create([ + 'inspection_id' => $inspection->id, + 'template_item_id' => $templateItemId, + 'check_date' => $checkDate, + 'result' => 'good', + ]); + $nextResult = 'good'; + } + + return [ + 'result' => $nextResult, + 'symbol' => EquipmentInspectionDetail::getResultSymbol($nextResult), + ]; + }); + } + + public function setResult(int $equipmentId, int $templateItemId, string $checkDate, string $cycle, ?string $result): array + { + return DB::transaction(function () use ($equipmentId, $templateItemId, $checkDate, $cycle, $result) { + $equipment = Equipment::find($equipmentId); + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + $userId = $this->apiUserId(); + if (! $equipment->canInspect($userId)) { + throw new AccessDeniedHttpException(__('error.equipment.no_inspect_permission')); + } + + $period = InspectionCycle::resolvePeriod($cycle, $checkDate); + if ($cycle === InspectionCycle::DAILY) { + $tenantId = $this->tenantId(); + $holidayDates = InspectionCycle::getHolidayDates($cycle, $period, $tenantId); + if (InspectionCycle::isNonWorkingDay($checkDate, $holidayDates)) { + throw new BadRequestHttpException(__('error.equipment.non_working_day')); + } + } + + $tenantId = $this->tenantId(); + + $inspection = EquipmentInspection::firstOrCreate( + [ + 'tenant_id' => $tenantId, + 'equipment_id' => $equipmentId, + 'inspection_cycle' => $cycle, + 'year_month' => $period, + ], + [ + 'created_by' => $userId, + ] + ); + + $detail = EquipmentInspectionDetail::where('inspection_id', $inspection->id) + ->where('template_item_id', $templateItemId) + ->where('check_date', $checkDate) + ->first(); + + if ($result === null) { + if ($detail) { + $detail->delete(); + } + + return ['result' => null, 'symbol' => '']; + } + + if ($detail) { + $detail->update(['result' => $result]); + } else { + $detail = EquipmentInspectionDetail::create([ + 'inspection_id' => $inspection->id, + 'template_item_id' => $templateItemId, + 'check_date' => $checkDate, + 'result' => $result, + ]); + } + + return [ + 'result' => $result, + 'symbol' => EquipmentInspectionDetail::getResultSymbol($result), + ]; + }); + } + + public function updateNotes(int $equipmentId, string $yearMonth, array $data, string $cycle = 'daily'): EquipmentInspection + { + return DB::transaction(function () use ($equipmentId, $yearMonth, $data, $cycle) { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $inspection = EquipmentInspection::firstOrCreate( + [ + 'tenant_id' => $tenantId, + 'equipment_id' => $equipmentId, + 'inspection_cycle' => $cycle, + 'year_month' => $yearMonth, + ], + [ + 'created_by' => $userId, + ] + ); + + $inspection->update($data); + + return $inspection->fresh(); + }); + } + + public function resetInspection(int $equipmentId, string $cycle, string $period): int + { + return DB::transaction(function () use ($equipmentId, $cycle, $period) { + $equipment = Equipment::find($equipmentId); + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + $userId = $this->apiUserId(); + if (! $equipment->canInspect($userId)) { + throw new AccessDeniedHttpException(__('error.equipment.no_inspect_permission')); + } + + $tenantId = $this->tenantId(); + + $inspection = EquipmentInspection::where('tenant_id', $tenantId) + ->where('equipment_id', $equipmentId) + ->where('inspection_cycle', $cycle) + ->where('year_month', $period) + ->first(); + + if (! $inspection) { + return 0; + } + + $deleted = EquipmentInspectionDetail::where('inspection_id', $inspection->id)->delete(); + $inspection->update([ + 'overall_judgment' => null, + 'repair_note' => null, + 'issue_note' => null, + 'inspector_id' => null, + ]); + + return $deleted; + }); + } + + public function saveTemplate(int $equipmentId, array $data): EquipmentInspectionTemplate + { + return DB::transaction(function () use ($equipmentId, $data) { + $tenantId = $this->tenantId(); + + return EquipmentInspectionTemplate::create(array_merge($data, [ + 'tenant_id' => $tenantId, + 'equipment_id' => $equipmentId, + ])); + }); + } + + public function updateTemplate(int $id, array $data): EquipmentInspectionTemplate + { + return DB::transaction(function () use ($id, $data) { + $template = EquipmentInspectionTemplate::find($id); + + if (! $template) { + throw new NotFoundHttpException(__('error.equipment.template_not_found')); + } + + $template->update($data); + + return $template->fresh(); + }); + } + + public function deleteTemplate(int $id): bool + { + return DB::transaction(function () use ($id) { + $template = EquipmentInspectionTemplate::find($id); + + if (! $template) { + throw new NotFoundHttpException(__('error.equipment.template_not_found')); + } + + return $template->delete(); + }); + } + + public function copyTemplates(int $equipmentId, string $sourceCycle, array $targetCycles): array + { + return DB::transaction(function () use ($equipmentId, $sourceCycle, $targetCycles) { + $tenantId = $this->tenantId(); + + $sourceTemplates = EquipmentInspectionTemplate::where('equipment_id', $equipmentId) + ->byCycle($sourceCycle) + ->active() + ->orderBy('sort_order') + ->get(); + + if ($sourceTemplates->isEmpty()) { + throw new BadRequestHttpException(__('error.equipment.no_source_templates')); + } + + $copiedCount = 0; + $skippedCount = 0; + + foreach ($targetCycles as $targetCycle) { + foreach ($sourceTemplates as $template) { + $exists = EquipmentInspectionTemplate::where('equipment_id', $equipmentId) + ->where('inspection_cycle', $targetCycle) + ->where('item_no', $template->item_no) + ->exists(); + + if ($exists) { + $skippedCount++; + + continue; + } + + EquipmentInspectionTemplate::create([ + 'tenant_id' => $tenantId, + 'equipment_id' => $equipmentId, + 'inspection_cycle' => $targetCycle, + 'item_no' => $template->item_no, + 'check_point' => $template->check_point, + 'check_item' => $template->check_item, + 'check_timing' => $template->check_timing, + 'check_frequency' => $template->check_frequency, + 'check_method' => $template->check_method, + 'sort_order' => $template->sort_order, + 'is_active' => true, + ]); + $copiedCount++; + } + } + + return [ + 'copied' => $copiedCount, + 'skipped' => $skippedCount, + 'source_count' => $sourceTemplates->count(), + 'target_cycles' => $targetCycles, + ]; + }); + } + + public function getActiveCycles(int $equipmentId): array + { + return EquipmentInspectionTemplate::where('equipment_id', $equipmentId) + ->active() + ->distinct() + ->pluck('inspection_cycle') + ->toArray(); + } +} diff --git a/app/Services/Equipment/EquipmentPhotoService.php b/app/Services/Equipment/EquipmentPhotoService.php new file mode 100644 index 0000000..e06cccb --- /dev/null +++ b/app/Services/Equipment/EquipmentPhotoService.php @@ -0,0 +1,50 @@ +photos; + } + + public function store(int $equipmentId, array $fileData): File + { + $equipment = Equipment::find($equipmentId); + + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + return File::create(array_merge($fileData, [ + 'document_id' => $equipmentId, + 'document_type' => 'equipment', + ])); + } + + public function destroy(int $equipmentId, int $fileId): bool + { + $file = File::where('document_id', $equipmentId) + ->where('document_type', 'equipment') + ->where('id', $fileId) + ->first(); + + if (! $file) { + throw new NotFoundHttpException(__('error.file.not_found')); + } + + return $file->delete(); + } +} diff --git a/app/Services/Equipment/EquipmentRepairService.php b/app/Services/Equipment/EquipmentRepairService.php new file mode 100644 index 0000000..47826b0 --- /dev/null +++ b/app/Services/Equipment/EquipmentRepairService.php @@ -0,0 +1,102 @@ +with('equipment', 'repairer'); + + if (! empty($filters['equipment_id'])) { + $query->where('equipment_id', $filters['equipment_id']); + } + + if (! empty($filters['repair_type'])) { + $query->where('repair_type', $filters['repair_type']); + } + + if (! empty($filters['date_from'])) { + $query->where('repair_date', '>=', $filters['date_from']); + } + + if (! empty($filters['date_to'])) { + $query->where('repair_date', '<=', $filters['date_to']); + } + + if (! empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('description', 'like', "%{$search}%") + ->orWhereHas('equipment', function ($eq) use ($search) { + $eq->where('name', 'like', "%{$search}%") + ->orWhere('equipment_code', 'like', "%{$search}%"); + }); + }); + } + + return $query->orderBy('repair_date', 'desc')->paginate($filters['per_page'] ?? 20); + } + + public function show(int $id): EquipmentRepair + { + $repair = EquipmentRepair::with('equipment', 'repairer')->find($id); + + if (! $repair) { + throw new NotFoundHttpException(__('error.equipment.repair_not_found')); + } + + return $repair; + } + + public function store(array $data): EquipmentRepair + { + return DB::transaction(function () use ($data) { + $data['tenant_id'] = $this->tenantId(); + + return EquipmentRepair::create($data); + }); + } + + public function update(int $id, array $data): EquipmentRepair + { + return DB::transaction(function () use ($id, $data) { + $repair = EquipmentRepair::find($id); + + if (! $repair) { + throw new NotFoundHttpException(__('error.equipment.repair_not_found')); + } + + $repair->update($data); + + return $repair->fresh(); + }); + } + + public function destroy(int $id): bool + { + return DB::transaction(function () use ($id) { + $repair = EquipmentRepair::find($id); + + if (! $repair) { + throw new NotFoundHttpException(__('error.equipment.repair_not_found')); + } + + return $repair->delete(); + }); + } + + public function recentRepairs(int $limit = 5): \Illuminate\Database\Eloquent\Collection + { + return EquipmentRepair::with('equipment') + ->orderBy('repair_date', 'desc') + ->limit($limit) + ->get(); + } +} diff --git a/app/Services/Equipment/EquipmentService.php b/app/Services/Equipment/EquipmentService.php new file mode 100644 index 0000000..7d736d5 --- /dev/null +++ b/app/Services/Equipment/EquipmentService.php @@ -0,0 +1,153 @@ +with(['manager', 'subManager']); + + if (! empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('equipment_code', 'like', "%{$search}%") + ->orWhere('name', 'like', "%{$search}%"); + }); + } + + if (! empty($filters['status'])) { + $query->byStatus($filters['status']); + } + + if (! empty($filters['production_line'])) { + $query->byLine($filters['production_line']); + } + + if (! empty($filters['equipment_type'])) { + $query->byType($filters['equipment_type']); + } + + $sortBy = $filters['sort_by'] ?? 'sort_order'; + $sortDir = $filters['sort_direction'] ?? 'asc'; + $query->orderBy($sortBy, $sortDir); + + return $query->paginate($filters['per_page'] ?? 20); + } + + public function show(int $id): Equipment + { + $equipment = Equipment::with(['manager', 'subManager', 'inspectionTemplates', 'repairs', 'processes', 'photos'])->find($id); + + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + return $equipment; + } + + public function store(array $data): Equipment + { + return DB::transaction(function () use ($data) { + $data['tenant_id'] = $this->tenantId(); + + return Equipment::create($data); + }); + } + + public function update(int $id, array $data): Equipment + { + return DB::transaction(function () use ($id, $data) { + $equipment = Equipment::find($id); + + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + $equipment->update($data); + + return $equipment->fresh(); + }); + } + + public function destroy(int $id): bool + { + return DB::transaction(function () use ($id) { + $equipment = Equipment::find($id); + + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + return $equipment->delete(); + }); + } + + public function restore(int $id): Equipment + { + return DB::transaction(function () use ($id) { + $equipment = Equipment::onlyTrashed()->find($id); + + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + $equipment->restore(); + + return $equipment->fresh(); + }); + } + + public function toggleActive(int $id): Equipment + { + return DB::transaction(function () use ($id) { + $equipment = Equipment::find($id); + + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + $equipment->update(['is_active' => ! $equipment->is_active]); + + return $equipment->fresh(); + }); + } + + public function stats(): array + { + $total = Equipment::count(); + $active = Equipment::where('status', 'active')->count(); + $idle = Equipment::where('status', 'idle')->count(); + $disposed = Equipment::where('status', 'disposed')->count(); + + return compact('total', 'active', 'idle', 'disposed'); + } + + public function options(): array + { + return [ + 'equipment_types' => Equipment::getEquipmentTypes(), + 'production_lines' => Equipment::getProductionLines(), + 'statuses' => Equipment::getStatuses(), + 'equipment_list' => Equipment::active() + ->orderBy('sort_order') + ->orderBy('name') + ->get(['id', 'equipment_code', 'name', 'equipment_type', 'production_line']), + ]; + } + + public function typeStats(): array + { + return Equipment::where('status', '!=', 'disposed') + ->selectRaw('equipment_type, count(*) as count') + ->groupBy('equipment_type') + ->pluck('count', 'equipment_type') + ->toArray(); + } +} diff --git a/database/migrations/2026_03_12_100000_add_options_to_equipment_tables.php b/database/migrations/2026_03_12_100000_add_options_to_equipment_tables.php new file mode 100644 index 0000000..290a0a0 --- /dev/null +++ b/database/migrations/2026_03_12_100000_add_options_to_equipment_tables.php @@ -0,0 +1,38 @@ +json('options')->nullable()->after('memo')->comment('확장 속성 JSON'); + }); + } + + if (Schema::hasTable('equipment_repairs') && ! Schema::hasColumn('equipment_repairs', 'options')) { + Schema::table('equipment_repairs', function (Blueprint $table) { + $table->json('options')->nullable()->after('memo')->comment('확장 속성 JSON'); + }); + } + } + + public function down(): void + { + if (Schema::hasTable('equipments') && Schema::hasColumn('equipments', 'options')) { + Schema::table('equipments', function (Blueprint $table) { + $table->dropColumn('options'); + }); + } + + if (Schema::hasTable('equipment_repairs') && Schema::hasColumn('equipment_repairs', 'options')) { + Schema::table('equipment_repairs', function (Blueprint $table) { + $table->dropColumn('options'); + }); + } + } +}; diff --git a/lang/ko/error.php b/lang/ko/error.php index 6418ac0..4b426e0 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -472,6 +472,17 @@ 'invalid_status' => '유효하지 않은 입찰 상태입니다.', ], + // 설비 관리 + 'equipment' => [ + 'not_found' => '설비 정보를 찾을 수 없습니다.', + 'template_not_found' => '점검항목을 찾을 수 없습니다.', + 'inspection_not_found' => '점검 데이터를 찾을 수 없습니다.', + 'repair_not_found' => '수리이력을 찾을 수 없습니다.', + 'photo_not_found' => '사진을 찾을 수 없습니다.', + 'invalid_cycle' => '유효하지 않은 점검주기입니다.', + 'no_source_templates' => '복사할 점검항목이 없습니다.', + ], + // 전자계약 (E-Sign) 'esign' => [ 'invalid_token' => '유효하지 않은 서명 링크입니다.', diff --git a/lang/ko/message.php b/lang/ko/message.php index a727c99..46159a0 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -575,6 +575,20 @@ 'downloaded' => '문서가 다운로드되었습니다.', ], + // 설비 관리 + 'equipment' => [ + 'created' => '설비가 등록되었습니다.', + 'updated' => '설비 정보가 수정되었습니다.', + 'deleted' => '설비가 삭제되었습니다.', + 'restored' => '설비가 복원되었습니다.', + 'inspection_saved' => '점검 정보가 저장되었습니다.', + 'inspection_reset' => '점검 데이터가 초기화되었습니다.', + 'template_created' => '점검항목이 추가되었습니다.', + 'template_copied' => '점검항목이 복사되었습니다.', + 'repair_created' => '수리이력이 등록되었습니다.', + 'photo_uploaded' => '사진이 업로드되었습니다.', + ], + // 일반전표입력 'journal_entry' => [ 'fetched' => '전표 조회 성공', diff --git a/routes/api.php b/routes/api.php index 0d329de..e82f5e5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -42,6 +42,7 @@ require __DIR__.'/api/v1/audit.php'; require __DIR__.'/api/v1/esign.php'; require __DIR__.'/api/v1/quality.php'; + require __DIR__.'/api/v1/equipment.php'; // 공유 링크 다운로드 (인증 불필요 - auth.apikey 그룹 밖) Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download'); diff --git a/routes/api/v1/equipment.php b/routes/api/v1/equipment.php new file mode 100644 index 0000000..619f1b4 --- /dev/null +++ b/routes/api/v1/equipment.php @@ -0,0 +1,45 @@ +group(function () { + // 설비 CRUD + Route::get('', [EquipmentController::class, 'index'])->name('v1.equipment.index'); + Route::get('/options', [EquipmentController::class, 'options'])->name('v1.equipment.options'); + Route::get('/stats', [EquipmentController::class, 'stats'])->name('v1.equipment.stats'); + Route::post('', [EquipmentController::class, 'store'])->name('v1.equipment.store'); + Route::get('/{id}', [EquipmentController::class, 'show'])->whereNumber('id')->name('v1.equipment.show'); + Route::put('/{id}', [EquipmentController::class, 'update'])->whereNumber('id')->name('v1.equipment.update'); + Route::delete('/{id}', [EquipmentController::class, 'destroy'])->whereNumber('id')->name('v1.equipment.destroy'); + Route::post('/{id}/restore', [EquipmentController::class, 'restore'])->whereNumber('id')->name('v1.equipment.restore'); + Route::patch('/{id}/toggle', [EquipmentController::class, 'toggleActive'])->whereNumber('id')->name('v1.equipment.toggle'); + + // 점검 템플릿 + Route::get('/{id}/templates', [EquipmentInspectionController::class, 'templates'])->whereNumber('id')->name('v1.equipment.templates'); + Route::post('/{id}/templates', [EquipmentInspectionController::class, 'storeTemplate'])->whereNumber('id')->name('v1.equipment.templates.store'); + Route::put('/templates/{templateId}', [EquipmentInspectionController::class, 'updateTemplate'])->whereNumber('templateId')->name('v1.equipment.templates.update'); + Route::delete('/templates/{templateId}', [EquipmentInspectionController::class, 'deleteTemplate'])->whereNumber('templateId')->name('v1.equipment.templates.destroy'); + Route::post('/{id}/templates/copy', [EquipmentInspectionController::class, 'copyTemplates'])->whereNumber('id')->name('v1.equipment.templates.copy'); + + // 점검 + Route::get('/inspections', [EquipmentInspectionController::class, 'index'])->name('v1.equipment.inspections.index'); + Route::patch('/inspections/toggle', [EquipmentInspectionController::class, 'toggleDetail'])->name('v1.equipment.inspections.toggle'); + Route::patch('/inspections/set-result', [EquipmentInspectionController::class, 'setResult'])->name('v1.equipment.inspections.set-result'); + Route::patch('/inspections/notes', [EquipmentInspectionController::class, 'updateNotes'])->name('v1.equipment.inspections.notes'); + Route::delete('/inspections/reset', [EquipmentInspectionController::class, 'resetInspection'])->name('v1.equipment.inspections.reset'); + + // 수리이력 + Route::get('/repairs', [EquipmentRepairController::class, 'index'])->name('v1.equipment.repairs.index'); + Route::post('/repairs', [EquipmentRepairController::class, 'store'])->name('v1.equipment.repairs.store'); + Route::put('/repairs/{id}', [EquipmentRepairController::class, 'update'])->whereNumber('id')->name('v1.equipment.repairs.update'); + Route::delete('/repairs/{id}', [EquipmentRepairController::class, 'destroy'])->whereNumber('id')->name('v1.equipment.repairs.destroy'); + + // 사진 + Route::get('/{id}/photos', [EquipmentPhotoController::class, 'index'])->whereNumber('id')->name('v1.equipment.photos.index'); + Route::post('/{id}/photos', [EquipmentPhotoController::class, 'store'])->whereNumber('id')->name('v1.equipment.photos.store'); + Route::delete('/{id}/photos/{fileId}', [EquipmentPhotoController::class, 'destroy'])->whereNumber('id')->name('v1.equipment.photos.destroy'); +}); From f401e17447719a3ff90665d92729ee95d3284f69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 12 Mar 2026 10:55:17 +0900 Subject: [PATCH 155/166] =?UTF-8?q?feat:=20[payroll]=20=EC=97=91=EC=85=80?= =?UTF-8?q?=20=EB=82=B4=EB=B3=B4=EB=82=B4=EA=B8=B0=20=EB=B0=8F=20=EC=A0=84?= =?UTF-8?q?=ED=91=9C=20=EC=83=9D=EC=84=B1=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /payrolls/export: 급여 현황 엑셀 다운로드 (필터 지원) - POST /payrolls/journal-entries: 연월 기준 급여 전표 일괄 생성 - JournalEntry SOURCE_PAYROLL 상수 추가 - StorePayrollJournalRequest 유효성 검증 추가 --- .../Controllers/Api/V1/PayrollController.php | 47 +++- .../V1/Payroll/StorePayrollJournalRequest.php | 31 +++ app/Models/Tenants/JournalEntry.php | 2 + app/Services/PayrollService.php | 255 ++++++++++++++++++ lang/ko/error.php | 1 + lang/ko/message.php | 2 + routes/api/v1/finance.php | 2 + 7 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 app/Http/Requests/V1/Payroll/StorePayrollJournalRequest.php diff --git a/app/Http/Controllers/Api/V1/PayrollController.php b/app/Http/Controllers/Api/V1/PayrollController.php index e1197df..8a0b4dc 100644 --- a/app/Http/Controllers/Api/V1/PayrollController.php +++ b/app/Http/Controllers/Api/V1/PayrollController.php @@ -8,16 +8,20 @@ use App\Http\Requests\V1\Payroll\CalculatePayrollRequest; use App\Http\Requests\V1\Payroll\CopyFromPreviousPayrollRequest; use App\Http\Requests\V1\Payroll\PayPayrollRequest; +use App\Http\Requests\V1\Payroll\StorePayrollJournalRequest; use App\Http\Requests\V1\Payroll\StorePayrollRequest; use App\Http\Requests\V1\Payroll\UpdatePayrollRequest; use App\Http\Requests\V1\Payroll\UpdatePayrollSettingRequest; +use App\Services\ExportService; use App\Services\PayrollService; use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\BinaryFileResponse; class PayrollController extends Controller { public function __construct( - private readonly PayrollService $service + private readonly PayrollService $service, + private readonly ExportService $exportService ) {} /** @@ -218,6 +222,47 @@ public function payslip(int $id) return ApiResponse::success($payslip, __('message.fetched')); } + /** + * 급여 엑셀 내보내기 + */ + public function export(Request $request): BinaryFileResponse + { + $params = $request->only([ + 'year', + 'month', + 'status', + 'user_id', + 'department_id', + 'search', + 'sort_by', + 'sort_dir', + ]); + + $exportData = $this->service->getExportData($params); + $filename = '급여현황_'.date('Ymd_His'); + + return $this->exportService->download( + $exportData['data'], + $exportData['headings'], + $filename, + '급여현황' + ); + } + + /** + * 급여 전표 생성 + */ + public function journalEntries(StorePayrollJournalRequest $request) + { + $year = (int) $request->input('year'); + $month = (int) $request->input('month'); + $entryDate = $request->input('entry_date'); + + $entry = $this->service->createJournalEntries($year, $month, $entryDate); + + return ApiResponse::success($entry, __('message.payroll.journal_created')); + } + /** * 급여 설정 조회 */ diff --git a/app/Http/Requests/V1/Payroll/StorePayrollJournalRequest.php b/app/Http/Requests/V1/Payroll/StorePayrollJournalRequest.php new file mode 100644 index 0000000..b8643cc --- /dev/null +++ b/app/Http/Requests/V1/Payroll/StorePayrollJournalRequest.php @@ -0,0 +1,31 @@ + ['required', 'integer', 'min:2000', 'max:2100'], + 'month' => ['required', 'integer', 'min:1', 'max:12'], + 'entry_date' => ['nullable', 'date_format:Y-m-d'], + ]; + } + + public function attributes(): array + { + return [ + 'year' => __('validation.attributes.pay_year'), + 'month' => __('validation.attributes.pay_month'), + 'entry_date' => __('validation.attributes.entry_date'), + ]; + } +} diff --git a/app/Models/Tenants/JournalEntry.php b/app/Models/Tenants/JournalEntry.php index 6b2b415..ab8c4a9 100644 --- a/app/Models/Tenants/JournalEntry.php +++ b/app/Models/Tenants/JournalEntry.php @@ -52,6 +52,8 @@ class JournalEntry extends Model public const SOURCE_HOMETAX_INVOICE = 'hometax_invoice'; + public const SOURCE_PAYROLL = 'payroll'; + // Entry type public const TYPE_GENERAL = 'general'; diff --git a/app/Services/PayrollService.php b/app/Services/PayrollService.php index a4fdb0b..798d363 100644 --- a/app/Services/PayrollService.php +++ b/app/Services/PayrollService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\Tenants\IncomeTaxBracket; +use App\Models\Tenants\JournalEntry; use App\Models\Tenants\Payroll; use App\Models\Tenants\PayrollSetting; use App\Models\Tenants\TenantUserProfile; @@ -916,4 +917,258 @@ public function resolveFamilyCount(int $userId): int return max(1, min(11, 1 + $dependentCount)); } + + // ========================================================================= + // 엑셀 내보내기 + // ========================================================================= + + /** + * 급여 엑셀 내보내기용 데이터 + */ + public function getExportData(array $params): array + { + $tenantId = $this->tenantId(); + + $query = Payroll::query() + ->where('tenant_id', $tenantId) + ->with(['user:id,name,email', 'user.tenantProfiles' => function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId)->with('department:id,name'); + }]); + + if (! empty($params['year'])) { + $query->where('pay_year', $params['year']); + } + if (! empty($params['month'])) { + $query->where('pay_month', $params['month']); + } + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + if (! empty($params['user_id'])) { + $query->where('user_id', $params['user_id']); + } + if (! empty($params['department_id'])) { + $deptId = $params['department_id']; + $query->whereHas('user.tenantProfiles', function ($q) use ($tenantId, $deptId) { + $q->where('tenant_id', $tenantId)->where('department_id', $deptId); + }); + } + if (! empty($params['search'])) { + $query->whereHas('user', function ($q) use ($params) { + $q->where('name', 'like', "%{$params['search']}%"); + }); + } + + $sortBy = $params['sort_by'] ?? 'pay_year'; + $sortDir = $params['sort_dir'] ?? 'desc'; + + if ($sortBy === 'period') { + $query->orderBy('pay_year', $sortDir)->orderBy('pay_month', $sortDir); + } else { + $query->orderBy($sortBy, $sortDir); + } + + $payrolls = $query->get(); + + $statusLabels = [ + Payroll::STATUS_DRAFT => '작성중', + Payroll::STATUS_CONFIRMED => '확정', + Payroll::STATUS_PAID => '지급완료', + ]; + + $data = $payrolls->map(function ($payroll) use ($statusLabels) { + $profile = $payroll->user?->tenantProfiles?->first(); + $department = $profile?->department?->name ?? '-'; + + return [ + $payroll->pay_year.'년 '.$payroll->pay_month.'월', + $payroll->user?->name ?? '-', + $department, + number_format($payroll->base_salary), + number_format($payroll->overtime_pay), + number_format($payroll->bonus), + number_format($payroll->gross_salary), + number_format($payroll->income_tax), + number_format($payroll->resident_tax), + number_format($payroll->health_insurance), + number_format($payroll->long_term_care), + number_format($payroll->pension), + number_format($payroll->employment_insurance), + number_format($payroll->total_deductions), + number_format($payroll->net_salary), + $statusLabels[$payroll->status] ?? $payroll->status, + ]; + })->toArray(); + + $headings = [ + '급여월', + '직원명', + '부서', + '기본급', + '야근수당', + '상여금', + '총지급액', + '소득세', + '주민세', + '건강보험', + '장기요양', + '국민연금', + '고용보험', + '공제합계', + '실지급액', + '상태', + ]; + + return [ + 'data' => $data, + 'headings' => $headings, + ]; + } + + // ========================================================================= + // 전표 생성 + // ========================================================================= + + /** + * 급여 전표 일괄 생성 + * + * 해당 연월의 확정/지급완료 급여를 합산하여 전표를 생성한다. + * - 차변: 급여 (총지급액) + * - 대변: 각 공제항목 + 미지급금(실지급액) + */ + public function createJournalEntries(int $year, int $month, ?string $entryDate = null): JournalEntry + { + $tenantId = $this->tenantId(); + + $payrolls = Payroll::query() + ->where('tenant_id', $tenantId) + ->forPeriod($year, $month) + ->whereIn('status', [Payroll::STATUS_CONFIRMED, Payroll::STATUS_PAID]) + ->get(); + + if ($payrolls->isEmpty()) { + throw new BadRequestHttpException(__('error.payroll.no_confirmed_payrolls')); + } + + // 합산 + $totalGross = $payrolls->sum('gross_salary'); + $totalIncomeTax = $payrolls->sum('income_tax'); + $totalResidentTax = $payrolls->sum('resident_tax'); + $totalHealthInsurance = $payrolls->sum('health_insurance'); + $totalLongTermCare = $payrolls->sum('long_term_care'); + $totalPension = $payrolls->sum('pension'); + $totalEmploymentInsurance = $payrolls->sum('employment_insurance'); + $totalNet = $payrolls->sum('net_salary'); + + // 전표일자: 지정값 또는 해당월 급여지급일 + if (! $entryDate) { + $settings = PayrollSetting::getOrCreate($tenantId); + $payDay = min($settings->pay_day, 28); + $entryDate = sprintf('%04d-%02d-%02d', $year, $month, $payDay); + } + + $sourceKey = "payroll_{$year}_{$month}"; + $description = "{$year}년 {$month}월 급여"; + + // 분개 행 구성 + $rows = []; + + // 차변: 급여 (총지급액) + $rows[] = [ + 'side' => 'debit', + 'account_code' => '51100', + 'account_name' => '급여', + 'debit_amount' => (int) $totalGross, + 'credit_amount' => 0, + 'memo' => $description, + ]; + + // 대변: 소득세예수금 + if ($totalIncomeTax > 0) { + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25500', + 'account_name' => '예수금-소득세', + 'debit_amount' => 0, + 'credit_amount' => (int) $totalIncomeTax, + ]; + } + + // 대변: 주민세예수금 + if ($totalResidentTax > 0) { + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25501', + 'account_name' => '예수금-주민세', + 'debit_amount' => 0, + 'credit_amount' => (int) $totalResidentTax, + ]; + } + + // 대변: 건강보험예수금 + if ($totalHealthInsurance > 0) { + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25502', + 'account_name' => '예수금-건강보험', + 'debit_amount' => 0, + 'credit_amount' => (int) $totalHealthInsurance, + ]; + } + + // 대변: 장기요양보험예수금 + if ($totalLongTermCare > 0) { + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25503', + 'account_name' => '예수금-장기요양', + 'debit_amount' => 0, + 'credit_amount' => (int) $totalLongTermCare, + ]; + } + + // 대변: 국민연금예수금 + if ($totalPension > 0) { + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25504', + 'account_name' => '예수금-국민연금', + 'debit_amount' => 0, + 'credit_amount' => (int) $totalPension, + ]; + } + + // 대변: 고용보험예수금 + if ($totalEmploymentInsurance > 0) { + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25505', + 'account_name' => '예수금-고용보험', + 'debit_amount' => 0, + 'credit_amount' => (int) $totalEmploymentInsurance, + ]; + } + + // 대변: 미지급금 (실지급액) + if ($totalNet > 0) { + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25300', + 'account_name' => '미지급금', + 'debit_amount' => 0, + 'credit_amount' => (int) $totalNet, + 'memo' => "급여 실지급액 ({$payrolls->count()}명)", + ]; + } + + $syncService = app(JournalSyncService::class); + + return $syncService->saveForSource( + JournalEntry::SOURCE_PAYROLL, + $sourceKey, + $entryDate, + $description, + $rows + ); + } } diff --git a/lang/ko/error.php b/lang/ko/error.php index 4b426e0..a3165cb 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -289,6 +289,7 @@ 'invalid_withdrawal' => '유효하지 않은 출금 내역입니다.', 'user_not_found' => '직원 정보를 찾을 수 없습니다.', 'no_base_salary' => '기본급이 설정되지 않았습니다.', + 'no_confirmed_payrolls' => '해당 연월에 확정된 급여가 없습니다.', ], // 세금계산서 관련 diff --git a/lang/ko/message.php b/lang/ko/message.php index 46159a0..bd1e14a 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -328,6 +328,8 @@ 'copied' => '전월 급여가 복사되었습니다.', 'calculated' => '급여가 일괄 계산되었습니다.', 'payslip_fetched' => '급여명세서를 조회했습니다.', + 'exported' => '급여 현황이 내보내기되었습니다.', + 'journal_created' => '급여 전표가 생성되었습니다.', ], // 급여 설정 관리 diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index d4a580a..0a18dc4 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -103,6 +103,8 @@ Route::post('/bulk-confirm', [PayrollController::class, 'bulkConfirm'])->name('v1.payrolls.bulk-confirm'); Route::post('/bulk-generate', [PayrollController::class, 'bulkGenerate'])->name('v1.payrolls.bulk-generate'); Route::post('/copy-from-previous', [PayrollController::class, 'copyFromPrevious'])->name('v1.payrolls.copy-from-previous'); + Route::get('/export', [PayrollController::class, 'export'])->name('v1.payrolls.export'); + Route::post('/journal-entries', [PayrollController::class, 'journalEntries'])->name('v1.payrolls.journal-entries'); 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'); From 964ee40e8d50456f98864ee7674ca67a944765f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 12 Mar 2026 11:00:15 +0900 Subject: [PATCH 156/166] =?UTF-8?q?fix:=20[equipment]=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=20DB=EC=97=90=20equipment=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=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?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 마이그레이션이 codebridge DB에만 테이블을 생성하는 문제 수정 - 운영서버(API+React)에서는 기본 DB(sam/sam_prod)에 테이블 필요 - hasTable() 체크로 이미 존재하는 환경에서는 건너뜀 - 모든 컬럼 최신 스키마 반영 (inspection_cycle, sub_manager_id, options) - options 마이그레이션도 hasTable/hasColumn 안전 체크 추가 --- ...2_100001_ensure_equipment_tables_exist.php | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 database/migrations/2026_03_12_100001_ensure_equipment_tables_exist.php diff --git a/database/migrations/2026_03_12_100001_ensure_equipment_tables_exist.php b/database/migrations/2026_03_12_100001_ensure_equipment_tables_exist.php new file mode 100644 index 0000000..1bcc97e --- /dev/null +++ b/database/migrations/2026_03_12_100001_ensure_equipment_tables_exist.php @@ -0,0 +1,203 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->string('equipment_code', 20)->comment('설비코드 (KD-M-001 형식)'); + $table->string('name', 100)->comment('설비명'); + $table->string('equipment_type', 50)->nullable()->comment('설비유형'); + $table->string('specification', 255)->nullable()->comment('규격'); + $table->string('manufacturer', 100)->nullable()->comment('제조사'); + $table->string('model_name', 100)->nullable()->comment('모델명'); + $table->string('serial_no', 100)->nullable()->comment('제조번호'); + $table->string('location', 100)->nullable()->comment('위치'); + $table->string('production_line', 50)->nullable()->comment('생산라인'); + $table->date('purchase_date')->nullable()->comment('구입일'); + $table->date('install_date')->nullable()->comment('설치일'); + $table->decimal('purchase_price', 15, 2)->nullable()->comment('구입가격'); + $table->integer('useful_life')->nullable()->comment('내용연수'); + $table->string('status', 20)->default('active')->comment('상태: active/idle/disposed'); + $table->date('disposed_date')->nullable()->comment('폐기일'); + $table->foreignId('manager_id')->nullable()->comment('담당자 ID'); + $table->foreignId('sub_manager_id')->nullable()->comment('부 담당자 ID'); + $table->string('photo_path', 500)->nullable()->comment('설비사진 경로'); + $table->text('memo')->nullable()->comment('비고'); + $table->json('options')->nullable()->comment('확장 속성 JSON'); + $table->tinyInteger('is_active')->default(1)->comment('사용여부'); + $table->integer('sort_order')->default(0)->comment('정렬순서'); + $table->foreignId('created_by')->nullable()->comment('생성자 ID'); + $table->foreignId('updated_by')->nullable()->comment('수정자 ID'); + $table->foreignId('deleted_by')->nullable()->comment('삭제자 ID'); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['tenant_id', 'equipment_code'], 'uq_equipment_code'); + $table->index(['tenant_id', 'status'], 'idx_equipment_status'); + $table->index(['tenant_id', 'production_line'], 'idx_equipment_line'); + $table->index(['tenant_id', 'equipment_type'], 'idx_equipment_type'); + }); + } + + // 2. equipment_inspection_templates (점검항목 템플릿) + if (! Schema::hasTable('equipment_inspection_templates')) { + Schema::create('equipment_inspection_templates', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedBigInteger('equipment_id')->comment('설비 ID'); + $table->string('inspection_cycle', 20)->default('daily') + ->comment('점검주기: daily/weekly/monthly/bimonthly/quarterly/semiannual'); + $table->integer('item_no')->comment('항목번호'); + $table->string('check_point', 50)->comment('점검개소'); + $table->string('check_item', 100)->comment('점검항목'); + $table->string('check_timing', 20)->nullable()->comment('시기: operating/stopped'); + $table->string('check_frequency', 50)->nullable()->comment('주기'); + $table->text('check_method')->nullable()->comment('점검방법 및 기준'); + $table->integer('sort_order')->default(0)->comment('정렬순서'); + $table->tinyInteger('is_active')->default(1)->comment('사용여부'); + $table->timestamps(); + + $table->unique(['equipment_id', 'inspection_cycle', 'item_no'], 'uq_equipment_cycle_item_no'); + $table->index('tenant_id', 'idx_insp_tmpl_tenant'); + $table->index('inspection_cycle', 'idx_insp_tmpl_cycle'); + + $table->foreign('equipment_id') + ->references('id') + ->on('equipments') + ->onDelete('cascade'); + }); + } + + // 3. equipment_inspections (점검 헤더) + if (! Schema::hasTable('equipment_inspections')) { + Schema::create('equipment_inspections', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedBigInteger('equipment_id')->comment('설비 ID'); + $table->string('inspection_cycle', 20)->default('daily') + ->comment('점검주기: daily/weekly/monthly/bimonthly/quarterly/semiannual'); + $table->string('year_month', 7)->comment('점검년월 (2026-02)'); + $table->string('overall_judgment', 10)->nullable()->comment('종합판정: OK/NG'); + $table->foreignId('inspector_id')->nullable()->comment('점검자 ID'); + $table->text('repair_note')->nullable()->comment('수리내역'); + $table->text('issue_note')->nullable()->comment('이상내용'); + $table->foreignId('created_by')->nullable()->comment('생성자 ID'); + $table->foreignId('updated_by')->nullable()->comment('수정자 ID'); + $table->timestamps(); + + $table->unique(['tenant_id', 'equipment_id', 'inspection_cycle', 'year_month'], 'uq_inspection_cycle_period'); + $table->index(['tenant_id', 'inspection_cycle', 'year_month'], 'idx_inspection_cycle_period'); + + $table->foreign('equipment_id') + ->references('id') + ->on('equipments') + ->onDelete('cascade'); + }); + } + + // 4. equipment_inspection_details (점검 상세) + if (! Schema::hasTable('equipment_inspection_details')) { + Schema::create('equipment_inspection_details', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('inspection_id')->comment('점검 헤더 ID'); + $table->unsignedBigInteger('template_item_id')->comment('점검항목 템플릿 ID'); + $table->date('check_date')->comment('점검일'); + $table->string('result', 10)->nullable()->comment('결과: good/bad/repaired'); + $table->string('note', 500)->nullable()->comment('비고'); + $table->timestamps(); + + $table->unique(['inspection_id', 'template_item_id', 'check_date'], 'uq_inspection_detail'); + + $table->foreign('inspection_id') + ->references('id') + ->on('equipment_inspections') + ->onDelete('cascade'); + + $table->foreign('template_item_id') + ->references('id') + ->on('equipment_inspection_templates') + ->onDelete('cascade'); + }); + } + + // 5. equipment_repairs (수리이력) + if (! Schema::hasTable('equipment_repairs')) { + Schema::create('equipment_repairs', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedBigInteger('equipment_id')->comment('설비 ID'); + $table->date('repair_date')->comment('수리일'); + $table->string('repair_type', 20)->comment('보전구분: internal/external'); + $table->decimal('repair_hours', 5, 1)->nullable()->comment('수리시간'); + $table->text('description')->nullable()->comment('수리내용'); + $table->decimal('cost', 15, 2)->nullable()->comment('수리비용'); + $table->string('vendor', 100)->nullable()->comment('외주업체'); + $table->foreignId('repaired_by')->nullable()->comment('수리자 ID'); + $table->text('memo')->nullable()->comment('비고'); + $table->json('options')->nullable()->comment('확장 속성 JSON'); + $table->foreignId('created_by')->nullable()->comment('생성자 ID'); + $table->foreignId('updated_by')->nullable()->comment('수정자 ID'); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['tenant_id', 'repair_date'], 'idx_repair_date'); + $table->index(['tenant_id', 'equipment_id'], 'idx_repair_equipment'); + + $table->foreign('equipment_id') + ->references('id') + ->on('equipments') + ->onDelete('cascade'); + }); + } + + // 6. equipment_process (설비-공정 매핑) + if (! Schema::hasTable('equipment_process')) { + Schema::create('equipment_process', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('equipment_id')->comment('설비 ID'); + $table->unsignedBigInteger('process_id')->comment('공정 ID'); + $table->tinyInteger('is_primary')->default(0)->comment('주 설비 여부'); + $table->timestamps(); + + $table->unique(['equipment_id', 'process_id'], 'uq_equipment_process'); + + $table->foreign('equipment_id') + ->references('id') + ->on('equipments') + ->onDelete('cascade'); + + $table->foreign('process_id') + ->references('id') + ->on('processes') + ->onDelete('cascade'); + }); + } + } + + public function down(): void + { + // 역순으로 삭제 (FK 의존성) + Schema::dropIfExists('equipment_process'); + Schema::dropIfExists('equipment_inspection_details'); + Schema::dropIfExists('equipment_repairs'); + Schema::dropIfExists('equipment_inspections'); + Schema::dropIfExists('equipment_inspection_templates'); + Schema::dropIfExists('equipments'); + } +}; From 8c301b54e3d93526f6a7f2be79c4e1371a1e80de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 12 Mar 2026 11:30:50 +0900 Subject: [PATCH 157/166] =?UTF-8?q?fix:=20[payroll]=20=EC=9D=BC=EA=B4=84?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EC=8B=9C=20=EC=82=AD=EC=A0=9C=EB=90=9C?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B1=B4=EB=84=88=EB=9B=B0?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bulkGenerate에서 users 테이블에 존재하지 않는 user_id로 인한 FK 위반 해결 - whereHas('user')로 유효한 사용자만 조회 --- app/Services/PayrollService.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Services/PayrollService.php b/app/Services/PayrollService.php index 798d363..2413047 100644 --- a/app/Services/PayrollService.php +++ b/app/Services/PayrollService.php @@ -421,6 +421,7 @@ public function bulkGenerate(int $year, int $month): array ->with('user:id,name') ->where('tenant_id', $tenantId) ->where('employee_status', 'active') + ->whereHas('user') ->get(); DB::transaction(function () use ($employees, $tenantId, $year, $month, $settings, $userId, &$created, &$skipped) { From 723b5a8e1a241d54820cc4bd2e65c01e7c0356c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 12 Mar 2026 12:22:58 +0900 Subject: [PATCH 158/166] =?UTF-8?q?feat:=20[pmis]=20pmis=5Fworkers=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 - 건설PMIS 현장 작업자 전용 프로필 테이블 - tenant_id + user_id 유니크 제약 포함 --- ...03_12_120000_create_pmis_workers_table.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 database/migrations/2026_03_12_120000_create_pmis_workers_table.php diff --git a/database/migrations/2026_03_12_120000_create_pmis_workers_table.php b/database/migrations/2026_03_12_120000_create_pmis_workers_table.php new file mode 100644 index 0000000..ffda5f3 --- /dev/null +++ b/database/migrations/2026_03_12_120000_create_pmis_workers_table.php @@ -0,0 +1,38 @@ +id(); + $table->unsignedBigInteger('tenant_id')->default(1)->index(); + $table->unsignedBigInteger('user_id')->nullable()->index()->comment('SAM users 테이블 FK (연결된 계정)'); + $table->string('name', 50); + $table->string('login_id', 50)->nullable()->comment('PMIS 로그인 아이디'); + $table->string('phone', 20)->nullable(); + $table->string('email', 255)->nullable(); + $table->string('department', 100)->nullable()->comment('소속 부서/현장소장 등'); + $table->string('position', 50)->nullable()->comment('직책'); + $table->string('role_type', 50)->nullable()->comment('권한 유형: 협력업체사용자, 원청관리자 등'); + $table->string('gender', 5)->nullable(); + $table->string('company', 100)->nullable()->comment('소속 업체명'); + $table->string('profile_photo_path', 500)->nullable(); + $table->json('options')->nullable(); + $table->timestamp('last_login_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['tenant_id', 'user_id'], 'pmis_workers_tenant_user_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('pmis_workers'); + } +}; From 2d32faa9b502e45db37fff394b35d8d67aeccef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 12 Mar 2026 13:45:15 +0900 Subject: [PATCH 159/166] =?UTF-8?q?refactor:=20[equipment]=20=EC=82=AC?= =?UTF-8?q?=EC=A7=84=20=EC=97=85=EB=A1=9C=EB=93=9C=EB=A5=BC=20R2(FileStora?= =?UTF-8?q?geSystem)=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GCS 스텁 코드를 Cloudflare R2 기반 실제 파일 업로드로 교체 - File 모델 import를 Boards\File에서 Commons\File로 수정 - StoreEquipmentPhotoRequest FormRequest 추가 (파일 검증) - 다중 파일 업로드 지원 (최대 10장 제한) - softDeleteFile 패턴 적용 (삭제 시 soft delete) - ItemsFileController 패턴 준용 (R2 저장, 랜덤 파일명) --- .../V1/Equipment/EquipmentPhotoController.php | 6 +- .../Equipment/StoreEquipmentPhotoRequest.php | 53 +++++++++ app/Models/Equipment/Equipment.php | 2 +- .../Equipment/EquipmentPhotoService.php | 112 +++++++++++++++--- 4 files changed, 150 insertions(+), 23 deletions(-) create mode 100644 app/Http/Requests/Equipment/StoreEquipmentPhotoRequest.php diff --git a/app/Http/Controllers/V1/Equipment/EquipmentPhotoController.php b/app/Http/Controllers/V1/Equipment/EquipmentPhotoController.php index 71ca832..35de41c 100644 --- a/app/Http/Controllers/V1/Equipment/EquipmentPhotoController.php +++ b/app/Http/Controllers/V1/Equipment/EquipmentPhotoController.php @@ -4,9 +4,9 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\Equipment\StoreEquipmentPhotoRequest; use App\Services\Equipment\EquipmentPhotoService; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; class EquipmentPhotoController extends Controller { @@ -20,10 +20,10 @@ public function index(int $id): JsonResponse ); } - public function store(Request $request, int $id): JsonResponse + public function store(StoreEquipmentPhotoRequest $request, int $id): JsonResponse { return ApiResponse::handle( - fn () => $this->service->store($id, $request->all()), + fn () => $this->service->store($id, $request->file('files')), __('message.equipment.photo_uploaded') ); } diff --git a/app/Http/Requests/Equipment/StoreEquipmentPhotoRequest.php b/app/Http/Requests/Equipment/StoreEquipmentPhotoRequest.php new file mode 100644 index 0000000..699859d --- /dev/null +++ b/app/Http/Requests/Equipment/StoreEquipmentPhotoRequest.php @@ -0,0 +1,53 @@ +route('id'); + $currentCount = File::where('document_id', $equipmentId) + ->where('document_type', 'equipment') + ->whereNull('deleted_at') + ->count(); + + $maxFiles = 10 - $currentCount; + + return [ + 'files' => ['required', 'array', 'min:1', "max:{$maxFiles}"], + 'files.*' => [ + 'required', + 'file', + 'mimes:jpg,jpeg,png,gif,bmp,webp', + 'max:10240', // 10MB + ], + ]; + } + + public function attributes(): array + { + return [ + 'files' => '사진 파일', + 'files.*' => '사진 파일', + ]; + } + + public function messages(): array + { + return [ + 'files.required' => __('error.file.required'), + 'files.max' => __('error.equipment.photo_limit_exceeded'), + 'files.*.mimes' => __('error.file.invalid_type'), + 'files.*.max' => __('error.file.size_exceeded'), + ]; + } +} diff --git a/app/Models/Equipment/Equipment.php b/app/Models/Equipment/Equipment.php index 3c7d996..dadf480 100644 --- a/app/Models/Equipment/Equipment.php +++ b/app/Models/Equipment/Equipment.php @@ -2,7 +2,7 @@ namespace App\Models\Equipment; -use App\Models\Boards\File; +use App\Models\Commons\File; use App\Traits\Auditable; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; diff --git a/app/Services/Equipment/EquipmentPhotoService.php b/app/Services/Equipment/EquipmentPhotoService.php index e06cccb..1d6bbe1 100644 --- a/app/Services/Equipment/EquipmentPhotoService.php +++ b/app/Services/Equipment/EquipmentPhotoService.php @@ -2,41 +2,86 @@ namespace App\Services\Equipment; -use App\Models\Boards\File; +use App\Models\Commons\File; use App\Models\Equipment\Equipment; use App\Services\Service; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class EquipmentPhotoService extends Service { - public function index(int $equipmentId): \Illuminate\Database\Eloquent\Collection + private const MAX_PHOTOS = 10; + + public function index(int $equipmentId): array { - $equipment = Equipment::find($equipmentId); + $equipment = $this->getEquipment($equipmentId); - if (! $equipment) { - throw new NotFoundHttpException(__('error.equipment.not_found')); - } - - return $equipment->photos; + return $equipment->photos->map(fn ($file) => $this->formatFileResponse($file))->values()->toArray(); } - public function store(int $equipmentId, array $fileData): File + /** + * @param UploadedFile[] $files + */ + public function store(int $equipmentId, array $files): array { - $equipment = Equipment::find($equipmentId); + $equipment = $this->getEquipment($equipmentId); + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); - if (! $equipment) { - throw new NotFoundHttpException(__('error.equipment.not_found')); + $currentCount = File::where('document_id', $equipmentId) + ->where('document_type', 'equipment') + ->whereNull('deleted_at') + ->count(); + + if ($currentCount + count($files) > self::MAX_PHOTOS) { + throw new \Exception(__('error.equipment.photo_limit_exceeded')); } - return File::create(array_merge($fileData, [ - 'document_id' => $equipmentId, - 'document_type' => 'equipment', - ])); + $uploaded = []; + + foreach ($files as $uploadedFile) { + $extension = $uploadedFile->getClientOriginalExtension(); + $storedName = bin2hex(random_bytes(8)).'.'.$extension; + $displayName = $uploadedFile->getClientOriginalName(); + + $year = date('Y'); + $month = date('m'); + $directory = sprintf('%d/equipment/%s/%s', $tenantId, $year, $month); + $filePath = $directory.'/'.$storedName; + + Storage::disk('r2')->putFileAs($directory, $uploadedFile, $storedName); + + $mimeType = $uploadedFile->getMimeType(); + + $file = File::create([ + 'tenant_id' => $tenantId, + 'display_name' => $displayName, + 'stored_name' => $storedName, + 'file_path' => $filePath, + 'file_size' => $uploadedFile->getSize(), + 'mime_type' => $mimeType, + 'file_type' => 'image', + 'document_id' => $equipmentId, + 'document_type' => 'equipment', + 'is_temp' => false, + 'uploaded_by' => $userId, + 'created_by' => $userId, + ]); + + $uploaded[] = $this->formatFileResponse($file); + } + + return $uploaded; } - public function destroy(int $equipmentId, int $fileId): bool + public function destroy(int $equipmentId, int $fileId): array { - $file = File::where('document_id', $equipmentId) + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $file = File::where('tenant_id', $tenantId) + ->where('document_id', $equipmentId) ->where('document_type', 'equipment') ->where('id', $fileId) ->first(); @@ -45,6 +90,35 @@ public function destroy(int $equipmentId, int $fileId): bool throw new NotFoundHttpException(__('error.file.not_found')); } - return $file->delete(); + $file->softDeleteFile($userId); + + return [ + 'file_id' => $fileId, + 'deleted' => true, + ]; + } + + private function getEquipment(int $equipmentId): Equipment + { + $equipment = Equipment::find($equipmentId); + + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + return $equipment; + } + + private function formatFileResponse(File $file): array + { + return [ + 'id' => $file->id, + 'file_name' => $file->display_name, + 'file_path' => $file->file_path, + 'file_url' => url("/api/v1/files/{$file->id}/download"), + 'file_size' => $file->file_size, + 'mime_type' => $file->mime_type, + 'created_at' => $file->created_at?->format('Y-m-d H:i:s'), + ]; } } From f3849808d579afeb69a73e2701e995ba04c75084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 12 Mar 2026 14:00:43 +0900 Subject: [PATCH 160/166] =?UTF-8?q?feat:=20[QMS]=20=EC=A0=90=EA=B2=80?= =?UTF-8?q?=ED=91=9C=20=ED=86=A0=EA=B8=80=20API=20=EC=B6=94=EA=B0=80=20+?= =?UTF-8?q?=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20AuditChecklist=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8A=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChecklistTemplateController.toggleItem() 추가 (PATCH /{id}/items/{subItemId}/toggle) - ChecklistTemplate 모델 User 클래스 경로 수정 (Members\User) - AuditChecklistController 라우트 제거 (checklist_templates로 통합) Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/ChecklistTemplateController.php | 10 ++++++++++ app/Models/Qualitys/ChecklistTemplate.php | 4 ++-- routes/api/v1/quality.php | 15 ++------------- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/app/Http/Controllers/Api/V1/ChecklistTemplateController.php b/app/Http/Controllers/Api/V1/ChecklistTemplateController.php index a749df7..da1ce14 100644 --- a/app/Http/Controllers/Api/V1/ChecklistTemplateController.php +++ b/app/Http/Controllers/Api/V1/ChecklistTemplateController.php @@ -34,6 +34,16 @@ public function update(SaveChecklistTemplateRequest $request, int $id) }, __('message.updated')); } + /** + * 항목 완료 토글 + */ + public function toggleItem(int $id, string $subItemId) + { + return ApiResponse::handle(function () use ($id, $subItemId) { + return $this->service->toggleItem($id, $subItemId); + }, __('message.updated')); + } + /** * 항목별 파일 목록 조회 */ diff --git a/app/Models/Qualitys/ChecklistTemplate.php b/app/Models/Qualitys/ChecklistTemplate.php index 3201593..4c4698f 100644 --- a/app/Models/Qualitys/ChecklistTemplate.php +++ b/app/Models/Qualitys/ChecklistTemplate.php @@ -33,12 +33,12 @@ class ChecklistTemplate extends Model public function creator(): BelongsTo { - return $this->belongsTo(\App\Models\User::class, 'created_by'); + return $this->belongsTo(\App\Models\Members\User::class, 'created_by'); } public function updater(): BelongsTo { - return $this->belongsTo(\App\Models\User::class, 'updated_by'); + return $this->belongsTo(\App\Models\Members\User::class, 'updated_by'); } /** diff --git a/routes/api/v1/quality.php b/routes/api/v1/quality.php index b266e15..c09c5f1 100644 --- a/routes/api/v1/quality.php +++ b/routes/api/v1/quality.php @@ -7,7 +7,6 @@ * - 실적신고 */ -use App\Http\Controllers\Api\V1\AuditChecklistController; use App\Http\Controllers\Api\V1\ChecklistTemplateController; use App\Http\Controllers\Api\V1\PerformanceReportController; use App\Http\Controllers\Api\V1\QmsLotAuditController; @@ -55,6 +54,7 @@ Route::prefix('quality/checklist-templates')->group(function () { Route::get('', [ChecklistTemplateController::class, 'show'])->name('v1.quality.checklist-templates.show'); Route::put('/{id}', [ChecklistTemplateController::class, 'update'])->whereNumber('id')->name('v1.quality.checklist-templates.update'); + Route::patch('/{id}/items/{subItemId}/toggle', [ChecklistTemplateController::class, 'toggleItem'])->whereNumber('id')->name('v1.quality.checklist-templates.toggle-item'); }); // QMS 점검표 문서 (파일) 관리 @@ -64,15 +64,4 @@ Route::delete('/{id}', [ChecklistTemplateController::class, 'deleteDocument'])->whereNumber('id')->name('v1.quality.qms-documents.destroy'); }); -// QMS 기준/매뉴얼 심사 (1일차) -Route::prefix('qms')->group(function () { - Route::get('/checklists', [AuditChecklistController::class, 'index'])->name('v1.qms.checklists.index'); - Route::post('/checklists', [AuditChecklistController::class, 'store'])->name('v1.qms.checklists.store'); - Route::get('/checklists/{id}', [AuditChecklistController::class, 'show'])->whereNumber('id')->name('v1.qms.checklists.show'); - Route::put('/checklists/{id}', [AuditChecklistController::class, 'update'])->whereNumber('id')->name('v1.qms.checklists.update'); - Route::patch('/checklists/{id}/complete', [AuditChecklistController::class, 'complete'])->whereNumber('id')->name('v1.qms.checklists.complete'); - Route::patch('/checklist-items/{id}/toggle', [AuditChecklistController::class, 'toggleItem'])->whereNumber('id')->name('v1.qms.checklist-items.toggle'); - Route::get('/checklist-items/{id}/documents', [AuditChecklistController::class, 'itemDocuments'])->whereNumber('id')->name('v1.qms.checklist-items.documents'); - Route::post('/checklist-items/{id}/documents', [AuditChecklistController::class, 'attachDocument'])->whereNumber('id')->name('v1.qms.checklist-items.documents.attach'); - Route::delete('/checklist-items/{id}/documents/{docId}', [AuditChecklistController::class, 'detachDocument'])->whereNumber('id')->whereNumber('docId')->name('v1.qms.checklist-items.documents.detach'); -}); +// QMS 기준/매뉴얼 심사 (1일차) — checklist_templates로 통합됨, AuditChecklistController 제거 From f5b60aab384e5e2ef2985214628b72620d7a33db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 12 Mar 2026 14:00:49 +0900 Subject: [PATCH 161/166] =?UTF-8?q?fix:=20[QMS]=20=EB=A1=9C=ED=8A=B8?= =?UTF-8?q?=EC=8B=AC=EC=82=AC=20=EC=84=9C=EB=A5=98=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 작업일지/중간검사: 인식 가능한 공정만 표시, 공정별 그룹핑 - 중간검사 detail: PQC Inspection 대신 WorkOrder 기반으로 변경 - 문서 아이템 표시 개선 (공정명, 작업지시번호, 문서번호 추가) - 루트 정보에 거래처(client) 필드 추가 - location에 document 관계 eager loading 추가 Co-Authored-By: Claude Opus 4.6 --- app/Services/QmsLotAuditService.php | 39 ++++++++++++++++------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/app/Services/QmsLotAuditService.php b/app/Services/QmsLotAuditService.php index 951be48..c776b01 100644 --- a/app/Services/QmsLotAuditService.php +++ b/app/Services/QmsLotAuditService.php @@ -82,13 +82,14 @@ public function show(int $id): array } /** - * 수주 루트별 8종 서류 목록 (Document[]) + * 수주 로트별 8종 서류 목록 (Document[]) */ public function routeDocuments(int $qualityDocumentOrderId): array { $docOrder = QualityDocumentOrder::with([ 'order.workOrders.process', 'locations.orderItem', + 'locations.document', 'qualityDocument', ])->findOrFail($qualityDocumentOrderId); @@ -118,20 +119,15 @@ public function routeDocuments(int $qualityDocumentOrderId): array // 2. 수주서 $documents[] = $this->formatDocument('order', '수주서', collect([$order])); - // 3. 작업일지 (공정별 1개씩 — 같은 공정의 WO는 그룹핑) - $workOrdersByProcess = $workOrders->groupBy('process_id')->map(fn ($group) => $group->first()); - $documents[] = $this->formatDocumentWithSubType('log', '작업일지', $workOrdersByProcess); + // 3. 작업일지 & 4. 중간검사 성적서 (인식 가능한 공정만 — 공정별 1개씩) + $recognizedWorkOrders = $workOrders->filter(function ($wo) { + $subType = $this->mapProcessToSubType($wo->process?->process_name); - // 4. 중간검사 성적서 (PQC — 공정별 1개씩) - $pqcInspections = Inspection::where('inspection_type', 'PQC') - ->whereIn('work_order_id', $workOrders->pluck('id')) - ->with('workOrder.process') - ->get(); + return $subType !== null; + })->groupBy('process_id')->map(fn ($group) => $group->first()); - // 공정별 그룹핑 (같은 공정의 PQC는 최신 1개만) - $pqcByProcess = $pqcInspections->groupBy(fn ($insp) => $insp->workOrder?->process_id) - ->map(fn ($group) => $group->sortByDesc('inspection_date')->first()); - $documents[] = $this->formatDocumentWithSubType('report', '중간검사 성적서', $pqcByProcess, 'workOrder'); + $documents[] = $this->formatDocumentWithSubType('log', '작업일지', $recognizedWorkOrders); + $documents[] = $this->formatDocumentWithSubType('report', '중간검사 성적서', $recognizedWorkOrders); // 5. 납품확인서 $shipments = $order->shipments()->get(); @@ -161,7 +157,7 @@ public function documentDetail(string $type, int $id): array 'import' => $this->getInspectionDetail($id, 'IQC'), 'order' => $this->getOrderDetail($id), 'log' => $this->getWorkOrderLogDetail($id), - 'report' => $this->getInspectionDetail($id, 'PQC'), + 'report' => $this->getWorkOrderLogDetail($id), 'confirmation', 'shipping' => $this->getShipmentDetail($id), 'product' => $this->getLocationDetail($id), 'quality' => $this->getQualityDocDetail($id), @@ -240,6 +236,7 @@ private function transformRouteToFrontend(QualityDocumentOrder $docOrder, Qualit 'id' => (string) $docOrder->id, 'code' => $docOrder->order->order_no, 'date' => $docOrder->order->received_at?->toDateString(), + 'client' => $docOrder->order->client_name ?? '', 'site' => $docOrder->order->site_name ?? '', 'location_count' => $docOrder->locations->count(), 'sub_items' => $docOrder->locations->values()->map(fn ($loc, $idx) => [ @@ -317,12 +314,18 @@ private function formatDocumentWithSubType(string $type, string $title, $collect private function formatDocumentItem(string $type, $item): array { return match ($type) { - 'import', 'report' => [ + 'import' => [ 'id' => (string) $item->id, 'title' => $item->inspection_no ?? '', 'date' => $item->inspection_date?->toDateString() ?? $item->request_date?->toDateString() ?? '', 'code' => $item->inspection_no ?? '', ], + 'report' => [ + 'id' => (string) $item->id, + 'title' => $item->process?->process_name ?? '중간검사 성적서', + 'date' => $item->created_at?->toDateString() ?? '', + 'code' => $item->work_order_no ?? '', + ], 'order' => [ 'id' => (string) $item->id, 'title' => $item->order_no, @@ -331,9 +334,9 @@ private function formatDocumentItem(string $type, $item): array ], 'log' => [ 'id' => (string) $item->id, - 'title' => $item->project_name ?? '작업일지', + 'title' => $item->process?->process_name ?? '작업일지', 'date' => $item->created_at?->toDateString() ?? '', - 'code' => $item->id, + 'code' => $item->work_order_no ?? '', ], 'confirmation', 'shipping' => [ 'id' => (string) $item->id, @@ -345,7 +348,7 @@ private function formatDocumentItem(string $type, $item): array 'id' => (string) $item->id, 'title' => trim(($item->orderItem?->floor_code ?? '').' '.($item->orderItem?->symbol_code ?? '')) ?: '제품검사 성적서', 'date' => $item->updated_at?->toDateString() ?? '', - 'code' => '', + 'code' => $item->document?->document_no ?? '', ], 'quality' => [ 'id' => (string) $item->id, From 57d8b97dde876bc0cc2d739e085eb633a8e6b77c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 12 Mar 2026 14:00:59 +0900 Subject: [PATCH 162/166] =?UTF-8?q?chore:=20[API]=20=EB=AC=B8=EC=84=9C/?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LOGICAL_RELATIONSHIPS.md 관계 정보 추가 - Swagger 서버 설명 변경 - files 테이블 mime_type 컬럼 확장 마이그레이션 Co-Authored-By: Claude Opus 4.6 --- .serena/project.yml | 9 + LOGICAL_RELATIONSHIPS.md | 158 +++++++++++++++++- ...232_alter_files_table_extend_mime_type.php | 28 ++++ storage/api-docs/api-docs-v1.json | 2 +- 4 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 database/migrations/2026_03_11_213232_alter_files_table_extend_mime_type.php diff --git a/.serena/project.yml b/.serena/project.yml index 7a512bc..686b4f7 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -114,3 +114,12 @@ symbol_info_budget: # Note: the backend is fixed at startup. If a project with a different backend # is activated post-init, an error will be returned. language_backend: + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 6fc90b6..be019cf 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-03-06 21:25:05 +> **자동 생성**: 2026-03-12 13:58:25 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -26,6 +26,68 @@ ### bad_debt_memos - **badDebt()**: belongsTo → `bad_debts` - **creator()**: belongsTo → `users` +### barobill_bank_sync_status +**모델**: `App\Models\Barobill\BarobillBankSyncStatus` + +- **tenant()**: belongsTo → `tenants` + +### barobill_bank_transactions +**모델**: `App\Models\Barobill\BarobillBankTransaction` + +- **tenant()**: belongsTo → `tenants` + +### barobill_bank_transaction_splits +**모델**: `App\Models\Barobill\BarobillBankTransactionSplit` + +- **tenant()**: belongsTo → `tenants` + +### barobill_billing_records +**모델**: `App\Models\Barobill\BarobillBillingRecord` + +- **member()**: belongsTo → `barobill_members` + +### barobill_card_transactions +**모델**: `App\Models\Barobill\BarobillCardTransaction` + +- **tenant()**: belongsTo → `tenants` + +### barobill_card_transaction_amount_logs +**모델**: `App\Models\Barobill\BarobillCardTransactionAmountLog` + +- **cardTransaction()**: belongsTo → `barobill_card_transactions` + +### barobill_card_transaction_splits +**모델**: `App\Models\Barobill\BarobillCardTransactionSplit` + +- **tenant()**: belongsTo → `tenants` + +### barobill_members +**모델**: `App\Models\Barobill\BarobillMember` + +- **tenant()**: belongsTo → `tenants` + +### barobill_monthly_summarys +**모델**: `App\Models\Barobill\BarobillMonthlySummary` + +- **member()**: belongsTo → `barobill_members` + +### barobill_subscriptions +**모델**: `App\Models\Barobill\BarobillSubscription` + +- **member()**: belongsTo → `barobill_members` + +### hometax_invoices +**모델**: `App\Models\Barobill\HometaxInvoice` + +- **tenant()**: belongsTo → `tenants` +- **journals()**: hasMany → `hometax_invoice_journals` + +### hometax_invoice_journals +**모델**: `App\Models\Barobill\HometaxInvoiceJournal` + +- **tenant()**: belongsTo → `tenants` +- **invoice()**: belongsTo → `hometax_invoices` + ### biddings **모델**: `App\Models\Bidding\Bidding` @@ -309,6 +371,43 @@ ### esign_signers - **contract()**: belongsTo → `esign_contracts` - **signFields()**: hasMany → `esign_sign_fields` +### equipments +**모델**: `App\Models\Equipment\Equipment` + +- **inspectionTemplates()**: hasMany → `equipment_inspection_templates` +- **inspections()**: hasMany → `equipment_inspections` +- **repairs()**: hasMany → `equipment_repairs` +- **photos()**: hasMany → `files` +- **processes()**: belongsToMany → `processes` + +### equipment_inspections +**모델**: `App\Models\Equipment\EquipmentInspection` + +- **equipment()**: belongsTo → `equipments` +- **details()**: hasMany → `equipment_inspection_details` + +### equipment_inspection_details +**모델**: `App\Models\Equipment\EquipmentInspectionDetail` + +- **inspection()**: belongsTo → `equipment_inspections` +- **templateItem()**: belongsTo → `equipment_inspection_templates` + +### equipment_inspection_templates +**모델**: `App\Models\Equipment\EquipmentInspectionTemplate` + +- **equipment()**: belongsTo → `equipments` + +### equipment_process +**모델**: `App\Models\Equipment\EquipmentProcess` + +- **equipment()**: belongsTo → `equipments` +- **process()**: belongsTo → `processes` + +### equipment_repairs +**모델**: `App\Models\Equipment\EquipmentRepair` + +- **equipment()**: belongsTo → `equipments` + ### estimates **모델**: `App\Models\Estimate\Estimate` @@ -734,6 +833,36 @@ ### push_notification_settings **모델**: `App\Models\PushNotificationSetting` +### audit_checklists +**모델**: `App\Models\Qualitys\AuditChecklist` + +- **categories()**: hasMany → `audit_checklist_categories` + +### audit_checklist_categorys +**모델**: `App\Models\Qualitys\AuditChecklistCategory` + +- **checklist()**: belongsTo → `audit_checklists` +- **items()**: hasMany → `audit_checklist_items` + +### audit_checklist_items +**모델**: `App\Models\Qualitys\AuditChecklistItem` + +- **category()**: belongsTo → `audit_checklist_categories` +- **standardDocuments()**: hasMany → `audit_standard_documents` + +### audit_standard_documents +**모델**: `App\Models\Qualitys\AuditStandardDocument` + +- **checklistItem()**: belongsTo → `audit_checklist_items` +- **document()**: belongsTo → `documents` + +### checklist_templates +**모델**: `App\Models\Qualitys\ChecklistTemplate` + +- **creator()**: belongsTo → `users` +- **updater()**: belongsTo → `users` +- **documents()**: morphMany → `files` + ### inspections **모델**: `App\Models\Qualitys\Inspection` @@ -838,6 +967,11 @@ ### quote_revisions - **quote()**: belongsTo → `quotes` - **reviser()**: belongsTo → `users` +### account_codes +**모델**: `App\Models\Tenants\AccountCode` + +- **children()**: hasMany → `account_codes` + ### ai_reports **모델**: `App\Models\Tenants\AiReport` @@ -857,14 +991,24 @@ ### approvals **모델**: `App\Models\Tenants\Approval` - **form()**: belongsTo → `approval_forms` +- **line()**: belongsTo → `approval_lines` - **drafter()**: belongsTo → `users` +- **department()**: belongsTo → `departments` +- **parentDocument()**: belongsTo → `approvals` - **creator()**: belongsTo → `users` - **updater()**: belongsTo → `users` +- **childDocuments()**: hasMany → `approvals` - **steps()**: hasMany → `approval_steps` - **approverSteps()**: hasMany → `approval_steps` - **referenceSteps()**: hasMany → `approval_steps` - **linkable()**: morphTo → `(Polymorphic)` +### approval_delegations +**모델**: `App\Models\Tenants\ApprovalDelegation` + +- **delegator()**: belongsTo → `users` +- **delegate()**: belongsTo → `users` + ### approval_forms **모델**: `App\Models\Tenants\ApprovalForm` @@ -883,6 +1027,7 @@ ### approval_steps - **approval()**: belongsTo → `approvals` - **approver()**: belongsTo → `users` +- **actedBy()**: belongsTo → `users` ### attendances **모델**: `App\Models\Tenants\Attendance` @@ -1004,6 +1149,11 @@ ### loans - **creator()**: belongsTo → `users` - **updater()**: belongsTo → `users` +### mail_logs +**모델**: `App\Models\Tenants\MailLog` + +- **tenant()**: belongsTo → `tenants` + ### payments **모델**: `App\Models\Tenants\Payment` @@ -1046,6 +1196,7 @@ ### receivings **모델**: `App\Models\Tenants\Receiving` - **item()**: belongsTo → `items` +- **certificateFile()**: belongsTo → `files` - **creator()**: belongsTo → `users` ### salarys @@ -1167,6 +1318,11 @@ ### tenant_field_settings - **fieldDef()**: belongsTo → `setting_field_defs` - **optionGroup()**: belongsTo → `tenant_option_groups` +### tenant_mail_configs +**모델**: `App\Models\Tenants\TenantMailConfig` + +- **tenant()**: belongsTo → `tenants` + ### tenant_option_groups **모델**: `App\Models\Tenants\TenantOptionGroup` diff --git a/database/migrations/2026_03_11_213232_alter_files_table_extend_mime_type.php b/database/migrations/2026_03_11_213232_alter_files_table_extend_mime_type.php new file mode 100644 index 0000000..d6f807e --- /dev/null +++ b/database/migrations/2026_03_11_213232_alter_files_table_extend_mime_type.php @@ -0,0 +1,28 @@ +string('mime_type', 150)->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('files', function (Blueprint $table) { + $table->string('mime_type', 50)->change(); + }); + } +}; diff --git a/storage/api-docs/api-docs-v1.json b/storage/api-docs/api-docs-v1.json index 92d16ed..cb709d1 100755 --- a/storage/api-docs/api-docs-v1.json +++ b/storage/api-docs/api-docs-v1.json @@ -11,7 +11,7 @@ "servers": [ { "url": "https://api.sam.kr/", - "description": "SAM API 서버" + "description": "SAM관리시스템 API 서버" } ], "paths": { From 76e098337f9dcacf17ac44e1a592401476536c33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 12 Mar 2026 14:02:50 +0900 Subject: [PATCH 163/166] =?UTF-8?q?feat:=20[pmis]=20=EC=8B=9C=EA=B3=B5?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=9D=B8=EC=9B=90=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=83=9D=EC=84=B1=20(job=5Ftypes?= =?UTF-8?q?,=20construction=5Fworkers)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._12_140000_create_pmis_job_types_table.php | 30 ++++++++++++++ ...create_pmis_construction_workers_table.php | 39 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 database/migrations/2026_03_12_140000_create_pmis_job_types_table.php create mode 100644 database/migrations/2026_03_12_140001_create_pmis_construction_workers_table.php diff --git a/database/migrations/2026_03_12_140000_create_pmis_job_types_table.php b/database/migrations/2026_03_12_140000_create_pmis_job_types_table.php new file mode 100644 index 0000000..7fcaecd --- /dev/null +++ b/database/migrations/2026_03_12_140000_create_pmis_job_types_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('name', 100); + $table->integer('sort_order')->default(0); + $table->boolean('is_active')->default(true); + $table->json('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('tenant_id'); + $table->index(['tenant_id', 'name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('pmis_job_types'); + } +}; diff --git a/database/migrations/2026_03_12_140001_create_pmis_construction_workers_table.php b/database/migrations/2026_03_12_140001_create_pmis_construction_workers_table.php new file mode 100644 index 0000000..d2c021e --- /dev/null +++ b/database/migrations/2026_03_12_140001_create_pmis_construction_workers_table.php @@ -0,0 +1,39 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('company_name', 200); + $table->string('trade_name', 100); + $table->unsignedBigInteger('job_type_id')->nullable(); + $table->string('name', 50); + $table->string('phone', 20)->nullable(); + $table->string('birth_date', 6)->nullable()->comment('YYMMDD'); + $table->char('ssn_gender', 1)->nullable()->comment('주민번호 뒷자리 첫째'); + $table->unsignedInteger('wage')->default(0); + $table->string('blood_type', 5)->nullable(); + $table->text('remark')->nullable(); + $table->json('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('tenant_id'); + $table->index(['tenant_id', 'company_name']); + $table->index(['tenant_id', 'name']); + $table->foreign('job_type_id')->references('id')->on('pmis_job_types')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('pmis_construction_workers'); + } +}; From 9ad76ceb82d0599addb8182ab4b2ccd07eb82657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 12 Mar 2026 14:13:34 +0900 Subject: [PATCH 164/166] =?UTF-8?q?feat:=20[pmis]=20=EC=9E=A5=EB=B9=84?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20(pmis=5Fequipments)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...12_150000_create_pmis_equipments_table.php | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 database/migrations/2026_03_12_150000_create_pmis_equipments_table.php diff --git a/database/migrations/2026_03_12_150000_create_pmis_equipments_table.php b/database/migrations/2026_03_12_150000_create_pmis_equipments_table.php new file mode 100644 index 0000000..3042742 --- /dev/null +++ b/database/migrations/2026_03_12_150000_create_pmis_equipments_table.php @@ -0,0 +1,39 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('company_name', 200); + $table->string('equipment_code', 50)->nullable(); + $table->string('equipment_name', 200); + $table->string('specification', 300)->nullable(); + $table->string('unit', 50)->nullable(); + $table->string('equipment_number', 100); + $table->string('operator', 50)->nullable()->comment('운전원'); + $table->date('inspection_end_date')->nullable()->comment('검사종료일'); + $table->boolean('inspection_not_applicable')->default(false)->comment('검사 해당없음'); + $table->date('insurance_end_date')->nullable()->comment('보험종료일'); + $table->boolean('insurance_not_applicable')->default(false)->comment('보험 해당없음'); + $table->json('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('tenant_id'); + $table->index(['tenant_id', 'company_name']); + $table->index(['tenant_id', 'equipment_name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('pmis_equipments'); + } +}; From cefad468b9ead0325642110fb01c1ff25d9f91e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 12 Mar 2026 14:37:41 +0900 Subject: [PATCH 165/166] =?UTF-8?q?feat:=20[pmis]=20=EC=9E=90=EC=9E=AC?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=ED=85=8C=EC=9D=B4=EB=B8=94=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 --- ..._12_160000_create_pmis_materials_table.php | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 database/migrations/2026_03_12_160000_create_pmis_materials_table.php diff --git a/database/migrations/2026_03_12_160000_create_pmis_materials_table.php b/database/migrations/2026_03_12_160000_create_pmis_materials_table.php new file mode 100644 index 0000000..826429b --- /dev/null +++ b/database/migrations/2026_03_12_160000_create_pmis_materials_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('company_name', 200); + $table->string('material_code', 50)->nullable(); + $table->string('material_name', 200); + $table->string('specification', 300)->nullable(); + $table->string('unit', 50)->nullable(); + $table->decimal('design_quantity', 14, 2)->default(0)->comment('설계량'); + $table->json('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('tenant_id'); + $table->index(['tenant_id', 'company_name']); + $table->index(['tenant_id', 'material_name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('pmis_materials'); + } +}; From 88d919261816b0a18b0835bf66370aba8c5b000e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 12 Mar 2026 14:43:51 +0900 Subject: [PATCH 166/166] =?UTF-8?q?refactor:=20[pmis]=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=EC=9D=84=20MNG=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=EB=A1=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PMIS 테이블은 MNG 전용이므로 API에서 제거 - pmis_workers, pmis_job_types, pmis_construction_workers, pmis_equipments, pmis_materials --- ...03_12_120000_create_pmis_workers_table.php | 38 ------------------ ..._12_140000_create_pmis_job_types_table.php | 30 -------------- ...create_pmis_construction_workers_table.php | 39 ------------------- ...12_150000_create_pmis_equipments_table.php | 39 ------------------- ..._12_160000_create_pmis_materials_table.php | 34 ---------------- 5 files changed, 180 deletions(-) delete mode 100644 database/migrations/2026_03_12_120000_create_pmis_workers_table.php delete mode 100644 database/migrations/2026_03_12_140000_create_pmis_job_types_table.php delete mode 100644 database/migrations/2026_03_12_140001_create_pmis_construction_workers_table.php delete mode 100644 database/migrations/2026_03_12_150000_create_pmis_equipments_table.php delete mode 100644 database/migrations/2026_03_12_160000_create_pmis_materials_table.php diff --git a/database/migrations/2026_03_12_120000_create_pmis_workers_table.php b/database/migrations/2026_03_12_120000_create_pmis_workers_table.php deleted file mode 100644 index ffda5f3..0000000 --- a/database/migrations/2026_03_12_120000_create_pmis_workers_table.php +++ /dev/null @@ -1,38 +0,0 @@ -id(); - $table->unsignedBigInteger('tenant_id')->default(1)->index(); - $table->unsignedBigInteger('user_id')->nullable()->index()->comment('SAM users 테이블 FK (연결된 계정)'); - $table->string('name', 50); - $table->string('login_id', 50)->nullable()->comment('PMIS 로그인 아이디'); - $table->string('phone', 20)->nullable(); - $table->string('email', 255)->nullable(); - $table->string('department', 100)->nullable()->comment('소속 부서/현장소장 등'); - $table->string('position', 50)->nullable()->comment('직책'); - $table->string('role_type', 50)->nullable()->comment('권한 유형: 협력업체사용자, 원청관리자 등'); - $table->string('gender', 5)->nullable(); - $table->string('company', 100)->nullable()->comment('소속 업체명'); - $table->string('profile_photo_path', 500)->nullable(); - $table->json('options')->nullable(); - $table->timestamp('last_login_at')->nullable(); - $table->timestamps(); - $table->softDeletes(); - - $table->unique(['tenant_id', 'user_id'], 'pmis_workers_tenant_user_unique'); - }); - } - - public function down(): void - { - Schema::dropIfExists('pmis_workers'); - } -}; diff --git a/database/migrations/2026_03_12_140000_create_pmis_job_types_table.php b/database/migrations/2026_03_12_140000_create_pmis_job_types_table.php deleted file mode 100644 index 7fcaecd..0000000 --- a/database/migrations/2026_03_12_140000_create_pmis_job_types_table.php +++ /dev/null @@ -1,30 +0,0 @@ -id(); - $table->unsignedBigInteger('tenant_id'); - $table->string('name', 100); - $table->integer('sort_order')->default(0); - $table->boolean('is_active')->default(true); - $table->json('options')->nullable(); - $table->timestamps(); - $table->softDeletes(); - - $table->index('tenant_id'); - $table->index(['tenant_id', 'name']); - }); - } - - public function down(): void - { - Schema::dropIfExists('pmis_job_types'); - } -}; diff --git a/database/migrations/2026_03_12_140001_create_pmis_construction_workers_table.php b/database/migrations/2026_03_12_140001_create_pmis_construction_workers_table.php deleted file mode 100644 index d2c021e..0000000 --- a/database/migrations/2026_03_12_140001_create_pmis_construction_workers_table.php +++ /dev/null @@ -1,39 +0,0 @@ -id(); - $table->unsignedBigInteger('tenant_id'); - $table->string('company_name', 200); - $table->string('trade_name', 100); - $table->unsignedBigInteger('job_type_id')->nullable(); - $table->string('name', 50); - $table->string('phone', 20)->nullable(); - $table->string('birth_date', 6)->nullable()->comment('YYMMDD'); - $table->char('ssn_gender', 1)->nullable()->comment('주민번호 뒷자리 첫째'); - $table->unsignedInteger('wage')->default(0); - $table->string('blood_type', 5)->nullable(); - $table->text('remark')->nullable(); - $table->json('options')->nullable(); - $table->timestamps(); - $table->softDeletes(); - - $table->index('tenant_id'); - $table->index(['tenant_id', 'company_name']); - $table->index(['tenant_id', 'name']); - $table->foreign('job_type_id')->references('id')->on('pmis_job_types')->nullOnDelete(); - }); - } - - public function down(): void - { - Schema::dropIfExists('pmis_construction_workers'); - } -}; diff --git a/database/migrations/2026_03_12_150000_create_pmis_equipments_table.php b/database/migrations/2026_03_12_150000_create_pmis_equipments_table.php deleted file mode 100644 index 3042742..0000000 --- a/database/migrations/2026_03_12_150000_create_pmis_equipments_table.php +++ /dev/null @@ -1,39 +0,0 @@ -id(); - $table->unsignedBigInteger('tenant_id'); - $table->string('company_name', 200); - $table->string('equipment_code', 50)->nullable(); - $table->string('equipment_name', 200); - $table->string('specification', 300)->nullable(); - $table->string('unit', 50)->nullable(); - $table->string('equipment_number', 100); - $table->string('operator', 50)->nullable()->comment('운전원'); - $table->date('inspection_end_date')->nullable()->comment('검사종료일'); - $table->boolean('inspection_not_applicable')->default(false)->comment('검사 해당없음'); - $table->date('insurance_end_date')->nullable()->comment('보험종료일'); - $table->boolean('insurance_not_applicable')->default(false)->comment('보험 해당없음'); - $table->json('options')->nullable(); - $table->timestamps(); - $table->softDeletes(); - - $table->index('tenant_id'); - $table->index(['tenant_id', 'company_name']); - $table->index(['tenant_id', 'equipment_name']); - }); - } - - public function down(): void - { - Schema::dropIfExists('pmis_equipments'); - } -}; diff --git a/database/migrations/2026_03_12_160000_create_pmis_materials_table.php b/database/migrations/2026_03_12_160000_create_pmis_materials_table.php deleted file mode 100644 index 826429b..0000000 --- a/database/migrations/2026_03_12_160000_create_pmis_materials_table.php +++ /dev/null @@ -1,34 +0,0 @@ -id(); - $table->unsignedBigInteger('tenant_id'); - $table->string('company_name', 200); - $table->string('material_code', 50)->nullable(); - $table->string('material_name', 200); - $table->string('specification', 300)->nullable(); - $table->string('unit', 50)->nullable(); - $table->decimal('design_quantity', 14, 2)->default(0)->comment('설계량'); - $table->json('options')->nullable(); - $table->timestamps(); - $table->softDeletes(); - - $table->index('tenant_id'); - $table->index(['tenant_id', 'company_name']); - $table->index(['tenant_id', 'material_name']); - }); - } - - public function down(): void - { - Schema::dropIfExists('pmis_materials'); - } -};