feat: 매입관리 품의서/지출결의서 연동 기능 추가
- purchases 테이블에 approval_id 컬럼 추가 (마이그레이션) - Purchase 모델에 approval 관계 정의 - PurchaseService에서 approval 데이터 eager loading 구현 - FormRequest에 approval_id 유효성 검증 추가 - Swagger 문서에 approval 관련 스키마 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성자 관계
|
||||
*/
|
||||
|
||||
@@ -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<array{month: string, amount: float}>,
|
||||
* by_type: array<array{type: string, label: string, amount: float, ratio: float}>,
|
||||
* items: array<array{id: int, date: string, vendor_name: string, amount: float, type: string, type_label: string}>
|
||||
* }
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 매입번호 자동 생성
|
||||
*/
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('purchases', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user