From 7162fc2b46b01c3e1bc350d01feae4feac00d910 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 22 Jan 2026 22:47:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A7=A4=EC=9E=85=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=ED=92=88=EC=9D=98=EC=84=9C/=EC=A7=80=EC=B6=9C=EA=B2=B0?= =?UTF-8?q?=EC=9D=98=EC=84=9C=20=EC=97=B0=EB=8F=99=20=EA=B8=B0=EB=8A=A5=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 - purchases 테이블에 approval_id 컬럼 추가 (마이그레이션) - Purchase 모델에 approval 관계 정의 - PurchaseService에서 approval 데이터 eager loading 구현 - FormRequest에 approval_id 유효성 검증 추가 - Swagger 문서에 approval 관련 스키마 추가 Co-Authored-By: Claude Opus 4.5 --- .../V1/Purchase/StorePurchaseRequest.php | 1 + .../V1/Purchase/UpdatePurchaseRequest.php | 1 + app/Models/Tenants/Purchase.php | 10 ++ app/Services/PurchaseService.php | 125 +++++++++++++++++- app/Swagger/v1/PurchaseApi.php | 90 ++++++++++++- ...000_add_approval_id_to_purchases_table.php | 34 +++++ 6 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 database/migrations/2025_01_22_100000_add_approval_id_to_purchases_table.php diff --git a/app/Http/Requests/V1/Purchase/StorePurchaseRequest.php b/app/Http/Requests/V1/Purchase/StorePurchaseRequest.php index d051e81..e2b0e68 100644 --- a/app/Http/Requests/V1/Purchase/StorePurchaseRequest.php +++ b/app/Http/Requests/V1/Purchase/StorePurchaseRequest.php @@ -22,6 +22,7 @@ public function rules(): array 'purchase_type' => ['nullable', 'string', 'max:50'], 'description' => ['nullable', 'string', 'max:1000'], 'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'], + 'approval_id' => ['nullable', 'integer', 'exists:approvals,id'], ]; } diff --git a/app/Http/Requests/V1/Purchase/UpdatePurchaseRequest.php b/app/Http/Requests/V1/Purchase/UpdatePurchaseRequest.php index 377796a..46d00cb 100644 --- a/app/Http/Requests/V1/Purchase/UpdatePurchaseRequest.php +++ b/app/Http/Requests/V1/Purchase/UpdatePurchaseRequest.php @@ -22,6 +22,7 @@ public function rules(): array 'purchase_type' => ['nullable', 'string', 'max:50'], 'description' => ['nullable', 'string', 'max:1000'], 'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'], + 'approval_id' => ['nullable', 'integer', 'exists:approvals,id'], 'tax_invoice_received' => ['sometimes', 'boolean'], ]; } diff --git a/app/Models/Tenants/Purchase.php b/app/Models/Tenants/Purchase.php index d72f68b..7d26e2e 100644 --- a/app/Models/Tenants/Purchase.php +++ b/app/Models/Tenants/Purchase.php @@ -23,6 +23,7 @@ class Purchase extends Model 'status', 'purchase_type', 'withdrawal_id', + 'approval_id', 'tax_invoice_received', 'created_by', 'updated_by', @@ -58,6 +59,7 @@ class Purchase extends Model 'total_amount' => 'decimal:2', 'client_id' => 'integer', 'withdrawal_id' => 'integer', + 'approval_id' => 'integer', 'tax_invoice_received' => 'boolean', ]; @@ -85,6 +87,14 @@ public function withdrawal(): BelongsTo return $this->belongsTo(Withdrawal::class); } + /** + * 연결된 품의서/지출결의서 + */ + public function approval(): BelongsTo + { + return $this->belongsTo(Approval::class); + } + /** * 생성자 관계 */ diff --git a/app/Services/PurchaseService.php b/app/Services/PurchaseService.php index 782fa08..5918eb6 100644 --- a/app/Services/PurchaseService.php +++ b/app/Services/PurchaseService.php @@ -17,7 +17,7 @@ public function index(array $params): LengthAwarePaginator $query = Purchase::query() ->where('tenant_id', $tenantId) - ->with(['client:id,name']); + ->with(['client:id,name', 'approval:id,document_number,title,form_id', 'approval.form:id,name,category']); // 검색어 필터 if (! empty($params['search'])) { @@ -69,7 +69,7 @@ public function show(int $id): Purchase return Purchase::query() ->where('tenant_id', $tenantId) - ->with(['client:id,name', 'withdrawal', 'creator:id,name']) + ->with(['client:id,name', 'withdrawal', 'creator:id,name', 'approval:id,document_number,title,form_id,content', 'approval.form:id,name,category']) ->findOrFail($id); } @@ -97,6 +97,7 @@ public function store(array $data): Purchase $purchase->description = $data['description'] ?? null; $purchase->status = 'draft'; $purchase->withdrawal_id = $data['withdrawal_id'] ?? null; + $purchase->approval_id = $data['approval_id'] ?? null; $purchase->created_by = $userId; $purchase->updated_by = $userId; $purchase->save(); @@ -154,6 +155,9 @@ public function update(int $id, array $data): Purchase if (array_key_exists('tax_invoice_received', $data)) { $purchase->tax_invoice_received = $data['tax_invoice_received']; } + if (array_key_exists('approval_id', $data)) { + $purchase->approval_id = $data['approval_id']; + } $purchase->updated_by = $userId; $purchase->save(); @@ -302,6 +306,123 @@ public function bulkUpdateTaxReceived(array $ids, bool $taxInvoiceReceived): int ]); } + /** + * 대시보드 상세 조회 (CEO 대시보드 모달용) + * + * @return array{ + * summary: array{current_month_total: float, previous_month_total: float, change_rate: float, count: int}, + * monthly_trend: array, + * by_type: array, + * items: array + * } + */ + public function dashboardDetail(): array + { + $tenantId = $this->tenantId(); + + // 현재 월 범위 + $currentMonthStart = now()->startOfMonth()->toDateString(); + $currentMonthEnd = now()->endOfMonth()->toDateString(); + + // 전월 범위 + $previousMonthStart = now()->subMonth()->startOfMonth()->toDateString(); + $previousMonthEnd = now()->subMonth()->endOfMonth()->toDateString(); + + // 1. 요약 정보 + $currentMonthTotal = Purchase::query() + ->where('tenant_id', $tenantId) + ->whereBetween('purchase_date', [$currentMonthStart, $currentMonthEnd]) + ->sum('total_amount'); + + $previousMonthTotal = Purchase::query() + ->where('tenant_id', $tenantId) + ->whereBetween('purchase_date', [$previousMonthStart, $previousMonthEnd]) + ->sum('total_amount'); + + $currentMonthCount = Purchase::query() + ->where('tenant_id', $tenantId) + ->whereBetween('purchase_date', [$currentMonthStart, $currentMonthEnd]) + ->count(); + + $changeRate = $previousMonthTotal > 0 + ? round((($currentMonthTotal - $previousMonthTotal) / $previousMonthTotal) * 100, 1) + : 0; + + // 2. 월별 추이 (최근 7개월) + $monthlyTrend = []; + for ($i = 6; $i >= 0; $i--) { + $monthStart = now()->subMonths($i)->startOfMonth(); + $monthEnd = now()->subMonths($i)->endOfMonth(); + + $amount = Purchase::query() + ->where('tenant_id', $tenantId) + ->whereBetween('purchase_date', [$monthStart->toDateString(), $monthEnd->toDateString()]) + ->sum('total_amount'); + + $monthlyTrend[] = [ + 'month' => $monthStart->format('Y-m'), + 'amount' => (float) $amount, + ]; + } + + // 3. 유형별 분포 (현재 월) + $byTypeRaw = Purchase::query() + ->where('tenant_id', $tenantId) + ->whereBetween('purchase_date', [$currentMonthStart, $currentMonthEnd]) + ->select('purchase_type', DB::raw('SUM(total_amount) as amount')) + ->groupBy('purchase_type') + ->get(); + + $byType = []; + $totalAmount = $byTypeRaw->sum('amount'); + + foreach ($byTypeRaw as $item) { + $type = $item->purchase_type ?? 'unset'; + $byType[] = [ + 'type' => $type, + 'label' => Purchase::PURCHASE_TYPES[$type] ?? '미설정', + 'amount' => (float) $item->amount, + 'ratio' => $totalAmount > 0 ? round(($item->amount / $totalAmount) * 100, 1) : 0, + ]; + } + + // ratio 내림차순 정렬 + usort($byType, fn ($a, $b) => $b['ratio'] <=> $a['ratio']); + + // 4. 일별 매입 내역 (현재 월) + $items = Purchase::query() + ->where('tenant_id', $tenantId) + ->whereBetween('purchase_date', [$currentMonthStart, $currentMonthEnd]) + ->with(['client:id,name']) + ->orderBy('purchase_date', 'desc') + ->get() + ->map(function ($purchase) { + $type = $purchase->purchase_type ?? 'unset'; + + return [ + 'id' => $purchase->id, + 'date' => $purchase->purchase_date->format('Y-m-d'), + 'vendor_name' => $purchase->client?->name ?? '-', + 'amount' => (float) $purchase->total_amount, + 'type' => $type, + 'type_label' => Purchase::PURCHASE_TYPES[$type] ?? '미설정', + ]; + }) + ->toArray(); + + return [ + 'summary' => [ + 'current_month_total' => (float) $currentMonthTotal, + 'previous_month_total' => (float) $previousMonthTotal, + 'change_rate' => $changeRate, + 'count' => $currentMonthCount, + ], + 'monthly_trend' => $monthlyTrend, + 'by_type' => $byType, + 'items' => $items, + ]; + } + /** * 매입번호 자동 생성 */ diff --git a/app/Swagger/v1/PurchaseApi.php b/app/Swagger/v1/PurchaseApi.php index b4d78e8..8b2bcc5 100644 --- a/app/Swagger/v1/PurchaseApi.php +++ b/app/Swagger/v1/PurchaseApi.php @@ -21,11 +21,22 @@ * @OA\Property(property="description", type="string", example="1월 매입", nullable=true, description="적요"), * @OA\Property(property="status", type="string", enum={"draft","confirmed"}, example="draft", description="상태"), * @OA\Property(property="withdrawal_id", type="integer", example=1, nullable=true, description="출금 연결 ID"), + * @OA\Property(property="approval_id", type="integer", example=1, nullable=true, description="연결된 품의서/지출결의서 ID"), * @OA\Property(property="client", type="object", nullable=true, * @OA\Property(property="id", type="integer", example=1), * @OA\Property(property="name", type="string", example="(주)공급사"), * description="거래처 정보" * ), + * @OA\Property(property="approval", type="object", nullable=true, description="연결된 품의서/지출결의서 정보", + * @OA\Property(property="id", type="integer", example=1), + * @OA\Property(property="document_number", type="string", example="AP202501150001"), + * @OA\Property(property="title", type="string", example="원자재 구매 품의"), + * @OA\Property(property="form", type="object", nullable=true, + * @OA\Property(property="id", type="integer", example=1), + * @OA\Property(property="name", type="string", example="품의서"), + * @OA\Property(property="category", type="string", example="proposal") + * ) + * ), * @OA\Property(property="created_by", type="integer", example=1, nullable=true, description="생성자 ID"), * @OA\Property(property="created_at", type="string", format="date-time"), * @OA\Property(property="updated_at", type="string", format="date-time") @@ -43,7 +54,8 @@ * @OA\Property(property="tax_amount", type="number", format="float", example=100000, description="세액"), * @OA\Property(property="total_amount", type="number", format="float", example=1100000, description="합계"), * @OA\Property(property="description", type="string", example="1월 매입", maxLength=1000, nullable=true, description="적요"), - * @OA\Property(property="withdrawal_id", type="integer", example=1, nullable=true, description="출금 연결 ID") + * @OA\Property(property="withdrawal_id", type="integer", example=1, nullable=true, description="출금 연결 ID"), + * @OA\Property(property="approval_id", type="integer", example=1, nullable=true, description="연결할 품의서/지출결의서 ID") * ) * * @OA\Schema( @@ -57,7 +69,8 @@ * @OA\Property(property="tax_amount", type="number", format="float", example=100000, description="세액"), * @OA\Property(property="total_amount", type="number", format="float", example=1100000, description="합계"), * @OA\Property(property="description", type="string", example="1월 매입", maxLength=1000, nullable=true, description="적요"), - * @OA\Property(property="withdrawal_id", type="integer", example=1, nullable=true, description="출금 연결 ID") + * @OA\Property(property="withdrawal_id", type="integer", example=1, nullable=true, description="출금 연결 ID"), + * @OA\Property(property="approval_id", type="integer", example=1, nullable=true, description="연결할 품의서/지출결의서 ID") * ) * * @OA\Schema( @@ -80,6 +93,49 @@ * ) * ) * ) + * + * @OA\Schema( + * schema="PurchaseDashboardDetail", + * type="object", + * description="매입 대시보드 상세 (CEO 대시보드 모달용)", + * + * @OA\Property(property="summary", type="object", description="요약 정보", + * @OA\Property(property="current_month_total", type="number", format="float", example=305000000, description="당월 매입 합계"), + * @OA\Property(property="previous_month_total", type="number", format="float", example=276000000, description="전월 매입 합계"), + * @OA\Property(property="change_rate", type="number", format="float", example=10.5, description="전월 대비 변화율 (%)"), + * @OA\Property(property="count", type="integer", example=45, description="당월 매입 건수") + * ), + * @OA\Property(property="monthly_trend", type="array", description="월별 추이 (최근 7개월)", + * + * @OA\Items(type="object", + * + * @OA\Property(property="month", type="string", example="2026-01", description="월"), + * @OA\Property(property="amount", type="number", format="float", example=280000000, description="매입 합계") + * ) + * ), + * @OA\Property(property="by_type", type="array", description="유형별 분포", + * + * @OA\Items(type="object", + * + * @OA\Property(property="type", type="string", example="raw_material", description="유형 코드"), + * @OA\Property(property="label", type="string", example="원재료매입", description="유형 라벨"), + * @OA\Property(property="amount", type="number", format="float", example=180000000, description="합계"), + * @OA\Property(property="ratio", type="number", format="float", example=59.0, description="비율 (%)") + * ) + * ), + * @OA\Property(property="items", type="array", description="일별 매입 내역", + * + * @OA\Items(type="object", + * + * @OA\Property(property="id", type="integer", example=1, description="매입 ID"), + * @OA\Property(property="date", type="string", format="date", example="2026-01-15", description="매입일"), + * @OA\Property(property="vendor_name", type="string", example="대한철강", description="거래처명"), + * @OA\Property(property="amount", type="number", format="float", example=15000000, description="매입금액"), + * @OA\Property(property="type", type="string", example="raw_material", description="매입유형 코드"), + * @OA\Property(property="type_label", type="string", example="원재료매입", description="매입유형 라벨") + * ) + * ) + * ) */ class PurchaseApi { @@ -202,6 +258,36 @@ public function store() {} */ public function summary() {} + /** + * @OA\Get( + * path="/api/v1/purchases/dashboard-detail", + * tags={"Purchases"}, + * summary="매입 대시보드 상세 조회", + * description="CEO 대시보드 모달용 매입 상세 데이터를 조회합니다. 당월 요약, 월별 추이, 유형별 분포, 일별 내역을 반환합니다.", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Response( + * response=200, + * description="조회 성공", + * + * @OA\JsonContent( + * allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema( + * + * @OA\Property(property="data", ref="#/components/schemas/PurchaseDashboardDetail") + * ) + * } + * ) + * ), + * + * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function dashboardDetail() {} + /** * @OA\Get( * path="/api/v1/purchases/{id}", diff --git a/database/migrations/2025_01_22_100000_add_approval_id_to_purchases_table.php b/database/migrations/2025_01_22_100000_add_approval_id_to_purchases_table.php new file mode 100644 index 0000000..05bcbe5 --- /dev/null +++ b/database/migrations/2025_01_22_100000_add_approval_id_to_purchases_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('approval_id') + ->nullable() + ->after('withdrawal_id') + ->comment('연결된 품의서/지출결의서 ID'); + + $table->index('approval_id', 'idx_approval'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('purchases', function (Blueprint $table) { + $table->dropIndex('idx_approval'); + $table->dropColumn('approval_id'); + }); + } +};