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

@@ -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() {}
}