feat: 견적 단가 자동 적용 기능 추가
- 고객 그룹별 단가 조정 지원 - 견적 생성 시 자동 단가 조회 - 매출단가만 사용 (매입단가는 경고)
This commit is contained in:
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() {}
|
||||
}
|
||||
Reference in New Issue
Block a user