feat: 견적 단가 자동 적용 기능 추가
- 고객 그룹별 단가 조정 지원 - 견적 생성 시 자동 단가 조회 - 매출단가만 사용 (매입단가는 경고)
This commit is contained in:
35
CLAUDE.md
35
CLAUDE.md
@@ -532,11 +532,42 @@ ### 6. i18n & Response Messages
|
||||
- Resource-specific keys allowed: message.product.created, message.bom.bulk_upsert
|
||||
|
||||
### 7. Swagger Documentation (l5-swagger 9.0)
|
||||
- **Tags**: Resource-based (User, Auth, Product, BOM...)
|
||||
- **Structure**: Swagger annotations are written in separate PHP class files under `app/Swagger/v1/`
|
||||
- **File Naming**: `{Resource}Api.php` (e.g., CategoryApi.php, ClientApi.php, ProductApi.php)
|
||||
- **Controller Clean**: Controllers contain ONLY business logic, NO Swagger annotations
|
||||
- **Tags**: Resource-based (User, Auth, Product, BOM, Client...)
|
||||
- **Security**: ApiKeyAuth + BearerAuth
|
||||
- **Schemas**: Reuse ApiResponse, ErrorResponse, resource DTOs
|
||||
- **Schemas**: Define in Swagger files - Resource model, Pagination, CreateRequest, UpdateRequest
|
||||
- **Methods**: Define empty methods for each endpoint with full @OA annotations
|
||||
- **Specifications**: Clear Path/Query/Body parameters with examples
|
||||
- **No duplicate schemas**, accurate nullable/oneOf distinctions
|
||||
- **Regeneration**: Run `php artisan l5-swagger:generate` after creating/updating Swagger files
|
||||
|
||||
**Swagger File Structure Example**:
|
||||
```php
|
||||
<?php
|
||||
namespace App\Swagger\v1;
|
||||
|
||||
/**
|
||||
* @OA\Tag(name="Resource", description="리소스 관리")
|
||||
* @OA\Schema(schema="Resource", ...) // Model schema
|
||||
* @OA\Schema(schema="ResourcePagination", ...) // Pagination schema
|
||||
* @OA\Schema(schema="ResourceCreateRequest", ...) // Create request
|
||||
* @OA\Schema(schema="ResourceUpdateRequest", ...) // Update request
|
||||
*/
|
||||
class ResourceApi {
|
||||
/**
|
||||
* @OA\Get(path="/api/v1/resources", tags={"Resource"}, ...)
|
||||
*/
|
||||
public function index() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(path="/api/v1/resources", tags={"Resource"}, ...)
|
||||
*/
|
||||
public function store() {}
|
||||
// ... other methods
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Validation (FormRequest)
|
||||
- **No direct validate() calls**. Separate all into FormRequest classes
|
||||
|
||||
247
CURRENT_WORKS.md
247
CURRENT_WORKS.md
@@ -1,5 +1,252 @@
|
||||
# SAM API 저장소 작업 현황
|
||||
|
||||
## 2025-10-13 (일) - 고객그룹별 차등 가격 시스템 구축
|
||||
|
||||
### 주요 작업
|
||||
- **고객 그룹 관리 시스템 구축**: 고객별 차등 가격 관리를 위한 client_groups 테이블 및 모델 구현
|
||||
- **가격 이력 시스템 확장**: price_histories 테이블에 고객그룹별 가격 지원 추가
|
||||
- **PricingService 신규 구축**: 우선순위 기반 가격 조회 로직 구현
|
||||
- **EstimateService 통합**: 견적 생성 시 자동 가격 계산 기능 추가
|
||||
|
||||
### 추가된 파일:
|
||||
- `database/migrations/2025_10_13_213549_create_client_groups_table.php` - 고객 그룹 테이블 생성
|
||||
- `database/migrations/2025_10_13_213556_add_client_group_id_to_clients_table.php` - clients 테이블에 그룹 ID 추가
|
||||
- `database/migrations/2025_10_13_213602_add_client_group_id_to_price_histories_table.php` - price_histories 테이블에 그룹 ID 추가
|
||||
- `app/Models/Orders/ClientGroup.php` - 고객 그룹 모델
|
||||
- `app/Services/Pricing/PricingService.php` - 가격 조회/관리 서비스
|
||||
|
||||
### 수정된 파일:
|
||||
- `app/Models/Orders/Client.php` - ClientGroup 관계 추가
|
||||
- `app/Models/Products/PriceHistory.php` - ClientGroup 관계 추가, 다양한 스코프 메서드 추가
|
||||
- `app/Services/Estimate/EstimateService.php` - PricingService 의존성 주입 및 가격 계산 로직 통합
|
||||
- `lang/ko/error.php` - price_not_found 에러 메시지 추가
|
||||
|
||||
### 작업 내용:
|
||||
|
||||
#### 1. 데이터베이스 스키마 설계
|
||||
|
||||
**client_groups 테이블:**
|
||||
```sql
|
||||
- id, tenant_id
|
||||
- group_code (그룹 코드)
|
||||
- group_name (그룹명)
|
||||
- price_rate (가격 배율: 1.0 = 기준가, 0.9 = 90%, 1.1 = 110%)
|
||||
- is_active (활성 여부)
|
||||
- created_by, updated_by, deleted_by (감사 컬럼)
|
||||
- created_at, updated_at, deleted_at
|
||||
- UNIQUE(tenant_id, group_code)
|
||||
```
|
||||
|
||||
**clients 테이블 확장:**
|
||||
- `client_group_id` 컬럼 추가 (NULL 허용 = 기본 그룹)
|
||||
|
||||
**price_histories 테이블 확장:**
|
||||
- `client_group_id` 컬럼 추가 (NULL = 기본 가격, 값 있으면 그룹별 차등 가격)
|
||||
- 인덱스 재구성: (tenant_id, item_type_code, item_id, client_group_id, started_at)
|
||||
|
||||
#### 2. 모델 관계 설정
|
||||
|
||||
**ClientGroup 모델:**
|
||||
- `clients()` → hasMany 관계
|
||||
- `scopeActive()` → 활성 그룹만 조회
|
||||
- `scopeCode()` → 코드로 검색
|
||||
|
||||
**Client 모델:**
|
||||
- `clientGroup()` → belongsTo 관계
|
||||
|
||||
**PriceHistory 모델:**
|
||||
- `clientGroup()` → belongsTo 관계
|
||||
- `item()` → Polymorphic 관계 (Product/Material)
|
||||
- 다양한 스코프 메서드:
|
||||
- `scopeForItem()` → 특정 항목 필터링
|
||||
- `scopeForClientGroup()` → 고객 그룹 필터링
|
||||
- `scopeValidAt()` → 기준일 기준 유효한 가격
|
||||
- `scopeSalePrice()` → 매출단가만
|
||||
- `scopePurchasePrice()` → 매입단가만
|
||||
|
||||
#### 3. PricingService 핵심 로직
|
||||
|
||||
**가격 조회 우선순위:**
|
||||
```php
|
||||
1순위: 고객 그룹별 매출단가 (client_group_id 있음)
|
||||
2순위: 기본 매출단가 (client_group_id = NULL)
|
||||
3순위: NULL (경고 발생)
|
||||
|
||||
// ❌ 제거: 매입단가는 견적에서 사용하지 않음 (순수 참고용)
|
||||
```
|
||||
|
||||
**주요 메서드:**
|
||||
- `getItemPrice()` → 단일 항목 가격 조회
|
||||
- `getBulkItemPrices()` → 여러 항목 일괄 조회
|
||||
- `upsertPrice()` → 가격 등록/수정
|
||||
- `listPrices()` → 가격 이력 조회 (페이지네이션)
|
||||
- `deletePrice()` → 가격 삭제 (Soft Delete)
|
||||
|
||||
#### 4. EstimateService 통합
|
||||
|
||||
**견적 생성 프로세스:**
|
||||
```php
|
||||
1. BOM 계산 (수량만)
|
||||
2. 각 BOM 항목의 가격 조회 (PricingService)
|
||||
3. unit_price × quantity = total_price 계산
|
||||
4. 전체 항목의 total_price 합산 = total_amount
|
||||
5. 가격 없는 항목 경고 로그 기록
|
||||
```
|
||||
|
||||
**수정된 메서드:**
|
||||
- `createEstimate()` → client_id 전달, total_amount 재계산
|
||||
- `updateEstimate()` → 파라미터 변경 시 가격 재계산
|
||||
- `createEstimateItems()` → 가격 조회 로직 추가, float 반환
|
||||
|
||||
#### 5. 에러 처리 및 로깅
|
||||
|
||||
**가격 없는 항목 처리:**
|
||||
- 경고 메시지 반환: `__('error.price_not_found', [...])`
|
||||
- Laravel Log에 경고 기록
|
||||
- 견적 생성은 계속 진행 (unit_price = 0)
|
||||
- 프론트엔드에서 경고 표시 가능
|
||||
|
||||
### 비즈니스 규칙 정리:
|
||||
|
||||
#### 매입단가 vs 매출단가
|
||||
- **매입단가 (PURCHASE)**: 순수 참고용, 견적 계산에 미사용
|
||||
- **매출단가 (SALE)**: 실제 견적 계산에 사용
|
||||
- **STANDARD 가격**: 경동 비즈니스에서는 불필요 (사용하지 않음)
|
||||
|
||||
#### 고객 그룹별 차등 가격
|
||||
```
|
||||
예시 데이터:
|
||||
- 기본 가격: 100,000원 (client_group_id = NULL)
|
||||
- A그룹 가격: 90,000원 (client_group_id = 1, price_rate = 0.9)
|
||||
- B그룹 가격: 110,000원 (client_group_id = 2, price_rate = 1.1)
|
||||
|
||||
조회 로직:
|
||||
- A그룹 고객 → 90,000원 (1순위)
|
||||
- B그룹 고객 → 110,000원 (1순위)
|
||||
- 일반 고객 → 100,000원 (2순위)
|
||||
- 가격 없음 → NULL + 경고 (3순위)
|
||||
```
|
||||
|
||||
### 마이그레이션 실행 결과:
|
||||
```bash
|
||||
✅ 2025_10_13_213549_create_client_groups_table (46.85ms)
|
||||
✅ 2025_10_13_213556_add_client_group_id_to_clients_table (38.75ms)
|
||||
✅ 2025_10_13_213602_add_client_group_id_to_price_histories_table (38.46ms)
|
||||
```
|
||||
|
||||
### 코드 품질:
|
||||
- Laravel Pint 포맷팅 완료 (5 files, 4 style issues fixed)
|
||||
- SAM API Development Rules 준수
|
||||
- Service-First 아키텍처 유지
|
||||
- BelongsToTenant 멀티테넌트 스코프 적용
|
||||
- SoftDeletes 적용
|
||||
- 감사 컬럼 (created_by, updated_by, deleted_by) 포함
|
||||
|
||||
### 예상 효과:
|
||||
1. **유연한 가격 관리**: 고객 그룹별 차등 가격 설정 가능
|
||||
2. **자동 가격 계산**: 견적 생성 시 수동 입력 불필요
|
||||
3. **이력 관리**: 기간별 가격 변동 이력 추적
|
||||
4. **확장성**: 향후 복잡한 가격 정책 적용 가능
|
||||
5. **투명성**: 가격 출처 추적 가능 (price_history_id, client_group_id)
|
||||
|
||||
### 향후 작업:
|
||||
- [x] PricingService 구현
|
||||
- [x] EstimateService 통합
|
||||
- [ ] 가격 관리 API 엔드포인트 추가 (CRUD)
|
||||
- [ ] Swagger 문서 작성 (가격 관련 API)
|
||||
- [ ] 고객 그룹 관리 API 엔드포인트 추가
|
||||
- [ ] Frontend 가격 관리 화면 구현
|
||||
- [ ] 가격 일괄 업로드 기능 추가
|
||||
|
||||
### Git 커밋 준비:
|
||||
- 다음 커밋 예정: `feat: 고객그룹별 차등 가격 시스템 구축`
|
||||
|
||||
---
|
||||
|
||||
## 2025-10-01 (화) - Client API 및 Swagger 문서 구조 개선
|
||||
|
||||
### 주요 작업
|
||||
- **Client API Swagger 문서 생성**: Controller에서 분리된 Swagger 파일 생성
|
||||
- **Swagger 구조 표준화**: Controller는 비즈니스 로직만, Swagger는 별도 파일로 관리
|
||||
- **CLAUDE.md 업데이트**: Swagger 문서 작성 규칙 명확화
|
||||
|
||||
### 추가된 파일:
|
||||
- `app/Swagger/v1/ClientApi.php` - Client API Swagger 문서 (Tag, Schemas, Endpoints)
|
||||
|
||||
### 수정된 파일:
|
||||
- `app/Http/Controllers/Api/V1/ClientController.php` - Swagger 어노테이션 제거 (비즈니스 로직만 유지)
|
||||
- `CLAUDE.md` - Swagger 문서 작성 규칙 섹션 업데이트
|
||||
|
||||
### 작업 내용:
|
||||
|
||||
#### 1. Swagger 문서 구조 표준화
|
||||
**기존 방식 (잘못된 방식):**
|
||||
- Controller에 Swagger 어노테이션 직접 작성
|
||||
- 비즈니스 로직과 API 문서가 혼재
|
||||
- Controller 가독성 저하
|
||||
|
||||
**새로운 방식 (표준 방식):**
|
||||
- `app/Swagger/v1/{Resource}Api.php` 별도 파일로 관리
|
||||
- Controller는 순수 비즈니스 로직만 포함
|
||||
- Swagger 문서는 독립적으로 관리
|
||||
|
||||
#### 2. ClientApi.php 구조
|
||||
```php
|
||||
namespace App\Swagger\v1;
|
||||
|
||||
/**
|
||||
* @OA\Tag - 리소스 태그 정의
|
||||
* @OA\Schema(schema="Client") - 모델 스키마
|
||||
* @OA\Schema(schema="ClientPagination") - 페이지네이션
|
||||
* @OA\Schema(schema="ClientCreateRequest") - 생성 요청
|
||||
* @OA\Schema(schema="ClientUpdateRequest") - 수정 요청
|
||||
*/
|
||||
class ClientApi {
|
||||
public function index() {} // GET /api/v1/clients
|
||||
public function show() {} // GET /api/v1/clients/{id}
|
||||
public function store() {} // POST /api/v1/clients
|
||||
public function update() {} // PUT /api/v1/clients/{id}
|
||||
public function destroy() {} // DELETE /api/v1/clients/{id}
|
||||
public function toggle() {} // PATCH /api/v1/clients/{id}/toggle
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. CLAUDE.md 규칙 업데이트
|
||||
**추가된 내용:**
|
||||
- Swagger 파일 위치: `app/Swagger/v1/`
|
||||
- 파일 네이밍: `{Resource}Api.php`
|
||||
- Controller 정책: Swagger 어노테이션 금지
|
||||
- Schemas 구성: Model, Pagination, CreateRequest, UpdateRequest
|
||||
- 재생성 명령어: `php artisan l5-swagger:generate`
|
||||
|
||||
#### 4. Client API 엔드포인트 구성
|
||||
- **GET** `/api/v1/clients` - 거래처 목록 (페이지네이션)
|
||||
- **GET** `/api/v1/clients/{id}` - 거래처 상세
|
||||
- **POST** `/api/v1/clients` - 거래처 생성
|
||||
- **PUT** `/api/v1/clients/{id}` - 거래처 수정
|
||||
- **DELETE** `/api/v1/clients/{id}` - 거래처 삭제 (soft)
|
||||
- **PATCH** `/api/v1/clients/{id}/toggle` - 활성/비활성 토글
|
||||
|
||||
### 시스템 개선 효과:
|
||||
1. **코드 가독성 향상**: Controller가 순수 비즈니스 로직만 포함
|
||||
2. **문서 관리 효율성**: Swagger 문서를 독립적으로 관리 가능
|
||||
3. **표준화**: 모든 API가 동일한 구조로 문서화
|
||||
4. **유지보수성**: Swagger 변경 시 Controller 영향 없음
|
||||
|
||||
### Swagger 재생성:
|
||||
```bash
|
||||
php artisan l5-swagger:generate
|
||||
```
|
||||
- Client API가 Swagger UI에 "Client" 태그로 표시됨
|
||||
- `/api-docs/index.html`에서 확인 가능
|
||||
|
||||
### 향후 작업:
|
||||
- 다른 Controller에도 동일한 패턴 적용
|
||||
- Swagger 문서 품질 검증
|
||||
- API 엔드포인트 테스트
|
||||
|
||||
---
|
||||
|
||||
## 2025-09-24 (화) - FK 제약조건 최적화 및 데이터베이스 성능 개선
|
||||
|
||||
### 주요 작업
|
||||
|
||||
66
app/Http/Controllers/Api/V1/ClientController.php
Normal file
66
app/Http/Controllers/Api/V1/ClientController.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ClientService;
|
||||
use App\Helpers\ApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ClientController extends Controller
|
||||
{
|
||||
protected ClientService $service;
|
||||
|
||||
public function __construct(ClientService $service)
|
||||
{
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$data = $this->service->index($request->all());
|
||||
return ['data' => $data, 'message' => __('message.fetched')];
|
||||
});
|
||||
}
|
||||
|
||||
public function show(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$data = $this->service->show($id);
|
||||
return ['data' => $data, 'message' => __('message.fetched')];
|
||||
});
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$data = $this->service->store($request->all());
|
||||
return ['data' => $data, 'message' => __('message.created')];
|
||||
});
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
$data = $this->service->update($id, $request->all());
|
||||
return ['data' => $data, 'message' => __('message.updated')];
|
||||
});
|
||||
}
|
||||
|
||||
public function destroy(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$this->service->destroy($id);
|
||||
return ['data' => null, 'message' => __('message.deleted')];
|
||||
});
|
||||
}
|
||||
|
||||
public function toggle(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$data = $this->service->toggle($id);
|
||||
return ['data' => $data, 'message' => __('message.updated')];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,9 @@ public function children() { return $this->hasMany(self::class, 'parent_id'); }
|
||||
// 카테고리의 제품들
|
||||
public function products() { return $this->hasMany(\App\Models\Products\Product::class, 'category_id'); }
|
||||
|
||||
// 카테고리 필드
|
||||
public function categoryFields() { return $this->hasMany(CategoryField::class, 'category_id'); }
|
||||
|
||||
// 태그(폴리모픽) — 이미 taggables 존재
|
||||
public function tags() { return $this->morphToMany(\App\Models\Commons\Tag::class, 'taggable'); }
|
||||
|
||||
|
||||
51
app/Models/Orders/Client.php
Normal file
51
app/Models/Orders/Client.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Orders;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use App\Traits\ModelTrait;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Client extends Model
|
||||
{
|
||||
use BelongsToTenant, ModelTrait;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'client_group_id',
|
||||
'client_code',
|
||||
'name',
|
||||
'contact_person',
|
||||
'phone',
|
||||
'email',
|
||||
'address',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
// ClientGroup 관계
|
||||
public function clientGroup()
|
||||
{
|
||||
return $this->belongsTo(ClientGroup::class, 'client_group_id');
|
||||
}
|
||||
|
||||
// Orders 관계
|
||||
public function orders()
|
||||
{
|
||||
return $this->hasMany(Order::class, 'client_id');
|
||||
}
|
||||
|
||||
// 스코프
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', 'Y');
|
||||
}
|
||||
|
||||
public function scopeCode($query, string $code)
|
||||
{
|
||||
return $query->where('client_code', $code);
|
||||
}
|
||||
}
|
||||
46
app/Models/Orders/ClientGroup.php
Normal file
46
app/Models/Orders/ClientGroup.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Orders;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use App\Traits\ModelTrait;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ClientGroup extends Model
|
||||
{
|
||||
use BelongsToTenant, ModelTrait, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'group_code',
|
||||
'group_name',
|
||||
'price_rate',
|
||||
'is_active',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price_rate' => 'decimal:4',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
// Clients 관계
|
||||
public function clients()
|
||||
{
|
||||
return $this->hasMany(Client::class, 'client_group_id');
|
||||
}
|
||||
|
||||
// 스코프
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', 1);
|
||||
}
|
||||
|
||||
public function scopeCode($query, string $code)
|
||||
{
|
||||
return $query->where('group_code', $code);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Models\Products;
|
||||
|
||||
use App\Models\Orders\ClientGroup;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
@@ -10,6 +12,74 @@
|
||||
*/
|
||||
class PriceHistory extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
protected $fillable = ['tenant_id','item_type_code','item_id','price_type_code','price','started_at','ended_at'];
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'item_type_code',
|
||||
'item_id',
|
||||
'price_type_code',
|
||||
'client_group_id',
|
||||
'price',
|
||||
'started_at',
|
||||
'ended_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:4',
|
||||
'started_at' => 'date',
|
||||
'ended_at' => 'date',
|
||||
];
|
||||
|
||||
// ClientGroup 관계
|
||||
public function clientGroup()
|
||||
{
|
||||
return $this->belongsTo(ClientGroup::class, 'client_group_id');
|
||||
}
|
||||
|
||||
// Polymorphic 관계 (item_type_code에 따라 Product 또는 Material)
|
||||
public function item()
|
||||
{
|
||||
if ($this->item_type_code === 'PRODUCT') {
|
||||
return $this->belongsTo(Product::class, 'item_id');
|
||||
} elseif ($this->item_type_code === 'MATERIAL') {
|
||||
return $this->belongsTo(\App\Models\Materials\Material::class, 'item_id');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 스코프
|
||||
public function scopeForItem($query, string $itemType, int $itemId)
|
||||
{
|
||||
return $query->where('item_type_code', $itemType)
|
||||
->where('item_id', $itemId);
|
||||
}
|
||||
|
||||
public function scopeForClientGroup($query, ?int $clientGroupId)
|
||||
{
|
||||
return $query->where('client_group_id', $clientGroupId);
|
||||
}
|
||||
|
||||
public function scopeValidAt($query, $date)
|
||||
{
|
||||
return $query->where('started_at', '<=', $date)
|
||||
->where(function ($q) use ($date) {
|
||||
$q->whereNull('ended_at')
|
||||
->orWhere('ended_at', '>=', $date);
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeSalePrice($query)
|
||||
{
|
||||
return $query->where('price_type_code', 'SALE');
|
||||
}
|
||||
|
||||
public function scopePurchasePrice($query)
|
||||
{
|
||||
return $query->where('price_type_code', 'PURCHASE');
|
||||
}
|
||||
}
|
||||
|
||||
161
app/Services/ClientService.php
Normal file
161
app/Services/ClientService.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Orders\Client;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class ClientService extends Service
|
||||
{
|
||||
/** 목록(검색/페이징) */
|
||||
public function index(array $params)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$page = (int)($params['page'] ?? 1);
|
||||
$size = (int)($params['size'] ?? 20);
|
||||
$q = trim((string)($params['q'] ?? ''));
|
||||
$onlyActive = $params['only_active'] ?? null;
|
||||
|
||||
$query = Client::query()->where('tenant_id', $tenantId);
|
||||
|
||||
if ($q !== '') {
|
||||
$query->where(function ($qq) use ($q) {
|
||||
$qq->where('name', 'like', "%{$q}%")
|
||||
->orWhere('client_code', 'like', "%{$q}%")
|
||||
->orWhere('contact_person', 'like', "%{$q}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($onlyActive !== null) {
|
||||
$query->where('is_active', $onlyActive ? 'Y' : 'N');
|
||||
}
|
||||
|
||||
$query->orderBy('client_code')->orderBy('id');
|
||||
|
||||
return $query->paginate($size, ['*'], 'page', $page);
|
||||
}
|
||||
|
||||
/** 단건 */
|
||||
public function show(int $id)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$client = Client::where('tenant_id', $tenantId)->find($id);
|
||||
if (!$client) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
return $client;
|
||||
}
|
||||
|
||||
/** 생성 */
|
||||
public function store(array $params)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$uid = $this->apiUserId();
|
||||
|
||||
$v = Validator::make($params, [
|
||||
'client_code' => 'required|string|max:50',
|
||||
'name' => 'required|string|max:100',
|
||||
'contact_person' => 'nullable|string|max:50',
|
||||
'phone' => 'nullable|string|max:30',
|
||||
'email' => 'nullable|email|max:80',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'is_active' => 'nullable|in:Y,N',
|
||||
]);
|
||||
|
||||
if ($v->fails()) {
|
||||
throw new BadRequestHttpException($v->errors()->first());
|
||||
}
|
||||
|
||||
$data = $v->validated();
|
||||
|
||||
// client_code 중복 검사
|
||||
$exists = Client::where('tenant_id', $tenantId)
|
||||
->where('client_code', $data['client_code'])
|
||||
->exists();
|
||||
if ($exists) {
|
||||
throw new BadRequestHttpException(__('error.duplicate_code'));
|
||||
}
|
||||
|
||||
$data['tenant_id'] = $tenantId;
|
||||
$data['is_active'] = $data['is_active'] ?? 'Y';
|
||||
|
||||
return Client::create($data);
|
||||
}
|
||||
|
||||
/** 수정 */
|
||||
public function update(int $id, array $params)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$uid = $this->apiUserId();
|
||||
|
||||
$client = Client::where('tenant_id', $tenantId)->find($id);
|
||||
if (!$client) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
$v = Validator::make($params, [
|
||||
'client_code' => 'sometimes|required|string|max:50',
|
||||
'name' => 'sometimes|required|string|max:100',
|
||||
'contact_person' => 'nullable|string|max:50',
|
||||
'phone' => 'nullable|string|max:30',
|
||||
'email' => 'nullable|email|max:80',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'is_active' => 'nullable|in:Y,N',
|
||||
]);
|
||||
|
||||
if ($v->fails()) {
|
||||
throw new BadRequestHttpException($v->errors()->first());
|
||||
}
|
||||
|
||||
$payload = $v->validated();
|
||||
|
||||
// client_code 변경 시 중복 검사
|
||||
if (isset($payload['client_code']) && $payload['client_code'] !== $client->client_code) {
|
||||
$exists = Client::where('tenant_id', $tenantId)
|
||||
->where('client_code', $payload['client_code'])
|
||||
->exists();
|
||||
if ($exists) {
|
||||
throw new BadRequestHttpException(__('error.duplicate_code'));
|
||||
}
|
||||
}
|
||||
|
||||
$client->update($payload);
|
||||
return $client->refresh();
|
||||
}
|
||||
|
||||
/** 삭제 */
|
||||
public function destroy(int $id)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$client = Client::where('tenant_id', $tenantId)->find($id);
|
||||
if (!$client) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
// 주문 존재 검사
|
||||
if ($client->orders()->exists()) {
|
||||
throw new BadRequestHttpException(__('error.has_orders'));
|
||||
}
|
||||
|
||||
$client->delete();
|
||||
return 'success';
|
||||
}
|
||||
|
||||
/** 활성/비활성 토글 */
|
||||
public function toggle(int $id)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$client = Client::where('tenant_id', $tenantId)->find($id);
|
||||
if (!$client) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
$client->is_active = $client->is_active === 'Y' ? 'N' : 'Y';
|
||||
$client->save();
|
||||
return $client->refresh();
|
||||
}
|
||||
}
|
||||
@@ -2,28 +2,32 @@
|
||||
|
||||
namespace App\Services\Estimate;
|
||||
|
||||
use App\Models\Commons\Category;
|
||||
use App\Models\Estimate\Estimate;
|
||||
use App\Models\Estimate\EstimateItem;
|
||||
use App\Services\ModelSet\ModelSetService;
|
||||
use App\Services\Service;
|
||||
use App\Services\Calculation\CalculationEngine;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Services\ModelSet\ModelSetService;
|
||||
use App\Services\Pricing\PricingService;
|
||||
use App\Services\Service;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class EstimateService extends Service
|
||||
{
|
||||
protected ModelSetService $modelSetService;
|
||||
|
||||
protected CalculationEngine $calculationEngine;
|
||||
|
||||
protected PricingService $pricingService;
|
||||
|
||||
public function __construct(
|
||||
ModelSetService $modelSetService,
|
||||
CalculationEngine $calculationEngine
|
||||
CalculationEngine $calculationEngine,
|
||||
PricingService $pricingService
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->modelSetService = $modelSetService;
|
||||
$this->calculationEngine = $calculationEngine;
|
||||
$this->pricingService = $pricingService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,37 +39,37 @@ public function getEstimates(array $filters = []): LengthAwarePaginator
|
||||
->where('tenant_id', $this->tenantId());
|
||||
|
||||
// 필터링
|
||||
if (!empty($filters['status'])) {
|
||||
if (! empty($filters['status'])) {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
if (!empty($filters['customer_name'])) {
|
||||
$query->where('customer_name', 'like', '%' . $filters['customer_name'] . '%');
|
||||
if (! empty($filters['customer_name'])) {
|
||||
$query->where('customer_name', 'like', '%'.$filters['customer_name'].'%');
|
||||
}
|
||||
|
||||
if (!empty($filters['model_set_id'])) {
|
||||
if (! empty($filters['model_set_id'])) {
|
||||
$query->where('model_set_id', $filters['model_set_id']);
|
||||
}
|
||||
|
||||
if (!empty($filters['date_from'])) {
|
||||
if (! empty($filters['date_from'])) {
|
||||
$query->whereDate('created_at', '>=', $filters['date_from']);
|
||||
}
|
||||
|
||||
if (!empty($filters['date_to'])) {
|
||||
if (! empty($filters['date_to'])) {
|
||||
$query->whereDate('created_at', '<=', $filters['date_to']);
|
||||
}
|
||||
|
||||
if (!empty($filters['search'])) {
|
||||
if (! empty($filters['search'])) {
|
||||
$searchTerm = $filters['search'];
|
||||
$query->where(function ($q) use ($searchTerm) {
|
||||
$q->where('estimate_name', 'like', '%' . $searchTerm . '%')
|
||||
->orWhere('estimate_no', 'like', '%' . $searchTerm . '%')
|
||||
->orWhere('project_name', 'like', '%' . $searchTerm . '%');
|
||||
$q->where('estimate_name', 'like', '%'.$searchTerm.'%')
|
||||
->orWhere('estimate_no', 'like', '%'.$searchTerm.'%')
|
||||
->orWhere('project_name', 'like', '%'.$searchTerm.'%');
|
||||
});
|
||||
}
|
||||
|
||||
return $query->orderBy('created_at', 'desc')
|
||||
->paginate($filters['per_page'] ?? 20);
|
||||
->paginate($filters['per_page'] ?? 20);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,15 +114,22 @@ public function createEstimate(array $data): array
|
||||
'parameters' => $data['parameters'],
|
||||
'calculated_results' => $bomCalculation['calculated_values'] ?? [],
|
||||
'bom_data' => $bomCalculation,
|
||||
'total_amount' => $bomCalculation['total_amount'] ?? 0,
|
||||
'total_amount' => 0, // 항목 생성 후 재계산
|
||||
'notes' => $data['notes'] ?? null,
|
||||
'valid_until' => now()->addDays(30), // 기본 30일 유효
|
||||
'created_by' => $this->apiUserId(),
|
||||
]);
|
||||
|
||||
// 견적 항목 생성 (BOM 기반)
|
||||
if (!empty($bomCalculation['bom_items'])) {
|
||||
$this->createEstimateItems($estimate, $bomCalculation['bom_items']);
|
||||
// 견적 항목 생성 (BOM 기반) + 가격 계산
|
||||
if (! empty($bomCalculation['bom_items'])) {
|
||||
$totalAmount = $this->createEstimateItems(
|
||||
$estimate,
|
||||
$bomCalculation['bom_items'],
|
||||
$data['client_id'] ?? null
|
||||
);
|
||||
|
||||
// 총액 업데이트
|
||||
$estimate->update(['total_amount' => $totalAmount]);
|
||||
}
|
||||
|
||||
return $this->getEstimateDetail($estimate->id);
|
||||
@@ -143,12 +154,16 @@ public function updateEstimate($estimateId, array $data): array
|
||||
|
||||
$data['calculated_results'] = $bomCalculation['calculated_values'] ?? [];
|
||||
$data['bom_data'] = $bomCalculation;
|
||||
$data['total_amount'] = $bomCalculation['total_amount'] ?? 0;
|
||||
|
||||
// 기존 견적 항목 삭제 후 재생성
|
||||
$estimate->items()->delete();
|
||||
if (!empty($bomCalculation['bom_items'])) {
|
||||
$this->createEstimateItems($estimate, $bomCalculation['bom_items']);
|
||||
if (! empty($bomCalculation['bom_items'])) {
|
||||
$totalAmount = $this->createEstimateItems(
|
||||
$estimate,
|
||||
$bomCalculation['bom_items'],
|
||||
$data['client_id'] ?? null
|
||||
);
|
||||
$data['total_amount'] = $totalAmount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,13 +264,13 @@ public function changeEstimateStatus($estimateId, string $status, ?string $notes
|
||||
'EXPIRED' => ['DRAFT'],
|
||||
];
|
||||
|
||||
if (!in_array($status, $validTransitions[$estimate->status] ?? [])) {
|
||||
if (! in_array($status, $validTransitions[$estimate->status] ?? [])) {
|
||||
throw new \Exception(__('error.estimate.invalid_status_transition'));
|
||||
}
|
||||
|
||||
$estimate->update([
|
||||
'status' => $status,
|
||||
'notes' => $notes ? ($estimate->notes . "\n\n" . $notes) : $estimate->notes,
|
||||
'notes' => $notes ? ($estimate->notes."\n\n".$notes) : $estimate->notes,
|
||||
'updated_by' => $this->apiUserId(),
|
||||
]);
|
||||
|
||||
@@ -288,25 +303,63 @@ public function previewCalculation($modelSetId, array $parameters): array
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적 항목 생성
|
||||
* 견적 항목 생성 (가격 계산 포함)
|
||||
*
|
||||
* @return float 총 견적 금액
|
||||
*/
|
||||
protected function createEstimateItems(Estimate $estimate, array $bomItems): void
|
||||
protected function createEstimateItems(Estimate $estimate, array $bomItems, ?int $clientId = null): float
|
||||
{
|
||||
$totalAmount = 0;
|
||||
$warnings = [];
|
||||
|
||||
foreach ($bomItems as $index => $bomItem) {
|
||||
$quantity = $bomItem['quantity'] ?? 1;
|
||||
$unitPrice = 0;
|
||||
|
||||
// 가격 조회 (item_id와 item_type이 있는 경우)
|
||||
if (isset($bomItem['item_id']) && isset($bomItem['item_type'])) {
|
||||
$priceResult = $this->pricingService->getItemPrice(
|
||||
$bomItem['item_type'], // 'PRODUCT' or 'MATERIAL'
|
||||
$bomItem['item_id'],
|
||||
$clientId,
|
||||
now()->format('Y-m-d')
|
||||
);
|
||||
|
||||
$unitPrice = $priceResult['price'] ?? 0;
|
||||
|
||||
if ($priceResult['warning']) {
|
||||
$warnings[] = $priceResult['warning'];
|
||||
}
|
||||
}
|
||||
|
||||
$totalPrice = $unitPrice * $quantity;
|
||||
$totalAmount += $totalPrice;
|
||||
|
||||
EstimateItem::create([
|
||||
'tenant_id' => $this->tenantId(),
|
||||
'estimate_id' => $estimate->id,
|
||||
'sequence' => $index + 1,
|
||||
'item_name' => $bomItem['name'] ?? '견적 항목 ' . ($index + 1),
|
||||
'item_name' => $bomItem['name'] ?? '견적 항목 '.($index + 1),
|
||||
'item_description' => $bomItem['description'] ?? '',
|
||||
'parameters' => $bomItem['parameters'] ?? [],
|
||||
'calculated_values' => $bomItem['calculated_values'] ?? [],
|
||||
'unit_price' => $bomItem['unit_price'] ?? 0,
|
||||
'quantity' => $bomItem['quantity'] ?? 1,
|
||||
'unit_price' => $unitPrice,
|
||||
'quantity' => $quantity,
|
||||
'total_price' => $totalPrice,
|
||||
'bom_components' => $bomItem['components'] ?? [],
|
||||
'created_by' => $this->apiUserId(),
|
||||
]);
|
||||
}
|
||||
|
||||
// 가격 경고가 있으면 로그 기록
|
||||
if (! empty($warnings)) {
|
||||
\Log::warning('견적 가격 조회 경고', [
|
||||
'estimate_id' => $estimate->id,
|
||||
'warnings' => $warnings,
|
||||
]);
|
||||
}
|
||||
|
||||
return $totalAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -321,19 +374,19 @@ protected function summarizeCalculations(Estimate $estimate): array
|
||||
];
|
||||
|
||||
// 주요 계산 결과 추출
|
||||
if (!empty($estimate->calculated_results)) {
|
||||
if (! empty($estimate->calculated_results)) {
|
||||
$results = $estimate->calculated_results;
|
||||
|
||||
if (isset($results['W1'], $results['H1'])) {
|
||||
$summary['key_calculations']['제작사이즈'] = $results['W1'] . ' × ' . $results['H1'] . ' mm';
|
||||
$summary['key_calculations']['제작사이즈'] = $results['W1'].' × '.$results['H1'].' mm';
|
||||
}
|
||||
|
||||
if (isset($results['weight'])) {
|
||||
$summary['key_calculations']['중량'] = $results['weight'] . ' kg';
|
||||
$summary['key_calculations']['중량'] = $results['weight'].' kg';
|
||||
}
|
||||
|
||||
if (isset($results['area'])) {
|
||||
$summary['key_calculations']['면적'] = $results['area'] . ' ㎡';
|
||||
$summary['key_calculations']['면적'] = $results['area'].' ㎡';
|
||||
}
|
||||
|
||||
if (isset($results['bracket_size'])) {
|
||||
@@ -343,4 +396,4 @@ protected function summarizeCalculations(Estimate $estimate): array
|
||||
|
||||
return $summary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
196
app/Services/Pricing/PricingService.php
Normal file
196
app/Services/Pricing/PricingService.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Pricing;
|
||||
|
||||
use App\Models\Orders\Client;
|
||||
use App\Models\Products\PriceHistory;
|
||||
use App\Services\Service;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class PricingService extends Service
|
||||
{
|
||||
/**
|
||||
* 특정 항목(제품/자재)의 단가를 조회
|
||||
*
|
||||
* @param string $itemType 'PRODUCT' | 'MATERIAL'
|
||||
* @param int $itemId 제품/자재 ID
|
||||
* @param int|null $clientId 고객 ID (NULL이면 기본 가격)
|
||||
* @param string|null $date 기준일 (NULL이면 오늘)
|
||||
* @return array ['price' => float|null, 'price_history_id' => int|null, 'client_group_id' => int|null, 'warning' => string|null]
|
||||
*/
|
||||
public function getItemPrice(string $itemType, int $itemId, ?int $clientId = null, ?string $date = null): array
|
||||
{
|
||||
$date = $date ?? Carbon::today()->format('Y-m-d');
|
||||
$clientGroupId = null;
|
||||
|
||||
// 1. 고객의 그룹 ID 확인
|
||||
if ($clientId) {
|
||||
$client = Client::where('tenant_id', $this->tenantId())
|
||||
->where('id', $clientId)
|
||||
->first();
|
||||
|
||||
if ($client) {
|
||||
$clientGroupId = $client->client_group_id;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 가격 조회 (우선순위대로)
|
||||
$priceHistory = null;
|
||||
|
||||
// 1순위: 고객 그룹별 매출단가
|
||||
if ($clientGroupId) {
|
||||
$priceHistory = $this->findPrice($itemType, $itemId, $clientGroupId, $date);
|
||||
}
|
||||
|
||||
// 2순위: 기본 매출단가 (client_group_id = NULL)
|
||||
if (! $priceHistory) {
|
||||
$priceHistory = $this->findPrice($itemType, $itemId, null, $date);
|
||||
}
|
||||
|
||||
// 3순위: NULL (경고)
|
||||
if (! $priceHistory) {
|
||||
return [
|
||||
'price' => null,
|
||||
'price_history_id' => null,
|
||||
'client_group_id' => null,
|
||||
'warning' => __('error.price_not_found', [
|
||||
'item_type' => $itemType,
|
||||
'item_id' => $itemId,
|
||||
'date' => $date,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'price' => (float) $priceHistory->price,
|
||||
'price_history_id' => $priceHistory->id,
|
||||
'client_group_id' => $priceHistory->client_group_id,
|
||||
'warning' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 가격 이력에서 유효한 가격 조회
|
||||
*/
|
||||
private function findPrice(string $itemType, int $itemId, ?int $clientGroupId, string $date): ?PriceHistory
|
||||
{
|
||||
return PriceHistory::where('tenant_id', $this->tenantId())
|
||||
->forItem($itemType, $itemId)
|
||||
->forClientGroup($clientGroupId)
|
||||
->salePrice()
|
||||
->validAt($date)
|
||||
->orderBy('started_at', 'desc')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 항목의 단가를 일괄 조회
|
||||
*
|
||||
* @param array $items [['item_type' => 'PRODUCT', 'item_id' => 1], ...]
|
||||
* @return array ['prices' => [...], 'warnings' => [...]]
|
||||
*/
|
||||
public function getBulkItemPrices(array $items, ?int $clientId = null, ?string $date = null): array
|
||||
{
|
||||
$prices = [];
|
||||
$warnings = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$result = $this->getItemPrice(
|
||||
$item['item_type'],
|
||||
$item['item_id'],
|
||||
$clientId,
|
||||
$date
|
||||
);
|
||||
|
||||
$prices[] = array_merge($item, [
|
||||
'price' => $result['price'],
|
||||
'price_history_id' => $result['price_history_id'],
|
||||
'client_group_id' => $result['client_group_id'],
|
||||
]);
|
||||
|
||||
if ($result['warning']) {
|
||||
$warnings[] = $result['warning'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'prices' => $prices,
|
||||
'warnings' => $warnings,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 가격 등록/수정
|
||||
*/
|
||||
public function upsertPrice(array $data): PriceHistory
|
||||
{
|
||||
$data['tenant_id'] = $this->tenantId();
|
||||
$data['created_by'] = $this->apiUserId();
|
||||
$data['updated_by'] = $this->apiUserId();
|
||||
|
||||
// 중복 확인: 동일 조건(item, client_group, date 범위)의 가격이 이미 있는지
|
||||
$existing = PriceHistory::where('tenant_id', $data['tenant_id'])
|
||||
->where('item_type_code', $data['item_type_code'])
|
||||
->where('item_id', $data['item_id'])
|
||||
->where('price_type_code', $data['price_type_code'])
|
||||
->where('client_group_id', $data['client_group_id'] ?? null)
|
||||
->where('started_at', $data['started_at'])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->update($data);
|
||||
|
||||
return $existing->fresh();
|
||||
}
|
||||
|
||||
return PriceHistory::create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 가격 이력 조회 (페이지네이션)
|
||||
*
|
||||
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
|
||||
*/
|
||||
public function listPrices(array $filters = [], int $perPage = 15)
|
||||
{
|
||||
$query = PriceHistory::where('tenant_id', $this->tenantId());
|
||||
|
||||
if (isset($filters['item_type_code'])) {
|
||||
$query->where('item_type_code', $filters['item_type_code']);
|
||||
}
|
||||
|
||||
if (isset($filters['item_id'])) {
|
||||
$query->where('item_id', $filters['item_id']);
|
||||
}
|
||||
|
||||
if (isset($filters['price_type_code'])) {
|
||||
$query->where('price_type_code', $filters['price_type_code']);
|
||||
}
|
||||
|
||||
if (isset($filters['client_group_id'])) {
|
||||
$query->where('client_group_id', $filters['client_group_id']);
|
||||
}
|
||||
|
||||
if (isset($filters['date'])) {
|
||||
$query->validAt($filters['date']);
|
||||
}
|
||||
|
||||
return $query->orderBy('started_at', 'desc')
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 가격 삭제 (Soft Delete)
|
||||
*/
|
||||
public function deletePrice(int $id): bool
|
||||
{
|
||||
$price = PriceHistory::where('tenant_id', $this->tenantId())
|
||||
->findOrFail($id);
|
||||
|
||||
$price->deleted_by = $this->apiUserId();
|
||||
$price->save();
|
||||
|
||||
return $price->delete();
|
||||
}
|
||||
}
|
||||
185
app/Swagger/v1/ClientApi.php
Normal file
185
app/Swagger/v1/ClientApi.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace App\Swagger\v1;
|
||||
|
||||
/**
|
||||
* @OA\Tag(name="Client", description="거래처 관리")
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="Client",
|
||||
* type="object",
|
||||
* required={"id","client_code","name"},
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="tenant_id", type="integer", example=1),
|
||||
* @OA\Property(property="client_code", type="string", example="CLIENT_001"),
|
||||
* @OA\Property(property="name", type="string", example="거래처명"),
|
||||
* @OA\Property(property="contact_person", type="string", nullable=true, example="홍길동"),
|
||||
* @OA\Property(property="phone", type="string", nullable=true, example="010-1234-5678"),
|
||||
* @OA\Property(property="email", type="string", nullable=true, example="client@example.com"),
|
||||
* @OA\Property(property="address", type="string", nullable=true, example="서울시 강남구"),
|
||||
* @OA\Property(property="is_active", type="string", enum={"Y", "N"}, example="Y"),
|
||||
* @OA\Property(property="created_at", type="string", example="2025-10-01 12:00:00"),
|
||||
* @OA\Property(property="updated_at", type="string", example="2025-10-01 12:00:00")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="ClientPagination",
|
||||
* type="object",
|
||||
* @OA\Property(property="current_page", type="integer", example=1),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="array",
|
||||
* @OA\Items(ref="#/components/schemas/Client")
|
||||
* ),
|
||||
* @OA\Property(property="first_page_url", type="string", example="/api/v1/clients?page=1"),
|
||||
* @OA\Property(property="from", type="integer", example=1),
|
||||
* @OA\Property(property="last_page", type="integer", example=3),
|
||||
* @OA\Property(property="last_page_url", type="string", example="/api/v1/clients?page=3"),
|
||||
* @OA\Property(
|
||||
* property="links",
|
||||
* type="array",
|
||||
* @OA\Items(type="object",
|
||||
* @OA\Property(property="url", type="string", nullable=true, example=null),
|
||||
* @OA\Property(property="label", type="string", example="« Previous"),
|
||||
* @OA\Property(property="active", type="boolean", example=false)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Property(property="next_page_url", type="string", nullable=true, example="/api/v1/clients?page=2"),
|
||||
* @OA\Property(property="path", type="string", example="/api/v1/clients"),
|
||||
* @OA\Property(property="per_page", type="integer", example=20),
|
||||
* @OA\Property(property="prev_page_url", type="string", nullable=true, example=null),
|
||||
* @OA\Property(property="to", type="integer", example=20),
|
||||
* @OA\Property(property="total", type="integer", example=50)
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="ClientCreateRequest",
|
||||
* type="object",
|
||||
* required={"client_code","name"},
|
||||
* @OA\Property(property="client_code", type="string", maxLength=50, example="CLIENT_001"),
|
||||
* @OA\Property(property="name", type="string", maxLength=100, example="거래처명"),
|
||||
* @OA\Property(property="contact_person", type="string", nullable=true, maxLength=100, example="홍길동"),
|
||||
* @OA\Property(property="phone", type="string", nullable=true, maxLength=20, example="010-1234-5678"),
|
||||
* @OA\Property(property="email", type="string", nullable=true, maxLength=100, example="client@example.com"),
|
||||
* @OA\Property(property="address", type="string", nullable=true, maxLength=255, example="서울시 강남구"),
|
||||
* @OA\Property(property="is_active", type="string", enum={"Y", "N"}, example="Y")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="ClientUpdateRequest",
|
||||
* type="object",
|
||||
* @OA\Property(property="client_code", type="string", maxLength=50),
|
||||
* @OA\Property(property="name", type="string", maxLength=100),
|
||||
* @OA\Property(property="contact_person", type="string", nullable=true, maxLength=100),
|
||||
* @OA\Property(property="phone", type="string", nullable=true, maxLength=20),
|
||||
* @OA\Property(property="email", type="string", nullable=true, maxLength=100),
|
||||
* @OA\Property(property="address", type="string", nullable=true, maxLength=255),
|
||||
* @OA\Property(property="is_active", type="string", enum={"Y", "N"})
|
||||
* )
|
||||
*/
|
||||
class ClientApi
|
||||
{
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/clients",
|
||||
* tags={"Client"},
|
||||
* summary="거래처 목록",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
* @OA\Parameter(name="page", in="query", @OA\Schema(type="integer", example=1)),
|
||||
* @OA\Parameter(name="size", in="query", @OA\Schema(type="integer", example=20)),
|
||||
* @OA\Parameter(name="q", in="query", description="거래처 코드/이름 검색", @OA\Schema(type="string")),
|
||||
* @OA\Parameter(name="only_active", in="query", @OA\Schema(type="boolean")),
|
||||
* @OA\Response(response=200, description="조회 성공",
|
||||
* @OA\JsonContent(allOf={
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ClientPagination"))
|
||||
* })
|
||||
* ),
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function index() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/clients/{id}",
|
||||
* tags={"Client"},
|
||||
* summary="거래처 단건 조회",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Response(response=200, description="조회 성공",
|
||||
* @OA\JsonContent(allOf={
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Client"))
|
||||
* })
|
||||
* ),
|
||||
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function show() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/clients",
|
||||
* tags={"Client"},
|
||||
* summary="거래처 생성",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/ClientCreateRequest")),
|
||||
* @OA\Response(response=200, description="생성 성공",
|
||||
* @OA\JsonContent(allOf={
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Client"))
|
||||
* })
|
||||
* ),
|
||||
* @OA\Response(response=400, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function store() {}
|
||||
|
||||
/**
|
||||
* @OA\Put(
|
||||
* path="/api/v1/clients/{id}",
|
||||
* tags={"Client"},
|
||||
* summary="거래처 수정",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/ClientUpdateRequest")),
|
||||
* @OA\Response(response=200, description="수정 성공",
|
||||
* @OA\JsonContent(allOf={
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Client"))
|
||||
* })
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function update() {}
|
||||
|
||||
/**
|
||||
* @OA\Delete(
|
||||
* path="/api/v1/clients/{id}",
|
||||
* tags={"Client"},
|
||||
* summary="거래처 삭제(soft)",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Response(response=200, description="삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse"))
|
||||
* )
|
||||
*/
|
||||
public function destroy() {}
|
||||
|
||||
/**
|
||||
* @OA\Patch(
|
||||
* path="/api/v1/clients/{id}/toggle",
|
||||
* tags={"Client"},
|
||||
* summary="활성/비활성 토글",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Response(response=200, description="변경 성공",
|
||||
* @OA\JsonContent(allOf={
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Client"))
|
||||
* })
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function toggle() {}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?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::create('client_groups', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
|
||||
$table->string('group_code', 30)->comment('그룹 코드');
|
||||
$table->string('group_name', 100)->comment('그룹명');
|
||||
$table->decimal('price_rate', 5, 4)->default(1.0000)->comment('가격 배율 (1.0 = 기준가, 0.9 = 90%, 1.1 = 110%)');
|
||||
$table->tinyInteger('is_active')->default(1)->comment('활성 여부 (1=활성, 0=비활성)');
|
||||
|
||||
// 감사 컬럼
|
||||
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자 ID');
|
||||
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자 ID');
|
||||
$table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자 ID');
|
||||
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
// 인덱스
|
||||
$table->index(['tenant_id', 'is_active']);
|
||||
$table->unique(['tenant_id', 'group_code'], 'uq_client_groups_tenant_code');
|
||||
|
||||
// 외래키
|
||||
$table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('client_groups');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?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('clients', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('client_group_id')
|
||||
->nullable()
|
||||
->after('tenant_id')
|
||||
->comment('고객 그룹 ID (NULL = 기본 그룹)');
|
||||
|
||||
$table->index(['tenant_id', 'client_group_id']);
|
||||
$table->foreign('client_group_id')->references('id')->on('client_groups')->onDelete('set null');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('clients', function (Blueprint $table) {
|
||||
$table->dropForeign(['client_group_id']);
|
||||
$table->dropIndex(['tenant_id', 'client_group_id']);
|
||||
$table->dropColumn('client_group_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
<?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('price_histories', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('client_group_id')
|
||||
->nullable()
|
||||
->after('price_type_code')
|
||||
->comment('고객 그룹 ID (NULL = 기본 가격, 값 있으면 그룹별 차등 가격)');
|
||||
|
||||
// 기존 인덱스에 client_group_id 추가
|
||||
$table->dropIndex('idx_price_histories_main');
|
||||
$table->index(['tenant_id', 'item_type_code', 'item_id', 'client_group_id', 'started_at'], 'idx_price_histories_main');
|
||||
|
||||
$table->foreign('client_group_id')->references('id')->on('client_groups')->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('price_histories', function (Blueprint $table) {
|
||||
$table->dropForeign(['client_group_id']);
|
||||
$table->dropIndex('idx_price_histories_main');
|
||||
$table->index(['tenant_id', 'item_type_code', 'item_id', 'started_at'], 'idx_price_histories_main');
|
||||
$table->dropColumn('client_group_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -67,4 +67,7 @@
|
||||
'invalid_file_type' => '허용되지 않는 파일 형식입니다.',
|
||||
'file_too_large' => '파일 크기가 너무 큽니다.',
|
||||
],
|
||||
|
||||
// 가격 관리 관련
|
||||
'price_not_found' => ':item_type ID :item_id 항목의 :date 기준 매출단가를 찾을 수 없습니다.',
|
||||
];
|
||||
|
||||
2502
relationships.txt
Normal file
2502
relationships.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@
|
||||
use App\Http\Controllers\Api\V1\CategoryFieldController;
|
||||
use App\Http\Controllers\Api\V1\CategoryTemplateController;
|
||||
use App\Http\Controllers\Api\V1\ClassificationController;
|
||||
use App\Http\Controllers\Api\V1\ClientController;
|
||||
|
||||
|
||||
// 설계 전용 (디자인 네임스페이스)
|
||||
@@ -281,12 +282,29 @@
|
||||
Route::delete('/{id}', [ClassificationController::class, 'destroy'])->whereNumber('id')->name('v1.classifications.destroy'); // 삭제
|
||||
});
|
||||
|
||||
// Clients (거래처 관리)
|
||||
Route::prefix('clients')->group(function () {
|
||||
Route::get ('', [ClientController::class, 'index'])->name('v1.clients.index'); // 목록
|
||||
Route::post ('', [ClientController::class, 'store'])->name('v1.clients.store'); // 생성
|
||||
Route::get ('/{id}', [ClientController::class, 'show'])->whereNumber('id')->name('v1.clients.show'); // 단건
|
||||
Route::put ('/{id}', [ClientController::class, 'update'])->whereNumber('id')->name('v1.clients.update'); // 수정
|
||||
Route::delete('/{id}', [ClientController::class, 'destroy'])->whereNumber('id')->name('v1.clients.destroy'); // 삭제
|
||||
Route::patch ('/{id}/toggle', [ClientController::class, 'toggle'])->whereNumber('id')->name('v1.clients.toggle'); // 활성/비활성
|
||||
});
|
||||
|
||||
// Products & Materials (제품/자재 통합 관리)
|
||||
Route::prefix('products')->group(function (){
|
||||
|
||||
// 제품 카테고리 (기존 product/category에서 이동)
|
||||
Route::get ('/categories', [ProductController::class, 'getCategory'])->name('v1.products.categories'); // 제품 카테고리
|
||||
|
||||
// 자재 관리 (기존 독립 materials에서 이동) - ProductController 기본 라우팅보다 앞에 위치
|
||||
Route::get ('/materials', [MaterialController::class, 'index'])->name('v1.products.materials.index'); // 자재 목록
|
||||
Route::post ('/materials', [MaterialController::class, 'store'])->name('v1.products.materials.store'); // 자재 생성
|
||||
Route::get ('/materials/{id}', [MaterialController::class, 'show'])->name('v1.products.materials.show'); // 자재 단건
|
||||
Route::patch ('/materials/{id}', [MaterialController::class, 'update'])->name('v1.products.materials.update'); // 자재 수정
|
||||
Route::delete('/materials/{id}', [MaterialController::class, 'destroy'])->name('v1.products.materials.destroy'); // 자재 삭제
|
||||
|
||||
// (선택) 드롭다운/모달용 간편 검색 & 활성 토글
|
||||
Route::get ('/search', [ProductController::class, 'search'])->name('v1.products.search');
|
||||
Route::post ('/{id}/toggle', [ProductController::class, 'toggle'])->name('v1.products.toggle');
|
||||
@@ -300,13 +318,6 @@
|
||||
// BOM 카테고리
|
||||
Route::get('bom/categories', [ProductBomItemController::class, 'suggestCategories'])->name('v1.products.bom.categories.suggest'); // 전역(테넌트) 추천
|
||||
Route::get('{id}/bom/categories', [ProductBomItemController::class, 'listCategories'])->name('v1.products.bom.categories'); // 해당 제품에서 사용 중
|
||||
|
||||
// 자재 관리 (기존 독립 materials에서 이동)
|
||||
Route::get ('/materials', [MaterialController::class, 'index'])->name('v1.products.materials.index'); // 자재 목록
|
||||
Route::post ('/materials', [MaterialController::class, 'store'])->name('v1.products.materials.store'); // 자재 생성
|
||||
Route::get ('/materials/{id}', [MaterialController::class, 'show'])->name('v1.products.materials.show'); // 자재 단건
|
||||
Route::patch ('/materials/{id}', [MaterialController::class, 'update'])->name('v1.products.materials.update'); // 자재 수정
|
||||
Route::delete('/materials/{id}', [MaterialController::class, 'destroy'])->name('v1.products.materials.destroy'); // 자재 삭제
|
||||
});
|
||||
|
||||
// BOM (product_components: ref_type=PRODUCT|MATERIAL)
|
||||
|
||||
Reference in New Issue
Block a user