From cbed92a95c0f42371460f8895bd1c7ff3606f5f3 Mon Sep 17 00:00:00 2001 From: hskwon Date: Wed, 17 Dec 2025 22:14:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A7=A4=EC=B6=9C/=EB=A7=A4=EC=9E=85?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 매출(Sale) 및 매입(Purchase) CRUD API 구현 - 문서번호 자동 생성 (SL/PU + YYYYMMDD + 시퀀스) - 상태 관리 (draft → confirmed → invoiced) - 확정(confirm) 및 요약(summary) 기능 추가 - BelongsToTenant, SoftDeletes 적용 - Swagger API 문서 작성 완료 추가된 파일: - 마이그레이션: sales, purchases 테이블 - 모델: Sale, Purchase - 서비스: SaleService, PurchaseService - 컨트롤러: SaleController, PurchaseController - FormRequest: Store/Update 4개 - Swagger: SaleApi.php, PurchaseApi.php API 엔드포인트 (14개): - GET/POST /v1/sales, /v1/purchases - GET/PUT/DELETE /v1/{sales,purchases}/{id} - POST /v1/{sales,purchases}/{id}/confirm - GET /v1/{sales,purchases}/summary --- CURRENT_WORKS.md | 97 +++++ .../Controllers/Api/V1/PurchaseController.php | 106 ++++++ .../Controllers/Api/V1/SaleController.php | 106 ++++++ .../V1/Purchase/StorePurchaseRequest.php | 39 ++ .../V1/Purchase/UpdatePurchaseRequest.php | 35 ++ .../Requests/V1/Sale/StoreSaleRequest.php | 39 ++ .../Requests/V1/Sale/UpdateSaleRequest.php | 35 ++ app/Models/Tenants/Purchase.php | 102 ++++++ app/Models/Tenants/Sale.php | 105 ++++++ app/Services/PurchaseService.php | 278 +++++++++++++++ app/Services/SaleService.php | 278 +++++++++++++++ app/Swagger/v1/PurchaseApi.php | 336 +++++++++++++++++ app/Swagger/v1/SaleApi.php | 337 ++++++++++++++++++ .../2025_12_17_100001_create_sales_table.php | 48 +++ ...25_12_17_100002_create_purchases_table.php | 47 +++ routes/api.php | 44 ++- 16 files changed, 2022 insertions(+), 10 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/PurchaseController.php create mode 100644 app/Http/Controllers/Api/V1/SaleController.php create mode 100644 app/Http/Requests/V1/Purchase/StorePurchaseRequest.php create mode 100644 app/Http/Requests/V1/Purchase/UpdatePurchaseRequest.php create mode 100644 app/Http/Requests/V1/Sale/StoreSaleRequest.php create mode 100644 app/Http/Requests/V1/Sale/UpdateSaleRequest.php create mode 100644 app/Models/Tenants/Purchase.php create mode 100644 app/Models/Tenants/Sale.php create mode 100644 app/Services/PurchaseService.php create mode 100644 app/Services/SaleService.php create mode 100644 app/Swagger/v1/PurchaseApi.php create mode 100644 app/Swagger/v1/SaleApi.php create mode 100644 database/migrations/2025_12_17_100001_create_sales_table.php create mode 100644 database/migrations/2025_12_17_100002_create_purchases_table.php diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index 3da11fd..983a383 100644 --- a/CURRENT_WORKS.md +++ b/CURRENT_WORKS.md @@ -1,5 +1,102 @@ # SAM API 작업 현황 +## 2025-12-17 (화) - 매출/매입 관리 API 개발 + +### 작업 목표 +- `docs/plans/erp-api-development-plan.md` Phase 1의 2.5 매출/매입 관리 +- 매출(Sale) 및 매입(Purchase) CRUD API 구현 +- 확정(confirm) 기능 및 요약(summary) 조회 기능 포함 + +### 생성된 마이그레이션 (2개) + +| 파일명 | 설명 | +|--------|------| +| `2025_12_17_100001_create_sales_table.php` | 매출 테이블 (sale_number, sale_date, client_id, 금액, 상태) | +| `2025_12_17_100002_create_purchases_table.php` | 매입 테이블 (purchase_number, purchase_date, client_id, 금액, 상태) | + +### 생성된 모델 (2개) + +**app/Models/Tenants/Sale.php:** +- 매출 모델 (BelongsToTenant, SoftDeletes) +- 상태: draft → confirmed → invoiced +- Relations: client(), deposit(), creator() +- Methods: canConfirm(), canEdit(), canDelete() + +**app/Models/Tenants/Purchase.php:** +- 매입 모델 (BelongsToTenant, SoftDeletes) +- 상태: draft → confirmed +- Relations: client(), withdrawal(), creator() +- Methods: canConfirm(), canEdit(), canDelete() + +### 생성된 서비스 (2개) + +**app/Services/SaleService.php:** +- CRUD, confirm(), summary() +- 문서번호 자동 생성: SL{YYYYMMDD}{0001} +- 상태 검증 (수정/삭제는 draft만 가능) + +**app/Services/PurchaseService.php:** +- CRUD, confirm(), summary() +- 문서번호 자동 생성: PU{YYYYMMDD}{0001} +- 상태 검증 (수정/삭제는 draft만 가능) + +### 생성된 FormRequest (4개) + +| 파일 | 설명 | +|------|------| +| `app/Http/Requests/V1/Sale/StoreSaleRequest.php` | 매출 등록 검증 | +| `app/Http/Requests/V1/Sale/UpdateSaleRequest.php` | 매출 수정 검증 | +| `app/Http/Requests/V1/Purchase/StorePurchaseRequest.php` | 매입 등록 검증 | +| `app/Http/Requests/V1/Purchase/UpdatePurchaseRequest.php` | 매입 수정 검증 | + +### 생성된 컨트롤러 (2개) + +| 파일 | 엔드포인트 | +|------|-----------| +| `SaleController.php` | index, store, show, update, destroy, confirm, summary | +| `PurchaseController.php` | index, store, show, update, destroy, confirm, summary | + +### 수정된 파일 + +**routes/api.php:** +- Sales 라우트 그룹 추가 (7개 라우트) +- Purchases 라우트 그룹 추가 (7개 라우트) + +### 생성된 Swagger 문서 (2개) + +| 파일 | 설명 | +|------|------| +| `app/Swagger/v1/SaleApi.php` | 매출 API 문서 (전체 엔드포인트) | +| `app/Swagger/v1/PurchaseApi.php` | 매입 API 문서 (전체 엔드포인트) | + +### API 엔드포인트 + +**매출 API (Sales):** +- `GET /api/v1/sales` - 목록 조회 +- `POST /api/v1/sales` - 등록 +- `GET /api/v1/sales/{id}` - 상세 조회 +- `PUT /api/v1/sales/{id}` - 수정 +- `DELETE /api/v1/sales/{id}` - 삭제 +- `POST /api/v1/sales/{id}/confirm` - 확정 +- `GET /api/v1/sales/summary` - 요약 + +**매입 API (Purchases):** +- `GET /api/v1/purchases` - 목록 조회 +- `POST /api/v1/purchases` - 등록 +- `GET /api/v1/purchases/{id}` - 상세 조회 +- `PUT /api/v1/purchases/{id}` - 수정 +- `DELETE /api/v1/purchases/{id}` - 삭제 +- `POST /api/v1/purchases/{id}/confirm` - 확정 +- `GET /api/v1/purchases/summary` - 요약 + +### 검증 완료 +- ✅ Pint 스타일 검사 통과 +- ✅ 라우트 등록 확인 (14개) +- ✅ 마이그레이션 실행 성공 +- ✅ Swagger 문서 생성 완료 + +--- + ## 2025-12-13 (금) - Items 테이블 통합 마이그레이션 작성 ### 작업 목표 diff --git a/app/Http/Controllers/Api/V1/PurchaseController.php b/app/Http/Controllers/Api/V1/PurchaseController.php new file mode 100644 index 0000000..13550bd --- /dev/null +++ b/app/Http/Controllers/Api/V1/PurchaseController.php @@ -0,0 +1,106 @@ +only([ + 'search', + 'start_date', + 'end_date', + 'client_id', + 'status', + 'sort_by', + 'sort_dir', + 'per_page', + 'page', + ]); + + $purchases = $this->service->index($params); + + return ApiResponse::handle(__('message.fetched'), $purchases); + } + + /** + * 매입 등록 + */ + public function store(StorePurchaseRequest $request) + { + $purchase = $this->service->store($request->validated()); + + return ApiResponse::handle(__('message.created'), $purchase, 201); + } + + /** + * 매입 상세 + */ + public function show(int $id) + { + $purchase = $this->service->show($id); + + return ApiResponse::handle(__('message.fetched'), $purchase); + } + + /** + * 매입 수정 + */ + public function update(int $id, UpdatePurchaseRequest $request) + { + $purchase = $this->service->update($id, $request->validated()); + + return ApiResponse::handle(__('message.updated'), $purchase); + } + + /** + * 매입 삭제 + */ + public function destroy(int $id) + { + $this->service->destroy($id); + + return ApiResponse::handle(__('message.deleted')); + } + + /** + * 매입 확정 + */ + public function confirm(int $id) + { + $purchase = $this->service->confirm($id); + + return ApiResponse::handle(__('message.purchase.confirmed'), $purchase); + } + + /** + * 매입 요약 (기간별 합계) + */ + public function summary(Request $request) + { + $params = $request->only([ + 'start_date', + 'end_date', + 'client_id', + 'status', + ]); + + $summary = $this->service->summary($params); + + return ApiResponse::handle(__('message.fetched'), $summary); + } +} diff --git a/app/Http/Controllers/Api/V1/SaleController.php b/app/Http/Controllers/Api/V1/SaleController.php new file mode 100644 index 0000000..56ef6e7 --- /dev/null +++ b/app/Http/Controllers/Api/V1/SaleController.php @@ -0,0 +1,106 @@ +only([ + 'search', + 'start_date', + 'end_date', + 'client_id', + 'status', + 'sort_by', + 'sort_dir', + 'per_page', + 'page', + ]); + + $sales = $this->service->index($params); + + return ApiResponse::handle(__('message.fetched'), $sales); + } + + /** + * 매출 등록 + */ + public function store(StoreSaleRequest $request) + { + $sale = $this->service->store($request->validated()); + + return ApiResponse::handle(__('message.created'), $sale, 201); + } + + /** + * 매출 상세 + */ + public function show(int $id) + { + $sale = $this->service->show($id); + + return ApiResponse::handle(__('message.fetched'), $sale); + } + + /** + * 매출 수정 + */ + public function update(int $id, UpdateSaleRequest $request) + { + $sale = $this->service->update($id, $request->validated()); + + return ApiResponse::handle(__('message.updated'), $sale); + } + + /** + * 매출 삭제 + */ + public function destroy(int $id) + { + $this->service->destroy($id); + + return ApiResponse::handle(__('message.deleted')); + } + + /** + * 매출 확정 + */ + public function confirm(int $id) + { + $sale = $this->service->confirm($id); + + return ApiResponse::handle(__('message.sale.confirmed'), $sale); + } + + /** + * 매출 요약 (기간별 합계) + */ + public function summary(Request $request) + { + $params = $request->only([ + 'start_date', + 'end_date', + 'client_id', + 'status', + ]); + + $summary = $this->service->summary($params); + + return ApiResponse::handle(__('message.fetched'), $summary); + } +} diff --git a/app/Http/Requests/V1/Purchase/StorePurchaseRequest.php b/app/Http/Requests/V1/Purchase/StorePurchaseRequest.php new file mode 100644 index 0000000..e8e17f9 --- /dev/null +++ b/app/Http/Requests/V1/Purchase/StorePurchaseRequest.php @@ -0,0 +1,39 @@ + ['required', 'date'], + 'client_id' => ['required', 'integer', 'exists:clients,id'], + 'supply_amount' => ['required', 'numeric', 'min:0'], + 'tax_amount' => ['required', 'numeric', 'min:0'], + 'total_amount' => ['required', 'numeric', 'min:0'], + 'description' => ['nullable', 'string', 'max:1000'], + 'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'], + ]; + } + + public function messages(): array + { + return [ + 'purchase_date.required' => __('validation.required', ['attribute' => '매입일자']), + 'client_id.required' => __('validation.required', ['attribute' => '거래처']), + 'client_id.exists' => __('validation.exists', ['attribute' => '거래처']), + 'supply_amount.required' => __('validation.required', ['attribute' => '공급가액']), + 'supply_amount.numeric' => __('validation.numeric', ['attribute' => '공급가액']), + 'tax_amount.required' => __('validation.required', ['attribute' => '세액']), + 'total_amount.required' => __('validation.required', ['attribute' => '합계']), + ]; + } +} diff --git a/app/Http/Requests/V1/Purchase/UpdatePurchaseRequest.php b/app/Http/Requests/V1/Purchase/UpdatePurchaseRequest.php new file mode 100644 index 0000000..5c89b5b --- /dev/null +++ b/app/Http/Requests/V1/Purchase/UpdatePurchaseRequest.php @@ -0,0 +1,35 @@ + ['sometimes', 'date'], + 'client_id' => ['sometimes', 'integer', 'exists:clients,id'], + 'supply_amount' => ['sometimes', 'numeric', 'min:0'], + 'tax_amount' => ['sometimes', 'numeric', 'min:0'], + 'total_amount' => ['sometimes', 'numeric', 'min:0'], + 'description' => ['nullable', 'string', 'max:1000'], + 'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'], + ]; + } + + public function messages(): array + { + return [ + 'client_id.exists' => __('validation.exists', ['attribute' => '거래처']), + 'supply_amount.numeric' => __('validation.numeric', ['attribute' => '공급가액']), + 'withdrawal_id.exists' => __('validation.exists', ['attribute' => '출금']), + ]; + } +} diff --git a/app/Http/Requests/V1/Sale/StoreSaleRequest.php b/app/Http/Requests/V1/Sale/StoreSaleRequest.php new file mode 100644 index 0000000..d75c0e6 --- /dev/null +++ b/app/Http/Requests/V1/Sale/StoreSaleRequest.php @@ -0,0 +1,39 @@ + ['required', 'date'], + 'client_id' => ['required', 'integer', 'exists:clients,id'], + 'supply_amount' => ['required', 'numeric', 'min:0'], + 'tax_amount' => ['required', 'numeric', 'min:0'], + 'total_amount' => ['required', 'numeric', 'min:0'], + 'description' => ['nullable', 'string', 'max:1000'], + 'deposit_id' => ['nullable', 'integer', 'exists:deposits,id'], + ]; + } + + public function messages(): array + { + return [ + 'sale_date.required' => __('validation.required', ['attribute' => '매출일자']), + 'client_id.required' => __('validation.required', ['attribute' => '거래처']), + 'client_id.exists' => __('validation.exists', ['attribute' => '거래처']), + 'supply_amount.required' => __('validation.required', ['attribute' => '공급가액']), + 'supply_amount.numeric' => __('validation.numeric', ['attribute' => '공급가액']), + 'tax_amount.required' => __('validation.required', ['attribute' => '세액']), + 'total_amount.required' => __('validation.required', ['attribute' => '합계']), + ]; + } +} diff --git a/app/Http/Requests/V1/Sale/UpdateSaleRequest.php b/app/Http/Requests/V1/Sale/UpdateSaleRequest.php new file mode 100644 index 0000000..565632a --- /dev/null +++ b/app/Http/Requests/V1/Sale/UpdateSaleRequest.php @@ -0,0 +1,35 @@ + ['sometimes', 'date'], + 'client_id' => ['sometimes', 'integer', 'exists:clients,id'], + 'supply_amount' => ['sometimes', 'numeric', 'min:0'], + 'tax_amount' => ['sometimes', 'numeric', 'min:0'], + 'total_amount' => ['sometimes', 'numeric', 'min:0'], + 'description' => ['nullable', 'string', 'max:1000'], + 'deposit_id' => ['nullable', 'integer', 'exists:deposits,id'], + ]; + } + + public function messages(): array + { + return [ + 'client_id.exists' => __('validation.exists', ['attribute' => '거래처']), + 'supply_amount.numeric' => __('validation.numeric', ['attribute' => '공급가액']), + 'deposit_id.exists' => __('validation.exists', ['attribute' => '입금']), + ]; + } +} diff --git a/app/Models/Tenants/Purchase.php b/app/Models/Tenants/Purchase.php new file mode 100644 index 0000000..4f4ed37 --- /dev/null +++ b/app/Models/Tenants/Purchase.php @@ -0,0 +1,102 @@ + 'date', + 'supply_amount' => 'decimal:2', + 'tax_amount' => 'decimal:2', + 'total_amount' => 'decimal:2', + 'client_id' => 'integer', + 'withdrawal_id' => 'integer', + ]; + + /** + * 상태 목록 + */ + public const STATUSES = [ + 'draft' => '임시저장', + 'confirmed' => '확정', + ]; + + /** + * 거래처 관계 + */ + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + /** + * 출금 관계 + */ + public function withdrawal(): BelongsTo + { + return $this->belongsTo(Withdrawal::class); + } + + /** + * 생성자 관계 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'created_by'); + } + + /** + * 상태 라벨 + */ + public function getStatusLabelAttribute(): string + { + return self::STATUSES[$this->status] ?? $this->status; + } + + /** + * 확정 가능 여부 + */ + public function canConfirm(): bool + { + return $this->status === 'draft'; + } + + /** + * 수정 가능 여부 + */ + public function canEdit(): bool + { + return $this->status === 'draft'; + } + + /** + * 삭제 가능 여부 + */ + public function canDelete(): bool + { + return $this->status === 'draft'; + } +} diff --git a/app/Models/Tenants/Sale.php b/app/Models/Tenants/Sale.php new file mode 100644 index 0000000..b9c980a --- /dev/null +++ b/app/Models/Tenants/Sale.php @@ -0,0 +1,105 @@ + 'date', + 'supply_amount' => 'decimal:2', + 'tax_amount' => 'decimal:2', + 'total_amount' => 'decimal:2', + 'client_id' => 'integer', + 'tax_invoice_id' => 'integer', + 'deposit_id' => 'integer', + ]; + + /** + * 상태 목록 + */ + public const STATUSES = [ + 'draft' => '임시저장', + 'confirmed' => '확정', + 'invoiced' => '세금계산서발행', + ]; + + /** + * 거래처 관계 + */ + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + /** + * 입금 관계 + */ + public function deposit(): BelongsTo + { + return $this->belongsTo(Deposit::class); + } + + /** + * 생성자 관계 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'created_by'); + } + + /** + * 상태 라벨 + */ + public function getStatusLabelAttribute(): string + { + return self::STATUSES[$this->status] ?? $this->status; + } + + /** + * 확정 가능 여부 + */ + public function canConfirm(): bool + { + return $this->status === 'draft'; + } + + /** + * 수정 가능 여부 + */ + public function canEdit(): bool + { + return $this->status === 'draft'; + } + + /** + * 삭제 가능 여부 + */ + public function canDelete(): bool + { + return $this->status === 'draft'; + } +} diff --git a/app/Services/PurchaseService.php b/app/Services/PurchaseService.php new file mode 100644 index 0000000..9c9d1b4 --- /dev/null +++ b/app/Services/PurchaseService.php @@ -0,0 +1,278 @@ +tenantId(); + + $query = Purchase::query() + ->where('tenant_id', $tenantId) + ->with(['client:id,name']); + + // 검색어 필터 + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('purchase_number', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%") + ->orWhereHas('client', function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%"); + }); + }); + } + + // 날짜 범위 필터 + if (! empty($params['start_date'])) { + $query->where('purchase_date', '>=', $params['start_date']); + } + if (! empty($params['end_date'])) { + $query->where('purchase_date', '<=', $params['end_date']); + } + + // 거래처 필터 + if (! empty($params['client_id'])) { + $query->where('client_id', $params['client_id']); + } + + // 상태 필터 + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + // 정렬 + $sortBy = $params['sort_by'] ?? 'purchase_date'; + $sortDir = $params['sort_dir'] ?? 'desc'; + $query->orderBy($sortBy, $sortDir); + + // 페이지네이션 + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 매입 상세 조회 + */ + public function show(int $id): Purchase + { + $tenantId = $this->tenantId(); + + return Purchase::query() + ->where('tenant_id', $tenantId) + ->with(['client:id,name', 'withdrawal', 'creator:id,name']) + ->findOrFail($id); + } + + /** + * 매입 등록 + */ + public function store(array $data): Purchase + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + // 매입번호 자동 생성 + $purchaseNumber = $this->generatePurchaseNumber($tenantId, $data['purchase_date']); + + $purchase = new Purchase; + $purchase->tenant_id = $tenantId; + $purchase->purchase_number = $purchaseNumber; + $purchase->purchase_date = $data['purchase_date']; + $purchase->client_id = $data['client_id']; + $purchase->supply_amount = $data['supply_amount']; + $purchase->tax_amount = $data['tax_amount']; + $purchase->total_amount = $data['total_amount']; + $purchase->description = $data['description'] ?? null; + $purchase->status = 'draft'; + $purchase->withdrawal_id = $data['withdrawal_id'] ?? null; + $purchase->created_by = $userId; + $purchase->updated_by = $userId; + $purchase->save(); + + return $purchase->load(['client:id,name']); + }); + } + + /** + * 매입 수정 + */ + public function update(int $id, array $data): Purchase + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $data, $tenantId, $userId) { + $purchase = Purchase::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // 확정 후에는 수정 불가 + if (! $purchase->canEdit()) { + throw new \Exception(__('error.purchase.cannot_edit')); + } + + if (isset($data['purchase_date'])) { + $purchase->purchase_date = $data['purchase_date']; + } + if (isset($data['client_id'])) { + $purchase->client_id = $data['client_id']; + } + if (isset($data['supply_amount'])) { + $purchase->supply_amount = $data['supply_amount']; + } + if (isset($data['tax_amount'])) { + $purchase->tax_amount = $data['tax_amount']; + } + if (isset($data['total_amount'])) { + $purchase->total_amount = $data['total_amount']; + } + if (array_key_exists('description', $data)) { + $purchase->description = $data['description']; + } + if (array_key_exists('withdrawal_id', $data)) { + $purchase->withdrawal_id = $data['withdrawal_id']; + } + + $purchase->updated_by = $userId; + $purchase->save(); + + return $purchase->fresh(['client:id,name']); + }); + } + + /** + * 매입 삭제 + */ + public function destroy(int $id): bool + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $tenantId, $userId) { + $purchase = Purchase::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // 확정 후에는 삭제 불가 + if (! $purchase->canDelete()) { + throw new \Exception(__('error.purchase.cannot_delete')); + } + + $purchase->deleted_by = $userId; + $purchase->save(); + $purchase->delete(); + + return true; + }); + } + + /** + * 매입 확정 + */ + public function confirm(int $id): Purchase + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $tenantId, $userId) { + $purchase = Purchase::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $purchase->canConfirm()) { + throw new \Exception(__('error.purchase.cannot_confirm')); + } + + $purchase->status = 'confirmed'; + $purchase->updated_by = $userId; + $purchase->save(); + + return $purchase->fresh(['client:id,name']); + }); + } + + /** + * 매입 요약 (기간별 합계) + */ + public function summary(array $params): array + { + $tenantId = $this->tenantId(); + + $query = Purchase::query() + ->where('tenant_id', $tenantId); + + // 날짜 범위 필터 + if (! empty($params['start_date'])) { + $query->where('purchase_date', '>=', $params['start_date']); + } + if (! empty($params['end_date'])) { + $query->where('purchase_date', '<=', $params['end_date']); + } + + // 거래처 필터 + if (! empty($params['client_id'])) { + $query->where('client_id', $params['client_id']); + } + + // 상태 필터 + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + // 전체 합계 + $totalSupply = (clone $query)->sum('supply_amount'); + $totalTax = (clone $query)->sum('tax_amount'); + $totalAmount = (clone $query)->sum('total_amount'); + $count = (clone $query)->count(); + + // 상태별 합계 + $byStatus = (clone $query) + ->select('status', DB::raw('SUM(total_amount) as total'), DB::raw('COUNT(*) as count')) + ->groupBy('status') + ->get() + ->keyBy('status') + ->toArray(); + + return [ + 'total_supply_amount' => (float) $totalSupply, + 'total_tax_amount' => (float) $totalTax, + 'total_amount' => (float) $totalAmount, + 'total_count' => $count, + 'by_status' => $byStatus, + ]; + } + + /** + * 매입번호 자동 생성 + */ + private function generatePurchaseNumber(int $tenantId, string $purchaseDate): string + { + $prefix = 'PU'.date('Ymd', strtotime($purchaseDate)); + + $lastPurchase = Purchase::query() + ->where('tenant_id', $tenantId) + ->where('purchase_number', 'like', $prefix.'%') + ->orderBy('purchase_number', 'desc') + ->first(); + + if ($lastPurchase) { + $lastSeq = (int) substr($lastPurchase->purchase_number, -4); + $newSeq = $lastSeq + 1; + } else { + $newSeq = 1; + } + + return $prefix.str_pad($newSeq, 4, '0', STR_PAD_LEFT); + } +} diff --git a/app/Services/SaleService.php b/app/Services/SaleService.php new file mode 100644 index 0000000..8ec8ea6 --- /dev/null +++ b/app/Services/SaleService.php @@ -0,0 +1,278 @@ +tenantId(); + + $query = Sale::query() + ->where('tenant_id', $tenantId) + ->with(['client:id,name']); + + // 검색어 필터 + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('sale_number', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%") + ->orWhereHas('client', function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%"); + }); + }); + } + + // 날짜 범위 필터 + if (! empty($params['start_date'])) { + $query->where('sale_date', '>=', $params['start_date']); + } + if (! empty($params['end_date'])) { + $query->where('sale_date', '<=', $params['end_date']); + } + + // 거래처 필터 + if (! empty($params['client_id'])) { + $query->where('client_id', $params['client_id']); + } + + // 상태 필터 + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + // 정렬 + $sortBy = $params['sort_by'] ?? 'sale_date'; + $sortDir = $params['sort_dir'] ?? 'desc'; + $query->orderBy($sortBy, $sortDir); + + // 페이지네이션 + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 매출 상세 조회 + */ + public function show(int $id): Sale + { + $tenantId = $this->tenantId(); + + return Sale::query() + ->where('tenant_id', $tenantId) + ->with(['client:id,name', 'deposit', 'creator:id,name']) + ->findOrFail($id); + } + + /** + * 매출 등록 + */ + public function store(array $data): Sale + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + // 매출번호 자동 생성 + $saleNumber = $this->generateSaleNumber($tenantId, $data['sale_date']); + + $sale = new Sale; + $sale->tenant_id = $tenantId; + $sale->sale_number = $saleNumber; + $sale->sale_date = $data['sale_date']; + $sale->client_id = $data['client_id']; + $sale->supply_amount = $data['supply_amount']; + $sale->tax_amount = $data['tax_amount']; + $sale->total_amount = $data['total_amount']; + $sale->description = $data['description'] ?? null; + $sale->status = 'draft'; + $sale->deposit_id = $data['deposit_id'] ?? null; + $sale->created_by = $userId; + $sale->updated_by = $userId; + $sale->save(); + + return $sale->load(['client:id,name']); + }); + } + + /** + * 매출 수정 + */ + public function update(int $id, array $data): Sale + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $data, $tenantId, $userId) { + $sale = Sale::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // 확정 후에는 수정 불가 + if (! $sale->canEdit()) { + throw new \Exception(__('error.sale.cannot_edit')); + } + + if (isset($data['sale_date'])) { + $sale->sale_date = $data['sale_date']; + } + if (isset($data['client_id'])) { + $sale->client_id = $data['client_id']; + } + if (isset($data['supply_amount'])) { + $sale->supply_amount = $data['supply_amount']; + } + if (isset($data['tax_amount'])) { + $sale->tax_amount = $data['tax_amount']; + } + if (isset($data['total_amount'])) { + $sale->total_amount = $data['total_amount']; + } + if (array_key_exists('description', $data)) { + $sale->description = $data['description']; + } + if (array_key_exists('deposit_id', $data)) { + $sale->deposit_id = $data['deposit_id']; + } + + $sale->updated_by = $userId; + $sale->save(); + + return $sale->fresh(['client:id,name']); + }); + } + + /** + * 매출 삭제 + */ + public function destroy(int $id): bool + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $tenantId, $userId) { + $sale = Sale::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // 확정 후에는 삭제 불가 + if (! $sale->canDelete()) { + throw new \Exception(__('error.sale.cannot_delete')); + } + + $sale->deleted_by = $userId; + $sale->save(); + $sale->delete(); + + return true; + }); + } + + /** + * 매출 확정 + */ + public function confirm(int $id): Sale + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $tenantId, $userId) { + $sale = Sale::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $sale->canConfirm()) { + throw new \Exception(__('error.sale.cannot_confirm')); + } + + $sale->status = 'confirmed'; + $sale->updated_by = $userId; + $sale->save(); + + return $sale->fresh(['client:id,name']); + }); + } + + /** + * 매출 요약 (기간별 합계) + */ + public function summary(array $params): array + { + $tenantId = $this->tenantId(); + + $query = Sale::query() + ->where('tenant_id', $tenantId); + + // 날짜 범위 필터 + if (! empty($params['start_date'])) { + $query->where('sale_date', '>=', $params['start_date']); + } + if (! empty($params['end_date'])) { + $query->where('sale_date', '<=', $params['end_date']); + } + + // 거래처 필터 + if (! empty($params['client_id'])) { + $query->where('client_id', $params['client_id']); + } + + // 상태 필터 + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + // 전체 합계 + $totalSupply = (clone $query)->sum('supply_amount'); + $totalTax = (clone $query)->sum('tax_amount'); + $totalAmount = (clone $query)->sum('total_amount'); + $count = (clone $query)->count(); + + // 상태별 합계 + $byStatus = (clone $query) + ->select('status', DB::raw('SUM(total_amount) as total'), DB::raw('COUNT(*) as count')) + ->groupBy('status') + ->get() + ->keyBy('status') + ->toArray(); + + return [ + 'total_supply_amount' => (float) $totalSupply, + 'total_tax_amount' => (float) $totalTax, + 'total_amount' => (float) $totalAmount, + 'total_count' => $count, + 'by_status' => $byStatus, + ]; + } + + /** + * 매출번호 자동 생성 + */ + private function generateSaleNumber(int $tenantId, string $saleDate): string + { + $prefix = 'SL'.date('Ymd', strtotime($saleDate)); + + $lastSale = Sale::query() + ->where('tenant_id', $tenantId) + ->where('sale_number', 'like', $prefix.'%') + ->orderBy('sale_number', 'desc') + ->first(); + + if ($lastSale) { + $lastSeq = (int) substr($lastSale->sale_number, -4); + $newSeq = $lastSeq + 1; + } else { + $newSeq = 1; + } + + return $prefix.str_pad($newSeq, 4, '0', STR_PAD_LEFT); + } +} diff --git a/app/Swagger/v1/PurchaseApi.php b/app/Swagger/v1/PurchaseApi.php new file mode 100644 index 0000000..1a1fedc --- /dev/null +++ b/app/Swagger/v1/PurchaseApi.php @@ -0,0 +1,336 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->string('sale_number', 30)->comment('매출번호'); + $table->date('sale_date')->comment('매출일자'); + $table->unsignedBigInteger('client_id')->comment('거래처 ID'); + $table->decimal('supply_amount', 15, 2)->comment('공급가액'); + $table->decimal('tax_amount', 15, 2)->comment('세액'); + $table->decimal('total_amount', 15, 2)->comment('합계'); + $table->text('description')->nullable()->comment('적요'); + $table->string('status', 20)->default('draft')->comment('상태: draft/confirmed/invoiced'); + $table->unsignedBigInteger('tax_invoice_id')->nullable()->comment('세금계산서 ID'); + $table->unsignedBigInteger('deposit_id')->nullable()->comment('입금 연결 ID'); + $table->unsignedBigInteger('created_by')->nullable()->comment('생성자'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자'); + $table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자'); + $table->softDeletes(); + $table->timestamps(); + + // 인덱스 + $table->unique(['tenant_id', 'sale_number'], 'uk_tenant_sale_number'); + $table->index(['tenant_id', 'sale_date'], 'idx_tenant_sale_date'); + $table->index('client_id', 'idx_client'); + $table->index('status', 'idx_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('sales'); + } +}; diff --git a/database/migrations/2025_12_17_100002_create_purchases_table.php b/database/migrations/2025_12_17_100002_create_purchases_table.php new file mode 100644 index 0000000..797061a --- /dev/null +++ b/database/migrations/2025_12_17_100002_create_purchases_table.php @@ -0,0 +1,47 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->string('purchase_number', 30)->comment('매입번호'); + $table->date('purchase_date')->comment('매입일자'); + $table->unsignedBigInteger('client_id')->comment('거래처 ID'); + $table->decimal('supply_amount', 15, 2)->comment('공급가액'); + $table->decimal('tax_amount', 15, 2)->comment('세액'); + $table->decimal('total_amount', 15, 2)->comment('합계'); + $table->text('description')->nullable()->comment('적요'); + $table->string('status', 20)->default('draft')->comment('상태: draft/confirmed'); + $table->unsignedBigInteger('withdrawal_id')->nullable()->comment('출금 연결 ID'); + $table->unsignedBigInteger('created_by')->nullable()->comment('생성자'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자'); + $table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자'); + $table->softDeletes(); + $table->timestamps(); + + // 인덱스 + $table->unique(['tenant_id', 'purchase_number'], 'uk_tenant_purchase_number'); + $table->index(['tenant_id', 'purchase_date'], 'idx_tenant_purchase_date'); + $table->index('client_id', 'idx_client'); + $table->index('status', 'idx_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('purchases'); + } +}; diff --git a/routes/api.php b/routes/api.php index b27c7da..33f6321 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,7 +4,9 @@ use App\Http\Controllers\Api\V1\AdminController; use App\Http\Controllers\Api\V1\ApiController; use App\Http\Controllers\Api\V1\AttendanceController; +use App\Http\Controllers\Api\V1\BankAccountController; use App\Http\Controllers\Api\V1\BoardController; +use App\Http\Controllers\Api\V1\CardController; use App\Http\Controllers\Api\V1\CategoryController; use App\Http\Controllers\Api\V1\CategoryFieldController; use App\Http\Controllers\Api\V1\CategoryLogController; @@ -14,6 +16,7 @@ use App\Http\Controllers\Api\V1\ClientGroupController; use App\Http\Controllers\Api\V1\CommonController; use App\Http\Controllers\Api\V1\DepartmentController; +use App\Http\Controllers\Api\V1\DepositController; use App\Http\Controllers\Api\V1\Design\AuditLogController as DesignAuditLogController; use App\Http\Controllers\Api\V1\Design\BomCalculationController; use App\Http\Controllers\Api\V1\Design\BomTemplateController as DesignBomTemplateController; @@ -35,35 +38,34 @@ use App\Http\Controllers\Api\V1\ItemsBomController; use App\Http\Controllers\Api\V1\ItemsController; use App\Http\Controllers\Api\V1\ItemsFileController; +// use App\Http\Controllers\Api\V1\MaterialController; // REMOVED: materials 테이블 삭제됨 use App\Http\Controllers\Api\V1\LeaveController; use App\Http\Controllers\Api\V1\MenuController; use App\Http\Controllers\Api\V1\ModelSetController; -// use App\Http\Controllers\Api\V1\MaterialController; // REMOVED: materials 테이블 삭제됨 use App\Http\Controllers\Api\V1\PermissionController; use App\Http\Controllers\Api\V1\PostController; -use App\Http\Controllers\Api\V1\PricingController; -use App\Http\Controllers\Api\V1\QuoteController; -use App\Http\Controllers\Api\V1\RefreshController; // use App\Http\Controllers\Api\V1\ProductBomItemController; // REMOVED: products 테이블 삭제됨 // use App\Http\Controllers\Api\V1\ProductController; // REMOVED: products 테이블 삭제됨 +use App\Http\Controllers\Api\V1\PricingController; +use App\Http\Controllers\Api\V1\PurchaseController; +use App\Http\Controllers\Api\V1\QuoteController; +use App\Http\Controllers\Api\V1\RefreshController; use App\Http\Controllers\Api\V1\RegisterController; use App\Http\Controllers\Api\V1\RoleController; use App\Http\Controllers\Api\V1\RolePermissionController; -use App\Http\Controllers\Api\V1\BankAccountController; -use App\Http\Controllers\Api\V1\CardController; -use App\Http\Controllers\Api\V1\DepositController; -use App\Http\Controllers\Api\V1\WithdrawalController; +use App\Http\Controllers\Api\V1\SaleController; use App\Http\Controllers\Api\V1\SiteController; use App\Http\Controllers\Api\V1\TenantController; use App\Http\Controllers\Api\V1\TenantFieldSettingController; -// 설계 전용 (디자인 네임스페이스) use App\Http\Controllers\Api\V1\TenantOptionGroupController; +// 설계 전용 (디자인 네임스페이스) use App\Http\Controllers\Api\V1\TenantOptionValueController; use App\Http\Controllers\Api\V1\TenantStatFieldController; use App\Http\Controllers\Api\V1\TenantUserProfileController; use App\Http\Controllers\Api\V1\UserController; -// 모델셋 관리 (견적 시스템) use App\Http\Controllers\Api\V1\UserRoleController; +// 모델셋 관리 (견적 시스템) +use App\Http\Controllers\Api\V1\WithdrawalController; use App\Http\Controllers\Api\V1\WorkSettingController; use Illuminate\Support\Facades\Route; @@ -316,6 +318,28 @@ Route::delete('/{id}', [WithdrawalController::class, 'destroy'])->whereNumber('id')->name('v1.withdrawals.destroy'); }); + // Sale API (매출 관리) + Route::prefix('sales')->group(function () { + Route::get('', [SaleController::class, 'index'])->name('v1.sales.index'); + Route::post('', [SaleController::class, 'store'])->name('v1.sales.store'); + Route::get('/summary', [SaleController::class, 'summary'])->name('v1.sales.summary'); + Route::get('/{id}', [SaleController::class, 'show'])->whereNumber('id')->name('v1.sales.show'); + Route::put('/{id}', [SaleController::class, 'update'])->whereNumber('id')->name('v1.sales.update'); + Route::delete('/{id}', [SaleController::class, 'destroy'])->whereNumber('id')->name('v1.sales.destroy'); + Route::post('/{id}/confirm', [SaleController::class, 'confirm'])->whereNumber('id')->name('v1.sales.confirm'); + }); + + // Purchase API (매입 관리) + Route::prefix('purchases')->group(function () { + Route::get('', [PurchaseController::class, 'index'])->name('v1.purchases.index'); + Route::post('', [PurchaseController::class, 'store'])->name('v1.purchases.store'); + Route::get('/summary', [PurchaseController::class, 'summary'])->name('v1.purchases.summary'); + Route::get('/{id}', [PurchaseController::class, 'show'])->whereNumber('id')->name('v1.purchases.show'); + Route::put('/{id}', [PurchaseController::class, 'update'])->whereNumber('id')->name('v1.purchases.update'); + Route::delete('/{id}', [PurchaseController::class, 'destroy'])->whereNumber('id')->name('v1.purchases.destroy'); + Route::post('/{id}/confirm', [PurchaseController::class, 'confirm'])->whereNumber('id')->name('v1.purchases.confirm'); + }); + // Permission API Route::prefix('permissions')->group(function () { Route::get('departments/{dept_id}/menu-matrix', [PermissionController::class, 'deptMenuMatrix'])->name('v1.permissions.deptMenuMatrix'); // 부서별 권한 메트릭스