feat: 견적 단가 자동 적용 기능 추가

- 고객 그룹별 단가 조정 지원
- 견적 생성 시 자동 단가 조회
- 매출단가만 사용 (매입단가는 경고)
This commit is contained in:
2025-10-13 21:52:34 +09:00
parent be36073282
commit a6b06be61d
17 changed files with 3794 additions and 47 deletions

View File

@@ -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

View File

@@ -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 제약조건 최적화 및 데이터베이스 성능 개선
### 주요 작업

View 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')];
});
}
}

View File

@@ -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'); }

View 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);
}
}

View 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);
}
}

View File

@@ -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');
}
}

View 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();
}
}

View File

@@ -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;
}
}
}

View 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();
}
}

View 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="&laquo; 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() {}
}

View File

@@ -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');
}
};

View File

@@ -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');
});
}
};

View File

@@ -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');
});
}
};

View File

@@ -67,4 +67,7 @@
'invalid_file_type' => '허용되지 않는 파일 형식입니다.',
'file_too_large' => '파일 크기가 너무 큽니다.',
],
// 가격 관리 관련
'price_not_found' => ':item_type ID :item_id 항목의 :date 기준 매출단가를 찾을 수 없습니다.',
];

2502
relationships.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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)